Merge remote-tracking branch 'origin/master' into codex/truncated-design

# Conflicts:
#	docs/config-catalog.md
#	docs/rfc/INDEX.md
#	examples/acp-agent/README.md
#	packages/bash/tool-bash/tests/tools.spec.ts
This commit is contained in:
Dudu-0223
2026-07-14 10:57:29 +08:00
245 changed files with 13168 additions and 2073 deletions

View File

@@ -26,7 +26,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency primitives shared across groups (branding, timeout, retention) | Support — small, stable, harness-dep-free |

View File

@@ -9,4 +9,4 @@ The canonical three-package capability seam (see [capability seams](../../docs/r
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [examples/sandbox-acp-agent](../../examples/sandbox-acp-agent/)).
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)).

View File

@@ -30,4 +30,4 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
workspaceRoot: !!js process.cwd()
```
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [`examples/sandbox-acp-agent`](../../../examples/sandbox-acp-agent/) for the runnable demo.
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.

View File

@@ -102,16 +102,7 @@ async function callUntilText(
throw new Error(`${name} output did not include ${JSON.stringify(expected)}; last text was ${JSON.stringify(last !== undefined ? text(last) : '')}`)
}
class LossyReadBashExecutor extends BashExecutor {
private readonly task: BashTask = {
id: BashTaskId('bash-lossy'),
command: 'fake',
status: 'running',
exitCode: null,
signal: null,
done: Promise.resolve(),
}
abstract class TestBashExecutor extends BashExecutor {
resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
@@ -123,6 +114,17 @@ class LossyReadBashExecutor extends BashExecutor {
sandboxMode: request.sandboxMode,
}
}
}
class LossyReadBashExecutor extends TestBashExecutor {
private readonly task: BashTask = {
id: BashTaskId('bash-lossy'),
command: 'fake',
status: 'running',
exitCode: null,
signal: null,
done: Promise.resolve(),
}
run(): Promise<BashRunResult> {
return Promise.reject(new Error('not used'))
@@ -1064,7 +1066,7 @@ describe('sandbox rendering', () => {
// it anyway: an executor that reports no sandboxMode (fields never
// advertised) whose task nonetheless carries denial facts must render
// the marker without suggesting a lever the schema does not offer.
class FactsOnlyExecutor extends BashExecutor {
class FactsOnlyExecutor extends TestBashExecutor {
private readonly task: BashTask = {
id: BashTaskId('bash-facts'),
command: 'fake',
@@ -1075,18 +1077,6 @@ describe('sandbox rendering', () => {
sandbox: { mode: 'read-only', denied: true },
}
resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 0,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...request.signal ? { signal: request.signal } : {},
owner: request.owner,
sandboxMode: request.sandboxMode,
}
}
run(): Promise<BashRunResult> { return Promise.reject(new Error('not used')) }
start(): BashTask { return this.task }
get(id: string): BashTask | undefined { return id === this.task.id ? this.task : undefined }

View File

@@ -29,4 +29,4 @@ Every field is validated (positive numbers) and defaulted; there are no other tu
## The worker entry, unbuilt and built
`worker.ts` is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns `src/worker.ts` directly and Node's native type stripping loads it; built, the entry ships as the sibling bundle `lib/worker.js` (its own tsdown entry). The built path is pinned by `tests/built-lib.e2e.ts`, the real-load-path guard from [docs/testing.md](../../../docs/testing.md).
`worker.ts` is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns `src/worker.ts` directly and Node's native type stripping loads it; built, the entry ships as the sibling CommonJS bundle `lib/worker.cjs` (its own tsdown entry). The CommonJS format is required because pkg's VFS Worker hook compiles filesystem-string entries as CommonJS. The host converts either entry URL to a filesystem string before constructing `Worker`, which works through both ordinary Node resolution and that pkg hook. The built path is pinned by `tests/built-lib.e2e.ts`, the real-load-path guard from [docs/testing.md](../../../docs/testing.md).

View File

@@ -13,14 +13,14 @@
},
"./worker": {
"types": "./lib/types/worker.d.ts",
"default": "./lib/worker.js"
"default": "./lib/worker.cjs"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/worker.js",
"lib/worker.cjs",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"

View File

@@ -14,6 +14,7 @@
import { Worker } from 'node:worker_threads'
import { stripTypeScriptTypes } from 'node:module'
import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import z from 'schemastery'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
@@ -98,16 +99,19 @@ interface LiveRun {
}
/**
* The worker entry module. Source runs unbuilt (`src/worker.ts`, loadable
* The worker entry path. Source runs unbuilt (`src/worker.ts`, loadable
* directly on this repo's Node range via native type stripping — the file
* is erasable-only with type-only relative imports); the built package
* ships it as a sibling bundle (`lib/worker.js`, its own tsdown entry).
* ships it as a sibling CommonJS bundle (`lib/worker.cjs`, its own tsdown
* entry) because pkg's VFS Worker hook compiles string-path entries as
* CommonJS.
* The URL *pathname*'s extension says which world this module is in —
* pathname, because dev-time module runners (vitest) may suffix
* `import.meta.url` with a query string; relative resolution drops it.
* `import.meta.url` with a query string; relative resolution drops it. Worker
* receives a filesystem string so pkg's VFS Worker hook can resolve it.
*/
/* v8 ignore next -- the './worker.js' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */
const WORKER_URL = new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.js', import.meta.url)
/* v8 ignore next -- the './worker.cjs' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */
const WORKER_PATH = fileURLToPath(new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.cjs', import.meta.url))
/** Render an unknown thrown value as a message, `Error` or not. */
function messageOf(error: unknown): string {
@@ -273,7 +277,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
maxLogBytes: this.config.maxLogBytes,
maxValueBytes: this.config.maxValueBytes,
}
const worker = new Worker(WORKER_URL, {
const worker = new Worker(WORKER_PATH, {
workerData: bootData,
// Model code gets NO ambient environment — stronger than the scrubbed
// env the defensive-patterns rule requires for spawned commands.

View File

@@ -17,4 +17,4 @@ import type { WorkerBootData } from './protocol.ts'
// A worker always has a parent port; guard loudly rather than run detached.
if (!parentPort) throw new Error('dsh-code-runtime-worker: worker entry loaded outside a worker thread')
await runWorkerMain(parentPort, workerData as WorkerBootData, { stdout: process.stdout, stderr: process.stderr })
void runWorkerMain(parentPort, workerData as WorkerBootData, { stdout: process.stdout, stderr: process.stderr })

View File

@@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest'
* BUILT-ARTIFACT smoke for the published package (the real-load-path guard
* from docs/testing.md): the unit suite runs `src/` under vitest, where the
* worker entry resolves to `src/worker.ts` — a consumer runs `lib/index.js`
* under plain `node`, where it must resolve the sibling `lib/worker.js`
* under plain `node`, where it must resolve the sibling `lib/worker.cjs`
* bundle instead. This spawns plain `node` (NOT tsx) from inside the package
* directory and imports the package BY NAME, so resolution flows through the
* real `exports` map exactly as it would from a downstream install; the
@@ -21,11 +21,11 @@ import { describe, expect, it } from 'vitest'
*/
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
const built = ['lib/index.js', 'lib/worker.js'].every(file => existsSync(join(pkgDir, file)))
const built = ['lib/index.js', 'lib/worker.cjs'].every(file => existsSync(join(pkgDir, file)))
&& existsSync(join(pkgDir, '../code-runtime/lib/index.js'))
describe.skipIf(!built)('built lib real load path (plain node)', () => {
it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.js entry', async () => {
it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.cjs entry', async () => {
const script = `
const { Context } = await import('cordis')
const { WorkerCodeRuntime } = await import('@deepseek-ai/dsh-code-runtime-worker')

View File

@@ -3,8 +3,10 @@ import { defineConfig } from 'tsdown'
/**
* Package-shape override (see the root tsdown.config.ts): besides the
* default lib/index.js bundle, the worker BOOTSTRAP ships as its own
* sibling entry — `new Worker(new URL('./worker.js', import.meta.url))`
* loads it as a file, so it cannot be part of the index bundle. TWO
* sibling CommonJS entry — `new Worker(fileURLToPath(new URL('./worker.cjs', import.meta.url)))`
* loads it as a file, so it cannot be part of the index bundle. pkg's VFS
* Worker hook compiles string-path entries as CommonJS, so an ESM worker is
* not viable inside the executable. TWO
* single-entry builds, not one two-entry build: a multi-entry build emits
* the shared bootstrap module as a `lib/bootstrap-*.js` chunk both bundles
* import, which the package.json `files` whitelist (deliberately exact)
@@ -25,7 +27,7 @@ export default defineConfig([
{
entry: ['lib/types/worker.js'],
outDir: 'lib',
format: ['esm'],
format: ['cjs'],
platform: 'node',
target: 'es2024',
fixedExtension: false,

View File

@@ -134,6 +134,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
],
},
{
key: 'permission',
summary: 'The permission service (`ctx.permission`).',
methods: [
'current(events: readonly SessionEvent[]): string',
'resolve(name: string): PresetSpec',
'optionOf(name: string): PresetOption',
'set(session: Session, name: string): void',
],
},
{
key: 'sandbox',
summary: 'Abstract process-sandbox service.',
@@ -521,6 +531,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ApprovalOutcome',
declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';',
},
{
name: 'ApprovalPolicy',
declaration: 'export type ApprovalPolicy = \'ask\' | \'never\';',
},
{
name: 'ApprovalRequest',
declaration: 'export interface ApprovalRequest {\n readonly agent: Agent;\n readonly toolName: string;\n readonly callId?: CallId;\n readonly reason?: string;\n readonly signal?: AbortSignal;\n}',
@@ -749,6 +763,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'OwnerToken',
declaration: 'export type OwnerToken = Branded<\'OwnerToken\'>;',
},
{
name: 'PresetOption',
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
},
{
name: 'PresetSpec',
declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}',
},
{
name: 'PromptAssembly',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',

View File

@@ -53,6 +53,9 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
for (const event of CODEX_EVENTS) {
const rawGroups = hooksMap[event]
// Matcher-group parsing remains dialect-local because the supported hook
// shapes and skip reasons differ from Claude Code's.
/* jscpd:ignore-start */
if (!Array.isArray(rawGroups)) continue
const groups: MatcherGroup[] = []
for (const rawGroup of rawGroups) {
@@ -64,6 +67,7 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
if (!hook) continue
const type = typeof hook.type === 'string' ? hook.type : 'command'
if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue }
/* jscpd:ignore-end */
if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue }
if (typeof hook.command !== 'string') continue
// Codex accepts `timeout` or the `timeoutSec` alias.

View File

@@ -15,6 +15,9 @@
* @module @deepseek-ai/dsh-hooks-codex
*/
// Each dialect bridge keeps its complete dependency list visible at the entry
// point; a cross-package facade for imports alone would add indirection.
/* jscpd:ignore-start */
import { readFileSync } from 'node:fs'
import type { Context } from 'cordis'
import z from 'schemastery'
@@ -35,6 +38,7 @@ import {
type MergedHookOutcome,
} from '@deepseek-ai/dsh-hook-protocol'
import { parseCodexConfig, type CodexHookConfig } from './config.ts'
/* jscpd:ignore-end */
export const name = 'hooks-codex'
export const inject = ['bash']
@@ -153,6 +157,9 @@ export function apply(ctx: Context, config: Config): void {
output.additionalContext = output.stdout
}
outputs.push(output)
// Execution and decision mapping remain in each bridge so dialect
// differences stay explicit at their owning seam.
/* jscpd:ignore-start */
if (output.systemMessage !== undefined) {
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
}
@@ -202,12 +209,14 @@ export function apply(ctx: Context, config: Config): void {
if (context) agent.inject(context.content, { source: context.source })
})
.catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }))
/* jscpd:ignore-end */
})
// UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask).
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
const turn = lastTurn(agent)
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
/* jscpd:ignore-start */
if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
// Context alone is not a veto: DELEGATE so a later prompt-submit listener can
// still block/rewrite, then fold our context onto its decision.
@@ -225,6 +234,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
/* jscpd:ignore-end */
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
return next()
})
@@ -232,6 +242,7 @@ export function apply(ctx: Context, config: Config): void {
// PostToolUse → PostToolDecision (block with feedback, or attach context).
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const turn = lastTurn(exec.agent)
/* jscpd:ignore-start */
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
@@ -257,6 +268,7 @@ export function apply(ctx: Context, config: Config): void {
// loop-guard (stop_hook_active + a max-consecutive cap) is deferred.
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
/* jscpd:ignore-end */
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation; a block with no reason (exit 2,
// empty stderr) still forces it — fall back to a generic steering line
@@ -271,6 +283,9 @@ export function apply(ctx: Context, config: Config): void {
// --- Codex DIALECT payloads: snake_case, model on every event, turn_id on
// turn-scoped events. ---
// These small payload helpers intentionally remain next to the dialect shape;
// sharing them would pull bridge-only agent/LLM dependencies into hook-protocol.
/* jscpd:ignore-start */
function lastTurn(agent: Agent | undefined): number {
if (!agent) return 0
const last = [...agent.session.events].findLast(e => e.type === 'turn/start')
@@ -283,6 +298,7 @@ function lastTurn(agent: Agent | undefined): number {
function blocksToText(content: ContentBlock[]): string {
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
}
/* jscpd:ignore-end */
/** Base fields on every Codex payload (no turn_id). */
function base(agent: Agent | undefined, event: string, model: string): Record<string, unknown> {

View File

@@ -9,4 +9,4 @@ The confinement half of the [capability-seam split](../../docs/rfc/implemented/a
The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [examples/sandbox-acp-agent](../../examples/sandbox-acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase).
Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [the acp-agent example's default composition](../../examples/acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase).

View File

@@ -15,4 +15,4 @@ Every rung has its keyless world-proof (`tests/bwrap.e2e.ts`, `tests/landlock.e2
name: '@deepseek-ai/dsh-sandbox-local'
```
Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [`examples/sandbox-acp-agent`](../../../examples/sandbox-acp-agent/) for the runnable composition.
Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [the acp-agent example](../../../examples/acp-agent/) for the runnable default composition.

View File

@@ -88,6 +88,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
this.coordinator = new PersistenceCoordinator<number>(this.ctx, this)
}
// Each backend keeps the typed service surface beside its storage hooks;
// extracting these trivial forwards would add an inheritance seam.
/* jscpd:ignore-start */
// --- SessionPersistence service surface (delegated to the coordinator) ---
create(meta: SessionHeader): Promise<void> {
@@ -115,6 +118,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
get inits(): Map<Session, Promise<void>> {
return this.coordinator.inits
}
/* jscpd:ignore-end */
// --- PersistenceBackend hooks (the file-bytes storage primitives) ---

View File

@@ -33,7 +33,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th
config:
providerName: acp
command: node
args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', './examples/acp-agent/cordis.yml']
args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', '--config', './examples/acp-agent/cordis.yml']
permission: reject
env:
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY

View File

@@ -48,7 +48,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
args: ['--import', tsxLoader, binScript, exampleConfig],
args: ['--import', tsxLoader, binScript, '--config', exampleConfig],
cwd: workdir,
permission: 'reject',
// The child harness needs the key to reach the model; forward it
@@ -57,6 +57,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
TSX_TSCONFIG_PATH: repoTsconfig,
DSH_PERMISSION_MODE: 'danger-full-access',
},
})
@@ -83,7 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
args: ['--import', tsxLoader, binScript, exampleConfig],
args: ['--import', tsxLoader, binScript, '--config', exampleConfig],
cwd: workdir,
// The child needs to act (run bash), so approve its permission prompts.
permission: 'allow',
@@ -91,6 +92,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
TSX_TSCONFIG_PATH: repoTsconfig,
DSH_PERMISSION_MODE: 'danger-full-access',
},
})

View File

@@ -226,7 +226,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
child = spawn(
process.execPath,
['--import', tsxLoader, opts.agent.binScript, opts.configPath ?? opts.agent.configPath],
['--import', tsxLoader, opts.agent.binScript, '--config', opts.configPath ?? opts.agent.configPath],
{ cwd, env, stdio: ['pipe', 'pipe', 'pipe'] },
)

View File

@@ -874,7 +874,7 @@ describe('scoped-dispatch invariants', () => {
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }]],
]
for (const [event, args] of rows) {
const subject = event.startsWith('tools/') ? agent : agent
const subject = agent
expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, subject), event, ...args) },
`${event} with matching carrier`).not.toThrow()
expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, other), event, ...args) },

View File

@@ -54,6 +54,16 @@ function chunkEvent(seq: number, turn: number, step: number, chunk: StreamChunk)
let dir: string
let file: string
/** Write a session log file and return its path. */
function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
let seq = 1
const events: SessionEvent[] = []
calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
const path = join(dir, filename)
writeFileSync(path, sessionJsonl(events, header), 'utf8')
return path
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'llm-replay-spec-'))
file = join(dir, 'session.jsonl')
@@ -391,16 +401,6 @@ describe('parseSessionHeader', () => {
})
describe('loadSessionScripts', () => {
/** Write a session log file and return its path. */
function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
let seq = 1
const events: SessionEvent[] = []
calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
const path = join(dir, filename)
writeFileSync(path, sessionJsonl(events, header), 'utf8')
return path
}
it('returns one primary script for a single-session scenario', () => {
const f = writeSession('session.jsonl', { id: 'p', createdAt: 100 }, [TEXT_CHUNKS])
const scripts: SessionScript[] = loadSessionScripts({ file: f })
@@ -508,16 +508,6 @@ describe('installLlmReplay (per-session keying)', () => {
{ type: 'finish', reason: { kind: 'stop' } },
]
/** Write a session log file and return its path. */
function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
let seq = 1
const events: SessionEvent[] = []
calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
const path = join(dir, filename)
writeFileSync(path, sessionJsonl(events, header), 'utf8')
return path
}
const live = (id: string): GenerateOptions =>
({ model: 'm', messages: [], sessionId: id as NonNullable<GenerateOptions['sessionId']> })

View File

@@ -6,14 +6,17 @@ Integrations that expose the agent to an external editor or client. These are **
|---|---|---|
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` |
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
| `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
| `jsonrpc/` | Stdio JSON-RPC SDK server plugin: serves `HarnessSdkServer` to out-of-process SDK clients (the Python SDK) on the process stdio | (drives `ctx.agents`) |
| `jsonrpc-agent/` | JSON-RPC SDK server APP: a bin-only boot of an external `cordis.yml` whose `jsonrpc` entry is the serving face; the single-exe runtime entrypoint | (`bin` only) |
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.
`stdio-agent` and `acp-agent` are the two composing **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. `jsonrpc-agent` is the third app but bin-only — no composition plugin, because the SDK runtime's hard semantic is that the external `cordis.yml` composes everything, the serving `jsonrpc` entry included. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.

View File

@@ -29,11 +29,11 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`).
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor.
## The bin
`dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`):
`dsh-acp-agent [--config path-to-cordis.yml]` (short form `-c`; default `./cordis.yml`):
- loads a gitignored `.env` from the cwd — **skipped** in snapshot REPLAY so a stray key can never trigger a live call;
- honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`);

View File

@@ -22,11 +22,13 @@
* STDERR only (the app plugin loads no stdout logger, and the shared guards
* write to stderr); a stray stdout write corrupts the protocol frames.
*
* Usage: `dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`).
* Usage: `dsh-acp-agent [--config path-to-cordis.yml]` (default
* `./cordis.yml`).
*
* @module @deepseek-ai/dsh-acp-agent/bin
*/
import { parseArgs } from 'node:util'
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
const NAME = 'dsh-acp-agent'
@@ -37,7 +39,12 @@ const NAME = 'dsh-acp-agent'
installFailLoud(NAME)
const snapshotMode = process.env['DSH_SNAPSHOT']
if (snapshotMode !== 'replay') loadEnv(NAME)
const ctx = await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', snapshotMode))
const { values } = parseArgs({
args: process.argv.slice(2),
options: { config: { type: 'string', short: 'c' } },
strict: true,
})
const ctx = await boot(NAME, resolveConfigPath(values.config ?? './cordis.yml', snapshotMode))
if (snapshotMode !== undefined) {
process.stdin.on('end', () => {
void ctx.fiber.dispose().then(() => { process.exit(0) })

View File

@@ -64,6 +64,9 @@ export interface Config {
skills?: agentCore.SkillConfig
}
// Each front door owns a complete, directly readable config schema; extracting
// the common fields would make two small app contracts depend on a new facade.
/* jscpd:ignore-start */
export const Config: z<Config> = z.object({
model: z.string().required(),
persona: z.string(),
@@ -77,6 +80,7 @@ export const Config: z<Config> = z.object({
persistenceRoot: z.string().default('./.sessions'),
skills: agentCore.SkillConfigSchema,
})
/* jscpd:ignore-end */
/**
* Compose the spine with the ACP front door. The agent-core bundle pre-creates

View File

@@ -117,7 +117,7 @@ afterEach(async () => {
describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, no tsx)', () => {
it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => {
consumer = await makeConsumer()
child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], {
child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], {
cwd: consumer,
// Dummy key: initialize never reaches the model, so it is never used.
env: {
@@ -183,7 +183,7 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js,
/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
return new Promise((resolve, reject) => {
const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
const proc = spawn(process.execPath, ['--expose-internals', acpBin, '--config', configArg], {
cwd,
env: {
...process.env,

View File

@@ -82,7 +82,7 @@ async function boot(): Promise<Spawned & { cwd: string }> {
await writeFile(configPath, CORDIS_YML)
const child = spawn(
process.execPath,
['--import', tsxLoader, binScript, configPath],
['--import', tsxLoader, binScript, '--config', configPath],
{
cwd,
env: {

View File

@@ -32,7 +32,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
| `session/request_permission` | `approval/request` listener | the bridge is the [`ctx.approval`](../user-approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" |
| `session/set_config_option` | `setSandboxMode` / `setApprovalPolicy` | per-session knob switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
| `session/set_config_option` | `ctx.permission.set()` | per-session permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
## Multi-session
@@ -40,7 +40,7 @@ The bridge multiplexes N sessions over one connection. Live sessions are held in
## Session config options
The bridge advertises one independent `select` per composable knob in the `session/new`/`session/load` responses — `sandbox-mode` (`read-only`/`workspace-write`/`danger-full-access`, category `mode`) iff the mounted executor confines (`ctx.get('bash')?.sandboxMode` defined), `approval-policy` (`ask`/`never`) iff the approval seam is composed — with each session's `currentValue` folded from its OWN log (`effectiveSandboxMode`/`effectiveApprovalPolicy` ?? the composition default), so `session/load` reports a resumed session's overrides with no catch-up machinery. `session/set_config_option` validates the value against the same closed vocabulary, routes to the domain's write path (`setSandboxMode`/`setApprovalPolicy` — ONE log-only event on that session's log), and returns the complete refreshed state per the spec. Anchoring honors turn-enclosure: a switch while a turn is open appends immediately (openness read from the LOG — `agent.status` stays `running` between queued turns); an idle switch is held on the session record and anchored at the next turn's `agent/prompt-submit` (inside the turn, before anything assembles, last write per knob — an idle flip-flop anchors as one event), because appending from inside a `session/event` listener would reorder events for later-registered peers. Until anchored the switch lives in bridge memory only: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6.
When `ctx.permission` is composed, the bridge advertises one `permission` select (category `mode`) in `session/new` and `session/load`; its options come from the deployment's preset table and its current value is `PermissionService.current(session.events)`, including the derived, switch-away-only `custom` state when the effective knobs match no preset. `session/set_config_option` accepts only advertised preset names, calls `PermissionService.set()` to write the preset through to the sandbox-mode and approval-policy events, and returns the complete refreshed state. A switch during an open turn appends immediately; an idle switch stays on the session record and anchors at the next turn's `agent/prompt-submit`, inside the turn and before request assembly. Until that anchor, responses overlay the pending value and a crash reverts to the durable fold. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); preset contract: [`dsh-permission`](../permission/README.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6.
Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload.

View File

@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
## At a glance
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, and per-session sandbox/approval config options. The largest **unbuilt** areas are **MCP passthrough**, runtime model selection, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, and per-session permission presets. The largest **unbuilt** areas are **MCP passthrough**, runtime model selection, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
## 1. Agent methods (client → agent)
@@ -25,8 +25,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
| `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. |
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry the two orthogonal knobs (see [§6](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | Two capability-gated selects — `sandbox-mode` (confining executor mounted) and `approval-policy` (approval seam composed); values validated against the domain vocabularies, one log-only event per switch on the session's own log, complete refreshed state in the response ([sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). |
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | One `permission` select when `ctx.permission` is composed; values come from the deployment preset table, a switch writes its preset event through to both knob events, and the response carries the complete refreshed state ([sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). |
| model selection | S | ❌ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. The bridge fixes the model per-bridge via config; no runtime switch. Codex still uses the legacy `unstable_setSessionModel` ext method. |
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
| `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. |
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
## 6. Session modes / config options / models
Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): the bridge advertises one independent `select` per composable knob — `sandbox-mode` iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with per-session current values folded from each session's own log, and honors `session/set_config_option` end to end (idle switches anchor at the next turn under the turn-enclosure contract). Session MODES stay deliberately unmodeled: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry two orthogonal knobs. Runtime model selection is still not modeled — the harness fixes the model per-bridge via `AcpConfig.model` (both reference adapters ship a model selector).
Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): when `ctx.permission` is composed, the bridge advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; `session/set_config_option` switches the preset end to end, with idle switches anchoring at the next turn under the turn-enclosure contract. Session modes stay deliberately unmodeled because config options replace them in ACP v2. Runtime model selection is still not modeled — the harness fixes the model per bridge via `AcpConfig.model` (both reference adapters ship a model selector).
## 7. Content blocks

View File

@@ -28,36 +28,38 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -74,10 +74,8 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
@@ -328,7 +326,7 @@ interface SessionRecord {
* overlay it truthfully, and a restart before the next turn reverts it —
* which `session/load` then reports honestly from the log's fold.
*/
pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy }
pendingSwitches: { preset?: string }
}
/**
@@ -550,46 +548,37 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- The ACP Agent method surface -----------------------------------------
/**
* The session config options this composition can honor, with current
* values folded from the AGENT'S OWN session log (`effectiveSandboxMode` /
* `effectiveApprovalPolicy` — the log is the per-session store, so a
* `session/load` reports a resumed session's overrides with no catch-up
* machinery), overlaid with the record's not-yet-anchored pending switches
* (see {@link SessionRecord.pendingSwitches}). Capability-gated like every
* advertised lever: the sandbox option exists only when the mounted
* executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval
* option only when the approval seam is composed — both read
* The session config options this composition can honor: ONE `Mode`
* select over the composed preset table (`ctx.permission` — the product
* layer bundling the sandbox-mode and approval-policy knobs), its current
* value folded from the AGENT'S OWN session log (the log is the
* per-session store, so a `session/load` reports a resumed session's
* preset with no catch-up machinery), overlaid with the record's
* not-yet-anchored pending switch (see
* {@link SessionRecord.pendingSwitches}). Capability-gated like every
* advertised lever: no preset service composed, no options — read
* opportunistically so this bridge keeps working in compositions without
* them.
* it.
*/
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
const options: SessionConfigOption[] = []
const defaultMode = ctx.get('bash')?.sandboxMode
if (defaultMode !== undefined) {
options.push({
id: 'sandbox-mode',
name: 'Sandbox',
description: 'The file sandbox mode bash commands in this session run under.',
category: 'mode',
type: 'select',
currentValue: pending.sandboxMode ?? effectiveSandboxMode(agent.session.events) ?? defaultMode,
options: SANDBOX_MODES.map(mode => ({ value: mode, name: mode })),
})
}
const approval = ctx.get('approval')
if (approval !== undefined) {
options.push({
id: 'approval-policy',
name: 'Approvals',
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
type: 'select',
// `?? 'ask'` also shields against a provided stand-in whose config
// never went through the plugin schema (tests do this).
currentValue: pending.approvalPolicy ?? effectiveApprovalPolicy(agent.session.events) ?? approval.config.policy ?? 'ask',
options: APPROVAL_POLICIES.map(policy => ({ value: policy, name: policy })),
})
}
return options
const presets = ctx.get('permission')
if (presets === undefined) return []
const currentValue = pending.preset ?? presets.current(agent.session.events)
return [{
id: 'permission',
name: 'Permissions',
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
category: 'mode',
type: 'select',
currentValue,
options: [
...presets.names.map((name: string) => presets.optionOf(name)),
// The derived not-a-preset state: visible exactly while it IS the
// current value (a knob state outside the table), switchable FROM,
// never a target — set() below rejects it like any unknown name.
...currentValue === 'custom' ? [presets.optionOf('custom')] : [],
],
}]
}
/**
@@ -619,15 +608,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
const flushPendingSwitches = (rec: SessionRecord): void => {
const pending = rec.pendingSwitches
rec.pendingSwitches = {}
const events = rec.agent.session.events
if (pending.sandboxMode !== undefined
&& pending.sandboxMode !== (effectiveSandboxMode(events) ?? ctx.get('bash')?.sandboxMode)) {
setSandboxMode(rec.agent.session, pending.sandboxMode)
}
if (pending.approvalPolicy !== undefined
&& pending.approvalPolicy !== (effectiveApprovalPolicy(events) ?? ctx.get('approval')?.config.policy ?? 'ask')) {
setApprovalPolicy(rec.agent.session, pending.approvalPolicy)
}
if (pending.preset === undefined) return
const presets = ctx.get('permission')
/* v8 ignore next -- a pending preset exists only if the service answered the
switch; it cannot unmount between that and the next turn in any composition. */
if (presets === undefined) return
presets.set(rec.agent.session, pending.preset)
}
// Idle-accepted switches anchor at the next turn's prompt-submit: the turn
@@ -869,7 +855,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
// Both advertised options are selects, so the boolean-shaped variant of
// The advertised option is a select, so the boolean-shaped variant of
// the request is a protocol misuse regardless of configId.
if (typeof params.value !== 'string') {
throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`)
@@ -886,32 +872,22 @@ export function apply(ctx: Context, config: AcpConfig): void {
// advertised; an id this composition never advertised (or an unknown
// one) rejects.
switch (params.configId) {
case 'sandbox-mode': {
const defaultMode = ctx.get('bash')?.sandboxMode
if (defaultMode === undefined || !SANDBOX_MODES.includes(params.value as SandboxMode)) {
throw invalidParams(`unknown sandbox-mode value ${JSON.stringify(params.value)}`)
case 'permission': {
const presets = ctx.get('permission')
if (presets === undefined) {
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
}
const value = params.value as SandboxMode
// A no-op switch (the value the session already shows — pending,
// else fold, else default) is acknowledged without recording
// anything: clients that re-push current selections on session
// start must not mint override events out of thin air.
const current = rec.pendingSwitches.sandboxMode ?? effectiveSandboxMode(rec.agent.session.events) ?? defaultMode
if (value === current) break
if (isTurnOpen(rec.agent)) setSandboxMode(rec.agent.session, value)
else rec.pendingSwitches.sandboxMode = value
break
}
case 'approval-policy': {
const approval = ctx.get('approval')
if (approval === undefined || !APPROVAL_POLICIES.includes(params.value as ApprovalPolicy)) {
throw invalidParams(`unknown approval-policy value ${JSON.stringify(params.value)}`)
// else derived) is acknowledged FIRST and records nothing:
// clients re-push current selections on session start, and the
// derived 'custom' current is only ever valid as such an echo.
const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events)
if (params.value === current) break
if (!presets.names.includes(params.value)) {
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
}
const value = params.value as ApprovalPolicy
const current = rec.pendingSwitches.approvalPolicy ?? effectiveApprovalPolicy(rec.agent.session.events) ?? approval.config.policy ?? 'ask'
if (value === current) break
if (isTurnOpen(rec.agent)) setApprovalPolicy(rec.agent.session, value)
else rec.pendingSwitches.approvalPolicy = value
if (isTurnOpen(rec.agent)) presets.set(rec.agent.session, params.value)
else rec.pendingSwitches.preset = params.value
break
}
default:

View File

@@ -1,10 +1,10 @@
/**
* Session config options over the bridge: the two per-session knobs
* (`sandbox-mode`, `approval-policy`) advertised from composition capability,
* their current values folded from each session's own log, switching via
* `session/set_config_option` (one log-only event per switch — the log is the
* store), and a resumed session reporting its overrides back on
* `session/load` with no catch-up machinery.
* Session config options over the bridge: ONE user-facing `Permissions`
* select (`ctx.permission`'s preset table — each choice bundles a sandbox
* mode and an approval policy), its current value folded from each session's
* own log, switching via `session/set_config_option` (the preset event plus
* its knob write-throughs — the log is the store), and a resumed session
* reporting its preset back on `session/load` with no catch-up machinery.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -14,50 +14,37 @@ import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import PermissionService from '@deepseek-ai/dsh-permission'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
/**
* The REAL local executor reporting a confining default — `sandboxMode` is
* the documented capability override point (`dsh-bash-sandbox` overrides it
* the same way), so the bridge sees exactly what a sandboxing composition
* advertises without this suite dragging in a kernel sandbox stack.
* advertises without this suite dragging in a kernel sandbox stack. It
* reports `workspace-write`: the shipped preset's bundle, which
* the permission service validates the composition defaults against.
*/
class SandboxedLocalExecutor extends LocalBashExecutor {
override get sandboxMode(): SandboxMode {
return 'read-only'
return 'workspace-write'
}
}
/** The exact option payloads the bridge advertises (pinned verbatim). */
function sandboxOption(currentValue: SandboxMode): object {
/** The exact option payload the bridge advertises (pinned verbatim). */
function permissionOption(currentValue: string): object {
return {
id: 'sandbox-mode',
name: 'Sandbox',
description: 'The file sandbox mode bash commands in this session run under.',
id: 'permission',
name: 'Permissions',
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
category: 'mode',
type: 'select',
currentValue,
options: [
{ value: 'read-only', name: 'read-only' },
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
}
}
function approvalOption(currentValue: ApprovalPolicy): object {
return {
id: 'approval-policy',
name: 'Approvals',
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
type: 'select',
currentValue,
options: [
{ value: 'ask', name: 'ask' },
{ value: 'never', name: 'never' },
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.' },
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access, no approval prompts.' },
],
}
}
@@ -75,144 +62,113 @@ describe('acp bridge — session config options', () => {
await rm(storageDir, { recursive: true, force: true })
})
/** A harness whose composition can honor both knobs (sandboxed executor + approval seam). */
async function bothKnobs(options: { policy?: ApprovalPolicy; script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
/** A harness composing the full preset stack (confining executor + approval seam + permission presets). */
async function presetStack(options: { script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
// The dev invariants police turn-enclosure: an idle switch that appended
// outside a turn would throw right here in the suite, not in production.
await harness.ctx.plugin(Invariants)
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
await harness.ctx.plugin(ApprovalService, options.policy !== undefined ? { policy: options.policy } : {})
await harness.ctx.plugin(ApprovalService)
await harness.ctx.plugin(PermissionService)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
return harness
}
it('advertises no configOptions in a composition with neither knob', async () => {
it('advertises no configOptions without the permission service — even with both knobs composed', async () => {
h = await makeBridgeHarness({ storageDir })
await h.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
await h.ctx.plugin(ApprovalService)
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toBeUndefined()
})
it('a non-confining executor advertises no sandbox option (nothing would honor it)', async () => {
h = await makeBridgeHarness({ storageDir, withBash: true })
await h.ctx.plugin(ApprovalService)
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
it('advertises the Permissions select with the default preset current', async () => {
h = await presetStack()
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual([approvalOption('ask')])
})
it('advertises both knobs with capability-derived currents (config default included)', async () => {
h = await bothKnobs({ policy: 'never' })
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
expect(res.configOptions).toEqual([permissionOption('workspace-write')])
})
it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => {
h = await bothKnobs({ script: [textResponse('ok')] })
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const afterSandbox = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
expect(afterSandbox.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('ask')])
const afterApproval = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
expect(afterApproval.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('never')])
const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
expect(after.configOptions).toEqual([permissionOption('danger-full-access')])
// Idle: nothing in the log yet — turn-enclosure forbids a bare append
// (the dev invariants in this suite would throw), so the switch lives on
// the record until a turn opens.
// Idle: nothing in the log yet — turn-enclosure forbids a bare append.
const session = h.ctx.agents.list()[0]?.session
expect(session?.events.some(e => e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
// The next turn anchors both switches inside itself, one event per knob.
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = session?.events ?? []
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
const turnStart = events.findIndex(e => e.type === 'turn/start')
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
const anchored = events.findIndex(e => e.type === 'permission/preset')
expect(turnStart).toBeGreaterThanOrEqual(0)
expect(anchored).toBeGreaterThan(turnStart)
})
it('an idle flip-flop anchors as ONE event (last write per knob wins)', async () => {
h = await bothKnobs({ script: [textResponse('ok')] })
it('an idle flip-flop anchors as ONE switch (last write wins)', async () => {
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
expect(again.configOptions).toEqual([permissionOption('danger-full-access')])
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
// Idle again AFTER a completed turn (the log now ends in turn/end): a new
// switch pends rather than appending outside the closed turn.
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
expect(again.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(1)
})
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
h = await bothKnobs({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
// Re-pushing the composition default (what clients that echo current
// selections on session start do) must not mint an override event.
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
expect(echo.configOptions?.find(option => option.id === 'approval-policy')).toMatchObject({ currentValue: 'ask' })
// Re-sending a PENDING value keeps the pending switch alive (it is what
// the session shows), rather than cancelling it.
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
expect(repeat.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'workspace-write' })
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'approval/policy')).toHaveLength(0)
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
// Between turns (a closed turn in the log) a switch still pends — the
// enclosure fold walks past the turn/end — and anchors with the NEXT turn.
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
expect(h.ctx.agents.list()[0]?.session.events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
})
it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => {
h = await bothKnobs({ script: [textResponse('ok')] })
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
expect(back.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
expect(back.configOptions).toEqual([permissionOption('workspace-write')])
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(0)
expect(events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
})
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
expect(echo.configOptions).toEqual([permissionOption('workspace-write')])
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
expect(repeat.configOptions).toEqual([permissionOption('danger-full-access')])
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
})
it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => {
h = await bothKnobs({ script: ['hang'] })
h = await presetStack({ script: ['hang'] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const hung = h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
// Give the loop a tick to open the turn (the turns.spec hang idiom).
await new Promise(resolve => setTimeout(resolve, 30))
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const events = h.ctx.agents.list()[0]?.session.events ?? []
const turnStart = events.findIndex(e => e.type === 'turn/start')
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
const anchored = events.findIndex(e => e.type === 'permission/preset')
expect(turnStart).toBeGreaterThanOrEqual(0)
expect(anchored).toBeGreaterThan(turnStart)
expect(events.some(e => e.type === 'bash/sandbox-mode')).toBe(true)
expect(events.some(e => e.type === 'approval/policy')).toBe(true)
await h.client.cancel({ sessionId })
await hung
})
it('tolerates a provided approval stand-in whose config skipped the plugin schema', async () => {
h = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
h.ctx.provide('approval', { config: {} } as unknown as InstanceType<typeof ApprovalService>)
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual([approvalOption('ask')])
const sessionId = res.sessionId
// The schema-less config also shields the no-op guard ('ask' by the ?? fallback)…
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
expect(echo.configOptions).toEqual([approvalOption('ask')])
// …and the anchor-time comparison: a real switch under the stand-in still anchors.
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
})
it('rejects unknown ids, unadvertised ids, boolean values, and out-of-vocabulary values', async () => {
h = await makeBridgeHarness({ storageDir })
await h.ctx.plugin(ApprovalService)
@@ -221,40 +177,72 @@ describe('acp bridge — session config options', () => {
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' }))
.rejects.toThrow(/unknown config option/)
// sandbox-mode exists as a concept but THIS composition never advertised it.
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }))
.rejects.toThrow(/unknown sandbox-mode value/)
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', type: 'boolean', value: true }))
// `permission` exists as a concept but THIS composition never advertised it.
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }))
.rejects.toThrow(/unknown permission value/)
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true }))
.rejects.toThrow(/select; boolean values are not accepted/)
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'always' }))
.rejects.toThrow(/unknown approval-policy value/)
})
it('rejects an out-of-vocabulary preset on an advertising composition', async () => {
h = await presetStack()
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'plan' }))
.rejects.toThrow(/unknown permission value/)
})
it('a switch in one session never leaks into a concurrent one (state and pending both per-session)', async () => {
h = await bothKnobs()
h = await presetStack()
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
// B sees its own composition defaults, not A's pending switch...
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'approval-policy', value: 'never' })
expect(bAfter.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
// B sees the composition default, not A's pending switch...
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'workspace-write' })
expect(bAfter.configOptions).toEqual([permissionOption('workspace-write')])
// ...and A keeps its own state, untouched by B's.
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
expect(aAfter.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('ask')])
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
expect(aAfter.configOptions).toEqual([permissionOption('danger-full-access')])
})
it('session/load reports a resumed session\'s overrides from its own log', async () => {
h = await bothKnobs({ script: [textResponse('ok')] })
it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => {
h = await presetStack()
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
// Drift a knob out from under the table (a plugin writing the knob
// directly — the raw setters remain public mechanism), inside its own
// turn: the dev invariants enforce turn-enclosure here too.
const agent = h.ctx.agents.list()[0]
if (agent === undefined) throw new Error('expected an agent')
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.session.append('bash/sandbox-mode', { mode: 'read-only' })
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// The echo of the derived current is a no-op, not an unknown-value error…
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
const option = echo.configOptions?.[0]
expect(option).toMatchObject({ currentValue: 'custom' })
if (option === undefined || !('options' in option)) throw new Error('expected a select option')
expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access', 'custom'])
// …while custom as a TARGET from a real preset stays rejected: switching
// away is ordinary, and the custom entry disappears from the options.
const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const afterOption = away.configOptions?.[0]
expect(afterOption).toMatchObject({ currentValue: 'danger-full-access' })
if (afterOption === undefined || !('options' in afterOption)) throw new Error('expected a select option')
expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access'])
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' }))
.rejects.toThrow(/unknown permission value/)
})
it('session/load reports a resumed session\'s preset from its own log', async () => {
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
// One turn checkpoints the log (the switch events flush with it).
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist me' }] })
await h.dispose()
h = undefined
loader = await bothKnobs()
loader = await presetStack()
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('never')])
expect(res.configOptions).toEqual([permissionOption('danger-full-access')])
})
})

View File

@@ -30,6 +30,21 @@ function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistryType, 'get'> {
return { get: name => map.get(name) }
}
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
return out
}
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
return ctx
}
function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent, { type: T }>['data']): SessionEvent {
return { type, seq: 0, time: 0, data } as SessionEvent
}
@@ -181,12 +196,6 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
}),
}
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
return out
}
it('tool/call uses the tool: description→title, command→rawInput, tool kind', () => {
const presenter = new ToolPresenter(registryOf(bashLike))
const [update] = updatesWith(presenter, evt('tool/call', {
@@ -627,21 +636,6 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
// result card the bridge forwards as `{ type: 'diff' }` content blocks. Uses
// the REAL tool (not a stand-in) per the anti-mock convention, mirroring the
// call-side diff test above.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
return ctx
}
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
return out
}
it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => {
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
@@ -739,14 +733,6 @@ describe('relative-path display titles (bridge relativizes the title against the
// diff paths RAW. Drive it with the REAL fs tools so the title/locations come
// from the shipping presentCall, and pass an ABSOLUTE file path (which a real
// editor forwards). The presenter is pure/args-only; the cwd is known only here.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
return ctx
}
function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] {
const presenter = new ToolPresenter(ctx.tools)
const out: SessionNotification['update'][] = []

View File

@@ -38,6 +38,9 @@
{
"path": "../user-approval"
},
{
"path": "../permission"
},
{
"path": "../../sandbox/sandbox"
},

View File

@@ -0,0 +1,17 @@
# @deepseek-ai/dsh-jsonrpc-agent
The **JSON-RPC SDK server app bin** (`dsh-jsonrpc-agent`): boot a harness from an externally supplied `cordis.yml` and let its [`@deepseek-ai/dsh-jsonrpc`](../jsonrpc/README.md) entry serve SDK clients over newline-delimited JSON-RPC on stdio. Structurally the SDK-runtime sibling of [`acp-agent`](../acp-agent/README.md)'s bin, but bin-only: there is no composition plugin here, because "the plugins that actually start come from the external config" is the SDK runtime's hard semantic — the leaf `cordis.yml` composes the spine, the backends, AND the serving face. This package is the entrypoint of the single-exe distribution (its `lib/bin.js` is what the packaged executable runs) — see [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
## Config discovery
Two channels, environment first: `$DSH_CORDIS_CONFIG` (the existing SDK-client convention, wins), then the `argv[2]` positional path (`dsh-jsonrpc-agent <path/to/cordis.yml>`, the human channel). An empty value counts as absent on either channel. Neither given, or the path missing on disk: the bin prints a one-line usage naming both channels to stderr and exits 1 — there is no default `./cordis.yml` and no built-in fallback config. A config that names a plugin which fails to load fails loud through the shared [`dsh-app-boot`](../app-boot/README.md) guards (`assertEntriesLoaded` + the unhandled-rejection handler), never a silent half-boot. There is no `DSH_SNAPSHOT` handling: this protocol is not part of the ACP snapshot tier.
Note the deliberate flip side of config-decides-everything: a config that loads no `dsh-jsonrpc` entry boots fine and serves nothing — the bin cannot know which plugin is "the server".
## Exit lifecycle
The bin owns the PROCESS-level exits: stdin EOF (the SDK client is gone — an in-flight turn is deliberately cut off, see the risk note in docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) and `SIGTERM` dispose the root context to quiescence and exit 0; `SIGINT` does the same but exits 130. The PROTOCOL-level exit — a `shutdown` JSON-RPC request answered first, then exit 0 — is owned by the `dsh-jsonrpc` plugin, which holds the server and transport; the two paths are individually idempotent and safe to race.
## stdout is the protocol
stdout carries only JSON-RPC frames; the bin and the app-boot guards write diagnostics to stderr only, and the booted config must load no stdout logger (see the `dsh-jsonrpc` README).

View File

@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-jsonrpc-agent",
"description": "JSON-RPC SDK server app bin: boots an externally supplied cordis.yml (DSH_CORDIS_CONFIG or argv, no built-in fallback) whose dsh-jsonrpc entry serves SDK clients over stdio; the single-exe runtime entrypoint",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"bin": {
"dsh-jsonrpc-agent": "lib/bin.js"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./bin": {
"types": "./lib/types/bin.d.ts",
"default": "./lib/bin.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/bin.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-app-boot": "workspace:^"
},
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* The `dsh-jsonrpc-agent` bin: boot a harness from an externally supplied
* `cordis.yml` whose `@deepseek-ai/dsh-jsonrpc` entry serves SDK clients over
* newline-delimited JSON-RPC on stdio. The shared boot glue — `.env` loading,
* the fail-loud Loader guards, the settle-the-tree boot sequence — lives in
* {@link @deepseek-ai/dsh-app-boot}, shared with the stdio/ACP bins; this bin
* owns only config discovery and the process-level exit lifecycle:
*
* - Config discovery is `$DSH_CORDIS_CONFIG` (the existing SDK-client
* convention, wins) or the `argv[2]` positional path (the human channel,
* for direct launches); an empty value counts as absent. Neither
* given, or the path missing on disk, prints the one-line usage to stderr
* and exits 1. No built-in fallback — the external config IS the deployment
* (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
* No `DSH_SNAPSHOT` handling: this
* protocol is not part of the ACP snapshot tier.
* - stdin EOF (the SDK client is gone) and SIGTERM dispose the root context
* to quiescence and exit 0; SIGINT does the same but exits 130. The
* `shutdown` JSON-RPC request's answer-then-exit-0 path is owned by the
* `dsh-jsonrpc` plugin, which holds the server (see its README).
*
* IMPORTANT: stdout is the JSON-RPC channel. Diagnostics go to STDERR only (a
* stray stdout write corrupts the protocol frames), which the app-boot guards
* already honor.
*
* @module @deepseek-ai/dsh-jsonrpc-agent/bin
*/
import { existsSync } from 'node:fs'
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
const NAME = 'dsh-jsonrpc-agent'
/* v8 ignore start -- thin self-executing composition over the unit-tested
dsh-app-boot helpers; the serving lifecycle it boots is unit-tested in
@deepseek-ai/dsh-jsonrpc, and the composed artifact is exercised by the
single-exe acceptance drive (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) */
installFailLoud(NAME)
loadEnv(NAME)
// Env wins over the positional argument; an empty value on either channel
// counts as absent. There is deliberately NO default `./cordis.yml`: "the
// plugins that actually start come from an explicit external config" is a
// hard semantic of the SDK runtime.
const fromEnv = process.env['DSH_CORDIS_CONFIG']
const fromArgv = process.argv[2]
const requested = fromEnv !== undefined && fromEnv !== ''
? fromEnv
: fromArgv !== undefined && fromArgv !== '' ? fromArgv : undefined
const configPath = requested === undefined ? undefined : resolveConfigPath(requested, undefined)
if (configPath === undefined || !existsSync(configPath)) {
process.stderr.write(
`usage: ${NAME} <path/to/cordis.yml> (or set DSH_CORDIS_CONFIG=<path>, which wins); the config is required — there is no built-in fallback\n`,
)
process.exit(1)
}
const ctx = await boot(NAME, configPath)
let exiting = false
async function disposeAndExit(code: number): Promise<void> {
if (exiting) return
exiting = true
try {
await ctx.fiber.dispose()
} finally {
process.exit(code)
}
}
process.stdin.on('end', () => { void disposeAndExit(0) })
process.on('SIGTERM', () => { void disposeAndExit(0) })
process.on('SIGINT', () => { void disposeAndExit(130) })
/* v8 ignore stop */

View File

@@ -0,0 +1,14 @@
/**
* The `dsh-jsonrpc-agent` app package IS its bin (see `./bin.ts`): config
* discovery plus the process-level exit lifecycle around a booted
* `cordis.yml`. This module deliberately exports nothing — unlike the
* stdio/ACP app packages there is no composition plugin here, because the
* serving face is the {@link @deepseek-ai/dsh-jsonrpc} plugin the external
* config loads like any other entry (which plugins actually start is the
* config's decision, the hard semantic of the SDK runtime; see
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
*
* @module @deepseek-ai/dsh-jsonrpc-agent
*/
export {}

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../app-boot"
}
]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'tsdown'
/**
* jsonrpc-agent ships TWO entries: the doc-only module (`index`) and the CLI
* `bin` (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`.
* The root tsdown builds only `lib/types/index.js`, so this override adds
* `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false),
* matching every package.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/bin.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
})

View File

@@ -0,0 +1,23 @@
# @deepseek-ai/dsh-jsonrpc
The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC server that lets an out-of-process SDK client (e.g. the Python `deepseek_harness` package) drive DeepSeek Harness agents without touching Cordis. The client speaks newline-delimited JSON-RPC on the process stdin/stdout ([`HarnessSdkServer`](src/server.ts): `initialize` → `session/prompt` → `shutdown`, with `session.event` / `session.finished` / `subagent.*` notifications over [`JsonRpcLineTransport`](src/transport.ts)). The SDK-client analogue of the [`acp`](../acp/README.md) bridge, split the same way: this package is the protocol plugin, [`jsonrpc-agent`](../jsonrpc-agent/README.md) is the app bin that boots a `cordis.yml` around it — which process serves this protocol is a config decision, not a hardcoded bin. This plugin is the serving face of the single-exe distribution plan — see [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
## Wiring
`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`) and demuxes `subagent/end` through the registry. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`.
## Config
No `cordis.yml`-settable keys. The `JsonRpcConfig` fields (`input`, `output`, `exit`) are runtime-only test seams so a spec can drive the server over in-memory streams without a subprocess or a killed test process; production always serves the process stdio and exits via `process.exit`.
## stdout is the protocol
The process stdout this plugin runs in carries only JSON-RPC frames. The tree that loads it must load NO stdout logger (a console logger corrupts the frames) — the guarantee is config-only, same as the ACP bridge. Diagnostics go to stderr.
## Shutdown and exit semantics
The plugin owns the PROTOCOL-level exit: a `shutdown` request is answered first (the response frame flushes), then the plugin disposes its own fiber — running the effect disposer: an idempotent `server.shutdown()` (every SDK-created agent disposed to quiescence, event subscriptions detached) plus `transport.close()` — and exits the process with code 0. Own-fiber disposal is deliberate: the request's `server.shutdown()` already flushed all SDK-owned session state, and the process exit that follows is the teardown of the rest of the tree. Process-level exits (stdin EOF → 0, SIGTERM → 0, SIGINT → 130) belong to the app bin, which disposes the whole root context. Fiber disposal WITHOUT a `shutdown` request (HMR-style unload) just stops serving — it never exits the process.
## Wire notes
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). A session accepts at most one in-flight `session/prompt`; an overlapping prompt for the same `sessionId` fails immediately through the standard handler-error response, while other sessions remain independent and the same session can be reused after the active prompt settles. Persistence roots and the deployment persona come from `cordis.yml`; the wire exposes only parameters the server applies.

View File

@@ -0,0 +1,46 @@
{
"name": "@deepseek-ai/dsh-jsonrpc",
"description": "Stdio JSON-RPC SDK server plugin: serves HarnessSdkServer over newline-delimited JSON-RPC on the process stdio, letting an out-of-process SDK client (e.g. the Python SDK) drive DeepSeek Harness agents",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.17.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-deepseek": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,144 @@
/**
* The SDK-facing stdio JSON-RPC server plugin: mounting it wires a
* {@link JsonRpcLineTransport} over the process stdio and serves
* {@link HarnessSdkServer} (`initialize` → `session/prompt`* → `shutdown`,
* plus the `session.*`/`subagent.*` notifications) to an out-of-process SDK
* client (e.g. the Python `deepseek_harness` package). The structured
* SDK-client analogue of the `acp` bridge: a client-driver plugin over
* `ctx.agents`, not a loop change and not a capability seam. Which process
* actually serves this protocol is a `cordis.yml` decision — the tree that
* loads this plugin IS the SDK server (the `dsh-jsonrpc-agent` bin boots such
* a tree for the single-exe distribution; see
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
*
* stdout is the protocol: this plugin must run in a tree that loads NO stdout
* logger (the console logger writes to stdout and would corrupt the JSON-RPC
* frames). The guarantee is config-only — see the package README.
*
* Exit-lifecycle split: this plugin owns the PROTOCOL-level exit (the
* `shutdown` request answers first, then the plugin disposes its own fiber and
* exits 0 — see {@link apply}); process-level exits (stdin EOF, SIGTERM,
* SIGINT) belong to the app bin (`dsh-jsonrpc-agent`), which disposes the
* whole root context.
*
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
* export — the cordis Loader's `unwrapExports` does `exports.default ??
* exports`, so a stray default would collapse the module to the bare `apply`
* and silently drop `inject`/`name`/`Config` (see docs/postmortem/0001).
*
* @module @deepseek-ai/dsh-jsonrpc
*/
import type { Context } from 'cordis'
import type { Readable, Writable } from 'node:stream'
import Schema from 'schemastery'
import { HarnessSdkServer } from './server.ts'
import { JsonRpcLineTransport } from './transport.ts'
export * from './server.ts'
export * from './transport.ts'
export const name = 'jsonrpc'
// The server programs against the agent factory only: `agents` is read on
// every `session/prompt` (get-or-create) and on `subagent/end` demux. The LLM
// seam is deliberately NOT injected — `initialize` reads it opportunistically
// via `ctx.get('llm')` (the topology-independent lookup for a non-injected
// service, per packages/AGENTS.md) to decide whether to lazily mount the
// DeepSeek adapter for the requested model.
export const inject = ['agents']
/**
* Plugin config. Every field is a runtime-only test seam — none is part of the
* schemastery {@link Config}, so nothing here is settable from a `cordis.yml`
* (production always serves the process stdio and exits via `process.exit`).
*/
export interface JsonRpcConfig {
/**
* Transport input override. Production omits this (the plugin reads
* `process.stdin`); tests inject an in-memory `Readable` to drive the server
* without a subprocess.
*/
input?: Readable
/**
* Transport output override. Production omits this (the plugin writes
* `process.stdout` — the protocol channel); tests inject an in-memory
* `Writable` to capture frames.
*/
output?: Writable
/**
* Process-exit override for the `shutdown` request path. Production omits
* this (`process.exit`); tests inject a recorder so a driven shutdown does
* not kill the test process.
*/
exit?: (code: number) => void
}
export const Config: Schema<JsonRpcConfig> = Schema.object({})
/**
* Mount the SDK server on the process stdio: build the line transport and
* {@link HarnessSdkServer}, dispatch incoming requests, and start reading
* frames. Disposal is an effect: disposing this plugin's fiber runs
* `server.shutdown()` (disposes every SDK-created agent to quiescence and
* detaches the event subscriptions) and `transport.close()`.
*
* The `shutdown` request's process-exit semantics live HERE, because the
* plugin owns the server and transport: the request is answered first, an
* explicit output-write barrier confirms the response frame flushed, then the
* plugin disposes its
* OWN fiber and calls `exit(0)`. Own-fiber disposal is sufficient — the
* request's `server.shutdown()` already brought every SDK-created agent to
* quiescence (their session logs are flushed by the awaited agent-handle
* disposes), the fiber's effect disposer re-runs the idempotent shutdown and
* closes the transport, and the process exit that follows IS the teardown of
* the rest of the tree (the bin's EOF/signal handlers own root-context
* disposal for the process-level exits).
*/
export function apply(ctx: Context, config: JsonRpcConfig): void {
// Capture the fiber handle NOW, during apply(): the shutdown path runs LATER,
// from the transport's read loop, and must dispose exactly this plugin's
// fiber (cf. the injection-scope capture note in the acp bridge).
const fiber = ctx.fiber
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
const input = config.input ?? process.stdin
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
const output = config.output ?? process.stdout
/* v8 ignore next -- production exit wiring; tests always inject the runtime seams */
const exit = config.exit ?? ((code: number): void => { process.exit(code) })
const transport = new JsonRpcLineTransport(input, output)
const server = new HarnessSdkServer(ctx, transport)
// The shutdown-request exit path, exactly once (a second `shutdown` frame
// racing the dispose shares the same task). Flush and disposal failures are
// settled independently: once shutdown was answered, process exit is still
// the honest outcome and neither failure may prevent the next teardown step.
let exitTask: Promise<void> | undefined
const disposeAndExit = (): Promise<void> => {
exitTask ??= (async () => {
await Promise.allSettled([Promise.resolve().then(() => transport.flush())])
await Promise.allSettled([Promise.resolve().then(() => fiber.dispose())])
exit(0)
})()
return exitTask
}
transport.onRequest(async (method, params) => {
const result = await server.handleRequest(method, params)
if (method === 'shutdown') {
// The transport writes the returned result after this handler resolves.
// Schedule the explicit flush barrier after that write, then dispose this
// plugin's fiber and exit 0 (see apply's doc).
setImmediate(() => { void disposeAndExit() })
}
return result
})
ctx.effect(() => {
transport.start()
return async () => {
await server.shutdown()
transport.close()
}
}, 'jsonrpc.serve')
}

View File

@@ -0,0 +1,276 @@
/**
* `HarnessSdkServer`: the JSON-RPC method surface the `dsh-jsonrpc` plugin
* serves to out-of-process SDK clients (e.g. the Python `deepseek_harness`
* package). Requests: `initialize` → `session/prompt`* → `shutdown`.
* Notifications pushed to the host: `session.event` (every durable session
* event, verbatim), `session.finished` (per prompt turn settle),
* `subagent.started` / `subagent.finished` (child-session lineage and run
* outcomes). The server owns only the SDK-facing session map — the harness
* itself is the context the plugin mounts in; plugins, persistence, and
* the LLM adapter set all come from the external `cordis.yml`.
*
* @module @deepseek-ai/dsh-jsonrpc/server
*/
import type { Context } from 'cordis'
import { resolve } from 'node:path'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { JsonRpcTransportPeer } from './transport.ts'
/** Parameters of the `initialize` request (once per process, before any prompt). */
export interface InitializeParams {
/** Working directory recorded on every SDK-created session's header. */
cwd: string
/** Model name every SDK-created agent runs on (see {@link HarnessSdkServer.initialize} for adapter fallback). */
model: string
}
/** Result of the `initialize` request: the server's identity for the SDK handshake. */
export interface InitializeResult {
/** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */
serverInfo: { name: string; version: string }
}
/**
* Parameters of a `session/prompt` request: one user turn on one SDK session,
* with at most one in flight per session.
*/
export interface SessionPromptParams {
/** The SDK-side session id; an unknown id lazily creates the agent+session pair. */
sessionId: string
/** The prompt content blocks, sent verbatim as the user message. */
contentBlocks: ContentBlock[]
}
/** Result of a `session/prompt` request: the prompt ran to turn settle (outcome rides on `session.finished`). */
export interface SessionPromptResult {
/** Always `true`; the turn outcome is the paired `session.finished` notification. */
accepted: true
}
interface SessionRecord {
handle: AgentHandle
lastTurnEnd: TurnEndReason | undefined
activePrompt: boolean
}
interface SubagentRecord {
childSessionId: string
parentSessionId: string | undefined
}
/**
* The SDK server over a booted harness context. Constructing it subscribes to
* the context's `session/event`, `session/created`, `agent/created`, and
* `subagent/end` events and forwards them to the host as notifications; the
* subscriptions live until {@link shutdown}. One instance serves one transport
* peer for the process lifetime — there is no re-`initialize`.
*/
export class HarnessSdkServer {
private cwd = process.cwd()
private model = 'deepseek'
private llmFiber: { dispose(): Promise<void> } | undefined
private readonly sessions = new Map<string, SessionRecord>()
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
private readonly subagentSessions = new Map<string, SubagentRecord>()
private readonly disposers: (() => void)[] = []
private shutdownTask: Promise<Record<string, never>> | undefined
private shuttingDown = false
constructor(
private readonly ctx: Context,
private readonly transport: JsonRpcTransportPeer,
) {
this.disposers.push(ctx.on('session/event', (session, event) => {
if (event.type === 'turn/end') {
const rec = this.sessions.get(String(session.id))
if (rec) rec.lastTurnEnd = event.data.reason
}
this.transport.notify('session.event', { sessionId: String(session.id), event })
}))
this.disposers.push(ctx.on('session/created', (session) => {
const parentSession = session.header.parentSession
if (parentSession === undefined) return
this.transport.notify('subagent.started', {
parentSessionId: String(parentSession),
childSessionId: String(session.id),
})
}))
// Cache agent → session lineage on creation: by the time `subagent/end`
// fires the child agent may already be disposed and gone from the registry.
this.disposers.push(ctx.on('agent/created', (agent) => {
this.subagentSessions.set(String(agent.id), {
childSessionId: String(agent.session.id),
parentSessionId: agent.session.header.parentSession === undefined
? undefined
: String(agent.session.header.parentSession),
})
}))
this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => {
const rec = this.subagentSessions.get(String(info.id))
const agent = this.ctx.agents.get(info.id)
const childSessionId = rec?.childSessionId ?? (agent === undefined ? undefined : String(agent.session.id))
const parentSessionId = rec?.parentSessionId ?? (
agent?.session.header.parentSession === undefined ? undefined : String(agent.session.header.parentSession)
)
if (childSessionId === undefined) return
this.transport.notify('subagent.finished', {
provider: info.provider,
agentId: String(info.id),
...(parentSessionId === undefined ? {} : { parentSessionId }),
childSessionId,
status: info.stopReason === 'completed' ? 'ok' : 'error',
stopReason: info.stopReason,
...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
})
}))
}
/**
* Handle `initialize`: record the SDK deployment facts (cwd, model) and, when
* no registered adapter serves `params.model`, mount the DeepSeek adapter for
* it (credentials from `$DEEPSEEK_API_KEY`/`$DEEPSEEK_BASE_URL`) — a config
* that already registered an adapter for the model wins.
* @param params - the SDK handshake parameters.
* @returns the server identity for the handshake.
*/
async initialize(params: InitializeParams): Promise<InitializeResult> {
this.cwd = resolve(params.cwd)
this.model = params.model
if (!this.llmFiber && !this.hasAdapterFor(this.model)) {
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, { models: [this.model] })
}
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
}
/**
* Handle `session/prompt`: get-or-create the session's agent, send the
* content as the user message, await turn settle (quiescence), then notify
* `session.finished` with the settled turn's outcome. A session accepts at
* most one prompt at a time; an overlapping request fails immediately while
* other sessions remain independent.
* @param params - the target session id and prompt content.
* @returns `{ accepted: true }` after the turn settled.
*/
async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
const rec = await this.getOrCreateSession(params.sessionId)
if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`)
rec.activePrompt = true
try {
rec.lastTurnEnd = undefined
rec.handle.agent.send(params.contentBlocks)
await rec.handle.agent.whenIdle()
const status = this.finishedStatus(rec.lastTurnEnd)
this.transport.notify('session.finished', {
sessionId: params.sessionId,
status,
reason: rec.lastTurnEnd,
})
return { accepted: true }
} finally {
rec.activePrompt = false
}
}
/**
* Handle `shutdown`: dispose every SDK-created agent handle (awaiting loop
* quiescence), unmount the adapter fiber this server mounted (if any), and
* detach the event subscriptions. The CONTEXT stays up — the bin disposes it
* as part of process exit.
* @returns an empty object (the JSON-RPC result).
*/
shutdown(): Promise<Record<string, never>> {
this.shutdownTask ??= this.performShutdown()
return this.shutdownTask
}
private async performShutdown(): Promise<Record<string, never>> {
this.shuttingDown = true
const pendingCreations = [...this.sessionCreations.values()]
await Promise.allSettled(pendingCreations)
this.sessionCreations.clear()
const records = [...this.sessions.values()]
this.sessions.clear()
this.subagentSessions.clear()
const failures: unknown[] = []
while (this.disposers.length > 0) {
try {
this.disposers.pop()?.()
} catch (error) {
failures.push(error)
}
}
const teardownResults = await Promise.allSettled([
...records.map(rec => Promise.resolve().then(() => rec.handle.dispose())),
...(this.llmFiber === undefined ? [] : [Promise.resolve().then(() => this.llmFiber?.dispose())]),
])
this.llmFiber = undefined
failures.push(...teardownResults
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map(result => result.reason as unknown))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'SDK server teardown failed')
return {}
}
/**
* Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a
* JSON-RPC error response) on an unknown method.
* @param method - the JSON-RPC method name.
* @param params - the raw params object from the wire.
* @returns the handler's result, to be serialized as the response.
*/
async handleRequest(method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
switch (method) {
case 'initialize':
return this.initialize(params as unknown as InitializeParams)
case 'session/prompt':
return this.prompt(params as unknown as SessionPromptParams)
case 'shutdown':
return this.shutdown()
default:
throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`)
}
}
private async getOrCreateSession(sessionId: string): Promise<SessionRecord> {
if (this.shuttingDown) throw new Error('SDK server is shutting down')
const existing = this.sessions.get(sessionId)
if (existing) return existing
const pending = this.sessionCreations.get(sessionId)
if (pending) return pending
const creation = this.createSession(sessionId)
this.sessionCreations.set(sessionId, creation)
void creation.then(
() => { this.sessionCreations.delete(sessionId) },
() => { this.sessionCreations.delete(sessionId) },
)
return creation
}
private async createSession(sessionId: string): Promise<SessionRecord> {
const handle = await this.ctx.agents.create({
agentId: AgentId(sessionId),
sessionId: SessionId(sessionId),
meta: { cwd: this.cwd },
agentOptions: { model: this.model },
})
const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false }
this.sessions.set(sessionId, rec)
return rec
}
private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' {
if (!reason) return 'error'
return reason.kind === 'completed' ? 'ok' : 'error'
}
private hasAdapterFor(model: string): boolean {
return this.ctx.get('llm')?.models().includes(model) ?? false
}
}

View File

@@ -0,0 +1,238 @@
/**
* Newline-delimited JSON-RPC 2.0 transport over a byte stream pair (the SDK
* server's stdio channel). One JSON frame per line; a frame with `id`+`method`
* is an incoming request, `id` alone matches a pending outgoing request, and
* `method` alone is a notification. Malformed lines are ignored (a resilient
* wire reader, not a validator); handler failures become JSON-RPC error
* responses, never a crashed transport.
*
* @module @deepseek-ai/dsh-jsonrpc/transport
*/
import { randomUUID } from 'node:crypto'
import type { Readable, Writable } from 'node:stream'
import { StringDecoder } from 'node:string_decoder'
type JsonRpcId = string | number
type RequestHandler = (method: string, params: Record<string, unknown>) => Promise<unknown>
type NotificationHandler = (method: string, params: Record<string, unknown>) => void
/**
* The outbound half of a JSON-RPC peer — what {@link HarnessSdkServer} needs
* to talk BACK to the host: awaited `request`s and fire-and-forget `notify`s.
* Narrow on purpose so tests substitute a recording fake without a stream pair.
*/
export interface JsonRpcTransportPeer {
/**
* Send a request to the remote peer and await its response.
* @param method - the JSON-RPC method name.
* @param params - the request parameters object.
* @returns the remote peer's `result`; rejects on a JSON-RPC `error`
* response, a write failure, or transport/input closure.
*/
request(method: string, params: Record<string, unknown>): Promise<unknown>
/**
* Send a notification (no response expected). An omitted `params` sends no
* `params` member at all.
* @param method - the JSON-RPC method name.
* @param params - the optional notification parameters object.
*/
notify(method: string, params?: Record<string, unknown>): void
}
interface PendingRequest {
resolve: (value: unknown) => void
reject: (error: Error) => void
}
/**
* Line-delimited JSON-RPC 2.0 endpoint over a `Readable`/`Writable` pair.
* Inert until {@link start} attaches the input listeners; {@link close}
* detaches them and rejects every pending outgoing request (dispose-safe: the
* streams themselves are not destroyed — the caller owns them). Incoming
* requests are dispatched to the single {@link onRequest} handler (a missing
* handler answers `-32601 method not found`; a throwing handler answers
* `-32603` with the message); incoming notifications go to {@link
* onNotification} and are dropped without one.
*/
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
private buffer = ''
private readonly decoder = new StringDecoder('utf8')
private started = false
private requestHandler: RequestHandler | undefined
private notificationHandler: NotificationHandler | undefined
private readonly pending = new Map<JsonRpcId, PendingRequest>()
constructor(
private readonly input: Readable,
private readonly output: Writable,
) {}
/** Attach the input listeners and begin reading frames. Idempotent. */
start(): void {
if (this.started) return
this.started = true
this.input.on('data', this.onData)
this.input.on('error', this.onInputError)
this.input.on('end', this.onInputEnd)
}
/**
* Detach the input listeners and reject every pending outgoing request with
* "JSON-RPC transport closed". Safe to call without a prior {@link start}.
*/
close(): void {
this.input.off('data', this.onData)
this.input.off('error', this.onInputError)
this.input.off('end', this.onInputEnd)
this.failPending(new Error('JSON-RPC transport closed'))
}
/**
* Install THE handler for incoming requests (a later call replaces it).
* @param handler - resolves to the response `result`; a rejection becomes a
* `-32603` error response carrying the message.
*/
onRequest(handler: RequestHandler): void {
this.requestHandler = handler
}
/**
* Install THE handler for incoming notifications (a later call replaces it).
* @param handler - invoked per notification with the method and normalized
* params object.
*/
onNotification(handler: NotificationHandler): void {
this.notificationHandler = handler
}
request(method: string, params: Record<string, unknown>): Promise<unknown> {
const id = `req_${randomUUID().replaceAll('-', '')}`
const message = { jsonrpc: '2.0', id, method, params }
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
try {
this.write(message)
} catch (error) {
this.pending.delete(id)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
notify(method: string, params?: Record<string, unknown>): void {
this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params })
}
/**
* Wait until every frame written before this call has reached the output's
* write callback. The empty queued write is a barrier and emits no protocol
* bytes.
* @returns a promise that settles with the output write callback.
*/
flush(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.output.write('', (error) => {
if (error) reject(error)
else resolve()
})
})
}
private readonly onData = (chunk: Buffer | string): void => {
this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk)
this.drainLines()
}
private drainLines(): void {
for (;;) {
const newline = this.buffer.indexOf('\n')
if (newline < 0) break
const line = this.buffer.slice(0, newline).trim()
this.buffer = this.buffer.slice(newline + 1)
if (!line) continue
void this.handleLine(line)
}
}
private readonly onInputError = (error: Error): void => {
this.failPending(error)
}
private readonly onInputEnd = (): void => {
this.buffer += this.decoder.end()
this.drainLines()
this.failPending(new Error('JSON-RPC input closed'))
}
private async handleLine(line: string): Promise<void> {
let message: unknown
try {
message = JSON.parse(line)
} catch {
// Swallows ONLY JSON.parse syntax errors: a malformed wire line is a
// peer bug this resilient reader skips; nothing else runs in the try.
return
}
if (!message || typeof message !== 'object') return
const frame = message as Record<string, unknown>
const id = frame.id
const method = frame.method
if ((typeof id === 'string' || typeof id === 'number') && typeof method === 'string') {
await this.handleIncomingRequest(id, method, objectParams(frame.params))
return
}
if (typeof id === 'string' || typeof id === 'number') {
this.handleIncomingResponse(id, frame)
return
}
if (typeof method === 'string') {
this.notificationHandler?.(method, objectParams(frame.params))
}
}
private async handleIncomingRequest(id: JsonRpcId, method: string, params: Record<string, unknown>): Promise<void> {
const handler = this.requestHandler
if (!handler) {
this.writeError(id, -32601, `method not found: ${method}`)
return
}
try {
const result = await handler(method, params)
this.write({ jsonrpc: '2.0', id, result })
} catch (error) {
this.writeError(id, -32603, error instanceof Error ? error.message : String(error))
}
}
private handleIncomingResponse(id: JsonRpcId, frame: Record<string, unknown>): void {
const pending = this.pending.get(id)
if (!pending) return
this.pending.delete(id)
if (frame.error && typeof frame.error === 'object') {
const error = frame.error as Record<string, unknown>
pending.reject(new Error(typeof error.message === 'string' ? error.message : 'JSON-RPC error'))
return
}
pending.resolve(frame.result)
}
private writeError(id: JsonRpcId, code: number, message: string): void {
this.write({ jsonrpc: '2.0', id, error: { code, message } })
}
private write(message: Record<string, unknown>): void {
this.output.write(`${JSON.stringify(message)}\n`)
}
private failPending(error: Error): void {
const pending = [...this.pending.values()]
this.pending.clear()
for (const waiter of pending) waiter.reject(error)
}
}
/** Normalize JSON-RPC `params` to a plain object (arrays and scalars collapse to `{}`). */
function objectParams(params: unknown): Record<string, unknown> {
return params && typeof params === 'object' && !Array.isArray(params) ? params as Record<string, unknown> : {}
}

View File

@@ -0,0 +1,316 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { PassThrough, Writable } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as jsonrpc from '../src/index.ts'
/**
* apply()-level lifecycle coverage for the @deepseek-ai/dsh-jsonrpc plugin:
* the plugin is mounted through the REAL namespace mount path —
* `ctx.plugin(jsonrpc, config)` over the module namespace object, exactly what
* the Loader hands cordis after `unwrapExports` (plugin-shape.spec pins that
* identity) — with the runtime-only `input`/`output`/`exit` seams from
* {@link jsonrpc.JsonRpcConfig} replacing the process stdio, so the whole
* pipeline (line transport → HarnessSdkServer → notifications back onto the
* wire) runs in-process. The scenarios pin the plugin's exit-lifecycle split:
* a `shutdown` REQUEST answers first, then disposes the plugin's own fiber and
* calls `exit(0)` exactly once (a racing second `shutdown` must not re-exit);
* a bare fiber dispose (HMR-style unload, no request) only stops serving and
* never touches `exit`.
*/
/** One ordered observation on the plugin's outward-facing seams: a JSON-RPC frame written to `output`, or an `exit(code)` call. */
type WireEvent =
| { kind: 'frame'; frame: Record<string, unknown> }
| { kind: 'write-complete'; ids: (string | number)[] }
| { kind: 'exit'; code: number }
interface ApplyHarness {
ctx: Context
/** The jsonrpc plugin's own fiber (NOT the root), for the HMR-style dispose scenario. */
fiber: Awaited<ReturnType<Context['plugin']>>
/** Every output frame and exit call, in observation order — ordering assertions read this. */
events: WireEvent[]
outputErrors: Error[]
send(frame: Record<string, unknown>): void
sendRaw(text: string): void
frames(): Record<string, unknown>[]
exits(): number[]
waitForFrame(predicate: (frame: Record<string, unknown>) => boolean, description: string): Promise<Record<string, unknown>>
dispose(): Promise<void>
}
/** Poll `get` until it yields a value (5s cap) — the output side is fed asynchronously from the transport's read loop. */
async function waitFor<T>(get: () => T | undefined, description: string): Promise<T> {
const deadline = Date.now() + 5000
for (;;) {
const value = get()
if (value !== undefined) return value
if (Date.now() > deadline) throw new Error(`timed out waiting for ${description}`)
await new Promise(resolve => setTimeout(resolve, 5))
}
}
/** Let pending microtasks, setImmediate callbacks, and stream events drain — for asserting that something did NOT happen. */
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 25))
}
/**
* Boot a minimal harness context (agent-core bundle + JSONL persistence, the
* server.spec recipe) and mount the jsonrpc plugin on it through the real
* namespace mount path, with in-memory seams standing in for stdio/exit.
*/
async function mountPlugin(
storageDir: string,
options: { writeDelayMs?: number; failFlush?: boolean } = {},
): Promise<ApplyHarness> {
const ctx = new Context()
await ctx.plugin(agentCore)
await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
await new Promise(resolve => setTimeout(resolve, 50))
const input = new PassThrough()
const events: WireEvent[] = []
const outputErrors: Error[] = []
let pendingOutput = ''
// A hand-rolled Writable (not a PassThrough): _write records frames on
// admission and write-complete only when its callback fires, so a delayed
// output proves exit waits for the transport's flush barrier.
const output = new Writable({
write(chunk: Buffer, _encoding, callback) {
const ids: (string | number)[] = []
pendingOutput += chunk.toString('utf8')
for (;;) {
const newline = pendingOutput.indexOf('\n')
if (newline < 0) break
const line = pendingOutput.slice(0, newline).trim()
pendingOutput = pendingOutput.slice(newline + 1)
if (line) {
const frame = JSON.parse(line) as Record<string, unknown>
events.push({ kind: 'frame', frame })
if (typeof frame.id === 'string' || typeof frame.id === 'number') ids.push(frame.id)
}
}
const complete = (): void => {
if (options.failFlush === true && chunk.length === 0) {
callback(new Error('flush callback failed'))
return
}
events.push({ kind: 'write-complete', ids })
callback()
}
if ((options.writeDelayMs ?? 0) > 0) setTimeout(complete, options.writeDelayMs)
else complete()
},
})
output.on('error', (error: Error) => { outputErrors.push(error) })
const exit = (code: number): void => { events.push({ kind: 'exit', code }) }
const fiber = await ctx.plugin(jsonrpc, { input, output, exit })
const frames = (): Record<string, unknown>[] =>
events.flatMap(event => event.kind === 'frame' ? [event.frame] : [])
return {
ctx,
fiber,
events,
outputErrors,
send: (frame) => { input.write(`${JSON.stringify(frame)}\n`) },
sendRaw: (text) => { input.write(text) },
frames,
exits: () => events.flatMap(event => event.kind === 'exit' ? [event.code] : []),
waitForFrame: (predicate, description) => waitFor(() => frames().find(predicate), description),
dispose: async () => { await ctx.fiber.dispose() },
}
}
const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
vi.unstubAllEnvs()
})
/** The server.spec mock OpenAI-compatible SSE endpoint, so a prompt turn completes without a real key. */
async function mockCompletionServer(): Promise<{ url: string; requests: unknown[] }> {
const requests: unknown[] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
requests.push(JSON.parse(body))
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n')
response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
response.write('data: [DONE]\n\n')
response.end()
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return { url: `http://127.0.0.1:${address.port}`, requests }
}
describe('dsh-jsonrpc plugin apply', () => {
it('serves initialize over the injected stdio pair', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-init-'))
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
const harness = await mountPlugin(storageDir)
try {
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, model: 'apply-model' } })
const response = await harness.waitForFrame(frame => frame.id === 'init-1', 'initialize response')
expect(response).toEqual({
jsonrpc: '2.0',
id: 'init-1',
result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } },
})
expect(harness.exits()).toEqual([])
} finally {
await harness.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('drives a session/prompt turn end-to-end and forwards session notifications as output frames', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-prompt-'))
const llmServer = await mockCompletionServer()
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
const harness = await mountPlugin(storageDir)
try {
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, model: 'dsagent-model' } })
await harness.waitForFrame(frame => frame.id === 1, 'initialize response')
harness.send({
jsonrpc: '2.0',
id: 2,
method: 'session/prompt',
params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'fix it' }] },
})
const response = await harness.waitForFrame(frame => frame.id === 2, 'prompt response')
expect(response.result).toEqual({ accepted: true })
expect(llmServer.requests).toHaveLength(1)
const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
expect(body.model).toBe('dsagent-model')
expect(body.messages.at(-1)?.role).toBe('user')
// The server's notify() path rides the SAME transport apply() built:
// session.event / session.finished arrive as id-less frames on output.
const notifications = harness.frames().filter(frame => frame.id === undefined)
expect(notifications.some(frame => frame.method === 'session.event')).toBe(true)
expect(notifications.find(frame => frame.method === 'session.finished')).toMatchObject({
jsonrpc: '2.0',
params: { sessionId: 'main', status: 'ok' },
})
} finally {
await harness.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-'))
const harness = await mountPlugin(storageDir, { writeDelayMs: 10 })
try {
// Two shutdown frames in ONE chunk: both are dispatched from the same
// read-loop pass, so both setImmediate exit callbacks get scheduled and
// the second must hit the `exiting` guard instead of re-entering.
const first = { jsonrpc: '2.0', id: 'sd-1', method: 'shutdown' }
const second = { jsonrpc: '2.0', id: 'sd-2', method: 'shutdown' }
harness.sendRaw(`${JSON.stringify(first)}\n${JSON.stringify(second)}\n`)
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit recorder call')
expect(harness.exits()).toEqual([0])
// Response-then-exit ordering: both response write callbacks and the
// empty flush barrier complete before exit(0), even on delayed output.
const exitIndex = harness.events.findIndex(event => event.kind === 'exit')
const firstResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-1')
const secondResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-2')
const firstComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-1'))
const secondComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-2'))
const flushComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.length === 0)
expect(firstResponse).toBeGreaterThanOrEqual(0)
expect(secondResponse).toBeGreaterThanOrEqual(0)
expect(firstComplete).toBeGreaterThan(firstResponse)
expect(secondComplete).toBeGreaterThan(secondResponse)
expect(flushComplete).toBeGreaterThan(firstComplete)
expect(flushComplete).toBeGreaterThan(secondComplete)
expect(exitIndex).toBeGreaterThan(flushComplete)
// Idempotent: the racing second shutdown never produces a second exit.
await settle()
expect(harness.exits()).toEqual([0])
// The plugin fiber is disposed: the transport reads no further frames.
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
} finally {
await harness.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('still disposes and exits once when the flush callback fails', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-flush-failure-'))
const harness = await mountPlugin(storageDir, { failFlush: true })
try {
harness.send({ jsonrpc: '2.0', id: 'sd-fail', method: 'shutdown' })
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit after flush failure')
await settle()
expect(harness.exits()).toEqual([0])
expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed'])
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
} finally {
await harness.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('stops serving on a bare fiber dispose (HMR-style unload) without calling exit', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-dispose-'))
const harness = await mountPlugin(storageDir)
try {
// Prove the pipeline is live first (an unknown method still answers, as
// a JSON-RPC error frame — the transport's handler-rejection path).
harness.send({ jsonrpc: '2.0', id: 'probe-1', method: 'nope/unknown' })
const error = await harness.waitForFrame(frame => frame.id === 'probe-1', 'error response for unknown method')
expect(error.error).toMatchObject({
code: -32603,
message: 'unknown DeepSeek Harness SDK runtime method: nope/unknown',
})
await harness.fiber.dispose()
// The effect disposer shut the server and closed the transport — later
// frames are never read — and the exit seam was never touched.
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
expect(harness.exits()).toEqual([])
} finally {
await harness.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
})

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import Loader from '@cordisjs/plugin-loader'
import * as jsonrpc from '../src/index.ts'
/**
* REAL-export-path guard for the @deepseek-ai/dsh-jsonrpc namespace plugin
* (the packages/AGENTS.md red line: a plugin shipped via `cordis.yml` needs a
* test through the real Loader/export path). A hand-built `ctx.plugin({...})`
* mount bypasses `unwrapExports` — the exact path that once collapsed a
* namespace plugin with a stray `export default` and silently dropped its
* `inject` (docs/postmortem/0001) — so this spec drives the REAL
* `Loader.unwrapExports` over the module namespace and asserts the
* `name`/`inject`/`Config`/`apply` shape survives it intact.
*/
describe('dsh-jsonrpc plugin export shape', () => {
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
// A stray `export default` would make `unwrapExports` (`exports.default ??
// exports`) collapse the module to the bare default, dropping `inject` —
// the plugin would then throw "cannot get property … without inject" at
// its first `ctx.agents` read. Adding `export default` fails this test.
expect('default' in jsonrpc).toBe(false)
expect(typeof jsonrpc.apply).toBe('function')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(jsonrpc) as Record<string, unknown>
expect(unwrapped).toBe(jsonrpc)
expect(unwrapped.name).toBe('jsonrpc')
expect(unwrapped.inject).toEqual(['agents'])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
})

View File

@@ -0,0 +1,572 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { AgentId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SubagentService, { type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts'
class FakeTransport implements JsonRpcTransportPeer {
notifications: { method: string; params?: Record<string, unknown> }[] = []
async request(method: string, params: Record<string, unknown>): Promise<unknown> {
throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`)
}
notify(method: string, params?: Record<string, unknown>): void {
this.notifications.push(params === undefined ? { method } : { method, params })
}
}
const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
vi.unstubAllEnvs()
})
async function mockCompletionServer(): Promise<{ url: string; requests: unknown[]; headers: IncomingMessage['headers'][] }> {
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
requests.push(JSON.parse(body))
headers.push(request.headers)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n')
response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
response.write('data: [DONE]\n\n')
response.end()
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return { url: `http://127.0.0.1:${address.port}`, requests, headers }
}
async function makeHarness(storageDir: string) {
const ctx = new Context()
await ctx.plugin(agentCore)
await ctx.plugin(SubagentService)
await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
await new Promise(resolve => setTimeout(resolve, 50))
return ctx
}
/** Drive the owning service so test lifecycle events carry the real parent scope. */
async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndInfo): Promise<void> {
const disposeProvider = ctx.subagents.registerProvider({
name: info.provider,
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
async start() {
return {
id: info.id,
result: info.lastAssistantMessage === undefined
? Promise.reject(new Error('synthetic infrastructure failure'))
: Promise.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason }),
dispose: () => Promise.resolve(),
}
},
})
try {
const run = await ctx.subagents.start(info.provider, {
parent,
prompt: [],
signal: new AbortController().signal,
})
await run.result.then(() => undefined, () => undefined)
await run.dispose()
} finally {
disposeProvider()
}
}
describe('HarnessSdkServer', () => {
it('creates a harness agent and calls the configured OpenAI-compatible endpoint', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-'))
const llmServer = await mockCompletionServer()
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
const ctx = await makeHarness(storageDir)
try {
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const init = await server.handleRequest('initialize', {
cwd: storageDir,
model: 'dsagent-model',
}) as { serverInfo: { name: string } }
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
await server.handleRequest('session/prompt', {
sessionId: 'main',
contentBlocks: [{ type: 'text', text: 'fix it' }],
})
expect(llmServer.requests).toHaveLength(1)
const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
expect(body.model).toBe('dsagent-model')
expect(body.messages[0]?.role).toBe('system')
expect(body.messages.at(-1)?.role).toBe('user')
expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key')
expect(transport.notifications.some(n => n.method === 'session.event')).toBe(true)
expect(transport.notifications.at(-1)).toMatchObject({
method: 'session.finished',
params: { sessionId: 'main', status: 'ok' },
})
await server.handleRequest('session/prompt', {
sessionId: 'main',
contentBlocks: [{ type: 'text', text: 'again' }],
})
expect(llmServer.requests).toHaveLength(2)
const orphanHandle = await ctx.agents.create({
agentId: AgentId('orphan-agent'),
sessionId: SessionId('orphan-session'),
meta: { cwd: storageDir },
agentOptions: { model: 'dsagent-model' },
})
orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }])
await orphanHandle.agent.whenIdle()
await orphanHandle.dispose()
expect(llmServer.requests).toHaveLength(3)
await server.handleRequest('shutdown', undefined)
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('rejects overlapping prompts for one session without serializing other sessions', async () => {
let releaseMain: (() => void) | undefined
const firstMainIdle = new Promise<void>((resolve) => { releaseMain = resolve })
const mainWhenIdle = vi.fn<() => Promise<void>>()
.mockReturnValueOnce(firstMainIdle)
.mockResolvedValue(undefined)
const mainSend = vi.fn()
const mainAgent = {
send: mainSend,
whenIdle: mainWhenIdle,
} as unknown as Agent
const otherSend = vi.fn()
const otherAgent = {
send: otherSend,
whenIdle: vi.fn(() => Promise.resolve()),
} as unknown as Agent
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
const create = vi.fn(async (options: { agentId: AgentId }) =>
String(options.agentId) === 'main' ? mainHandle : otherHandle)
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create, get: () => undefined },
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport())
const prompt = (sessionId: string, text: string) => server.prompt({
sessionId,
contentBlocks: [{ type: 'text', text }],
})
const first = prompt('main', 'first')
await vi.waitFor(() => { expect(mainSend).toHaveBeenCalledOnce() })
await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main')
await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true })
releaseMain?.()
await expect(first).resolves.toEqual({ accepted: true })
await expect(prompt('main', 'sequential')).resolves.toEqual({ accepted: true })
mainWhenIdle.mockRejectedValueOnce(new Error('turn wait failed'))
await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed')
await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true })
expect(mainSend).toHaveBeenCalledTimes(4)
expect(otherSend).toHaveBeenCalledOnce()
await server.shutdown()
expect(mainHandle.dispose).toHaveBeenCalledOnce()
expect(otherHandle.dispose).toHaveBeenCalledOnce()
})
it('notifies the host when a child session is created with parent lineage', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-'))
const ctx = await makeHarness(storageDir)
try {
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
ctx.sessions.create(SessionId('root-session'), {
meta: { cwd: storageDir },
})
ctx.sessions.create(SessionId('child-session'), {
meta: { cwd: storageDir, parentSession: SessionId('main') },
})
expect(transport.notifications).toContainEqual({
method: 'subagent.started',
params: {
parentSessionId: 'main',
childSessionId: 'child-session',
},
})
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('creates an SDK session without an optional system prompt', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-no-system-'))
const llmServer = await mockCompletionServer()
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await server.initialize({ cwd: storageDir, model: 'plain-model' })
await server.prompt({
sessionId: 'plain',
contentBlocks: [{ type: 'text', text: 'hello' }],
})
expect(llmServer.requests).toHaveLength(1)
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('notifies the host when a subagent run settles', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-end-'))
const ctx = await makeHarness(storageDir)
try {
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const parentHandle = await ctx.agents.create({
agentId: AgentId('parent-agent'),
sessionId: SessionId('main'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
})
const handle = await ctx.agents.create({
agentId: AgentId('child-agent'),
sessionId: SessionId('child-session'),
meta: { cwd: storageDir, parentSession: SessionId('main') },
agentOptions: { model: 'deepseek' },
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'spawn',
id: AgentId('child-agent'),
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
})
expect(transport.notifications).toContainEqual({
method: 'subagent.finished',
params: {
provider: 'spawn',
agentId: 'child-agent',
parentSessionId: 'main',
childSessionId: 'child-session',
status: 'ok',
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
},
})
await handle.dispose()
await parentHandle.dispose()
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('falls back to live agent lineage for uncached subagent end events', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-'))
const ctx = await makeHarness(storageDir)
let parentHandle: AgentHandle | undefined
let handle: AgentHandle | undefined
let failedHandle: AgentHandle | undefined
try {
parentHandle = await ctx.agents.create({
agentId: AgentId('fallback-parent-agent'),
sessionId: SessionId('fallback-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
})
handle = await ctx.agents.create({
agentId: AgentId('fallback-child-agent'),
sessionId: SessionId('fallback-child-session'),
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
agentOptions: { model: 'deepseek' },
})
failedHandle = await ctx.agents.create({
agentId: AgentId('failed-child-agent'),
sessionId: SessionId('failed-child-session'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
})
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
await settleSubagent(ctx, parentHandle.agent, {
provider: 'fork',
id: AgentId('fallback-child-agent'),
stopReason: 'max-tokens',
lastAssistantMessage: [],
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'fork',
id: AgentId('failed-child-agent'),
stopReason: 'error',
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'fork',
id: AgentId('missing-child-agent'),
stopReason: 'error',
})
expect(transport.notifications).toContainEqual({
method: 'subagent.finished',
params: {
provider: 'fork',
agentId: 'fallback-child-agent',
parentSessionId: 'fallback-parent',
childSessionId: 'fallback-child-session',
status: 'error',
stopReason: 'max-tokens',
lastAssistantMessage: [],
},
})
expect(transport.notifications).toContainEqual({
method: 'subagent.finished',
params: {
provider: 'fork',
agentId: 'failed-child-agent',
childSessionId: 'failed-child-session',
status: 'error',
stopReason: 'error',
},
})
expect(transport.notifications.some(n =>
n.method === 'subagent.finished'
&& n.params?.agentId === 'missing-child-agent',
)).toBe(false)
await server.shutdown()
} finally {
await handle?.dispose()
await failedHandle?.dispose()
await parentHandle?.dispose()
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('does not re-register an LLM adapter that already exists', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-existing-llm-'))
const ctx = await makeHarness(storageDir)
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
await ctx.plugin(LlmDeepSeek, { models: ['preinstalled-model'] })
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
const inspect = server as unknown as { hasAdapterFor(model: string): boolean }
expect(inspect.hasAdapterFor('preinstalled-model')).toBe(true)
expect(inspect.hasAdapterFor('missing-model')).toBe(false)
await server.initialize({ cwd: storageDir, model: 'preinstalled-model' })
expect(ctx.get('llm')?.models().filter(model => model === 'preinstalled-model')).toEqual(['preinstalled-model'])
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('registers a missing model when an LLM service already exists', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-new-llm-'))
const ctx = await makeHarness(storageDir)
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
await ctx.plugin(LlmDeepSeek, { models: ['other-model'] })
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await server.initialize({ cwd: storageDir, model: 'new-model' })
expect(ctx.get('llm')?.models()).toEqual(expect.arrayContaining(['other-model', 'new-model']))
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('classifies defensive finish states', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-finish-states-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
finishedStatus(reason: unknown): 'ok' | 'error'
shutdown(): Promise<Record<string, never>>
}
expect(server.finishedStatus(undefined)).toBe('error')
expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('error')
expect(server.finishedStatus({ kind: 'error' })).toBe('error')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('reports no adapter when the LLM service is absent', async () => {
const ctx = new Context()
try {
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
hasAdapterFor(model: string): boolean
shutdown(): Promise<Record<string, never>>
}
expect(server.hasAdapterFor('missing-model')).toBe(false)
await server.shutdown()
} finally {
await ctx.fiber.dispose()
}
})
it('rejects unknown JSON-RPC runtime methods', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-unknown-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.handleRequest('does/not/exist', {}))
.rejects
.toThrow('unknown DeepSeek Harness SDK runtime method: does/not/exist')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('coalesces concurrent session creation and retries a failed creation', async () => {
let resolveShared: ((handle: AgentHandle) => void) | undefined
const sharedCreation = new Promise<AgentHandle>((resolve) => { resolveShared = resolve })
const sharedHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) }
const retryHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) }
const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
.mockReturnValueOnce(sharedCreation)
.mockRejectedValueOnce(new Error('creation failed'))
.mockResolvedValueOnce(retryHandle)
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create, get: () => undefined },
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
getOrCreateSession(sessionId: string): Promise<{ handle: AgentHandle }>
shutdown(): Promise<Record<string, never>>
}
const first = server.getOrCreateSession('shared')
const second = server.getOrCreateSession('shared')
expect(create).toHaveBeenCalledTimes(1)
resolveShared?.(sharedHandle)
const [firstRecord, secondRecord] = await Promise.all([first, second])
expect(firstRecord).toBe(secondRecord)
await expect(server.getOrCreateSession('retry')).rejects.toThrow('creation failed')
await expect(server.getOrCreateSession('retry')).resolves.toMatchObject({ handle: retryHandle })
expect(create).toHaveBeenCalledTimes(3)
await server.shutdown()
expect(sharedHandle.dispose).toHaveBeenCalledOnce()
expect(retryHandle.dispose).toHaveBeenCalledOnce()
await expect(server.getOrCreateSession('after-shutdown')).rejects.toThrow('SDK server is shutting down')
})
it('resolves a relative cwd before creating the session', async () => {
const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
.mockResolvedValue({ agent: {} as Agent, dispose: () => Promise.resolve() })
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create, get: () => undefined },
get: () => ({ models: () => ['model'] }),
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
initialize(params: { cwd: string; model: string }): Promise<unknown>
getOrCreateSession(sessionId: string): Promise<unknown>
shutdown(): Promise<Record<string, never>>
}
await server.initialize({ cwd: '.', model: 'model' })
await server.getOrCreateSession('relative')
expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } }))
await server.shutdown()
})
it('settles every teardown and aggregates multiple failures', async () => {
const firstDispose = vi.fn(() => { throw new Error('first teardown failed') })
const secondDispose = vi.fn(() => Promise.reject(new Error('second teardown failed')))
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create: vi.fn(), get: () => undefined },
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }>
shutdown(): Promise<Record<string, never>>
}
server.sessions.set('first', { handle: { agent: {} as Agent, dispose: firstDispose }, lastTurnEnd: undefined, activePrompt: false })
server.sessions.set('second', { handle: { agent: {} as Agent, dispose: secondDispose }, lastTurnEnd: undefined, activePrompt: false })
await expect(server.shutdown()).rejects.toThrow('SDK server teardown failed')
expect(firstDispose).toHaveBeenCalledOnce()
expect(secondDispose).toHaveBeenCalledOnce()
})
it('continues teardown after a subscription disposer fails', async () => {
let subscription = 0
const listenerFailure = new Error('listener teardown failed')
const on = vi.fn(() => {
subscription += 1
return subscription === 1 ? () => { throw listenerFailure } : () => undefined
})
const ctx = {
on,
agents: { create: vi.fn(), get: () => undefined },
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.shutdown()).rejects.toBe(listenerFailure)
expect(on).toHaveBeenCalledTimes(4)
})
})

View File

@@ -0,0 +1,260 @@
import { once } from 'node:events'
import { PassThrough, Writable } from 'node:stream'
import { describe, expect, it } from 'vitest'
import { JsonRpcLineTransport } from '../src/index.ts'
function transportPair() {
const aToB = new PassThrough()
const bToA = new PassThrough()
const a = new JsonRpcLineTransport(bToA, aToB)
const b = new JsonRpcLineTransport(aToB, bToA)
return { a, b, aToB, bToA }
}
describe('JsonRpcLineTransport', () => {
it('supports bidirectional requests and notifications over newline-delimited JSON-RPC', async () => {
const { a, b } = transportPair()
const notifications: Record<string, unknown>[] = []
a.onRequest(async (method, params) => {
expect(method).toBe('echo')
return { echoed: params }
})
b.onNotification((method, params) => {
notifications.push({ method, params })
})
a.start()
b.start()
const response = await b.request('echo', { value: 42 })
expect(response).toEqual({ echoed: { value: 42 } })
a.notify('session.finished', { sessionId: 'main', status: 'ok' })
a.notify('heartbeat')
await new Promise(resolve => setTimeout(resolve, 10))
expect(notifications).toEqual([
{ method: 'session.finished', params: { sessionId: 'main', status: 'ok' } },
{ method: 'heartbeat', params: {} },
])
a.close()
b.close()
})
it('reports JSON-RPC request errors from the remote peer', async () => {
const { a, b } = transportPair()
a.onRequest(async () => {
throw new Error('handler boom')
})
a.start()
b.start()
await expect(b.request('explode', {})).rejects.toThrow('handler boom')
a.close()
b.close()
})
it('stringifies non-Error request handler failures', async () => {
const { a, b } = transportPair()
a.onRequest(async () => {
throw 'string boom'
})
a.start()
b.start()
await expect(b.request('explode-string', {})).rejects.toThrow('string boom')
a.close()
b.close()
})
it('reports method-not-found when no request handler is installed', async () => {
const { a, b } = transportPair()
a.start()
b.start()
await expect(b.request('missing', {})).rejects.toThrow('method not found: missing')
a.close()
b.close()
})
it('normalizes non-object request params and ignores notifications without a handler', async () => {
const { aToB, bToA, b } = transportPair()
const seen: Record<string, unknown>[] = []
b.onRequest(async (method, params) => {
seen.push({ method, params })
return { ok: true }
})
b.start()
aToB.write('{"jsonrpc":"2.0","method":"ignored"}\n')
aToB.write('{"jsonrpc":"2.0","id":7,"method":"array-params","params":[]}\n')
const chunk = (await once(bToA, 'data'))[0] as Buffer | string
expect(seen).toEqual([{ method: 'array-params', params: {} }])
expect(JSON.parse(String(chunk))).toEqual({ jsonrpc: '2.0', id: 7, result: { ok: true } })
b.close()
})
it('ignores malformed frames and accepts notifications without params', async () => {
const { aToB, b } = transportPair()
const notifications: Record<string, unknown>[] = []
b.onNotification((method, params) => {
notifications.push({ method, params })
})
b.start()
b.start()
aToB.write('not json\n')
aToB.write('\n')
aToB.write('null\n')
aToB.write('{"jsonrpc":"2.0","params":{}}\n')
aToB.write('{"jsonrpc":"2.0","method":"tick"}\n')
aToB.emit('data', '{"jsonrpc":"2.0","method":"string-chunk"}\n')
await new Promise(resolve => setTimeout(resolve, 10))
expect(notifications).toEqual([
{ method: 'tick', params: {} },
{ method: 'string-chunk', params: {} },
])
b.close()
})
it('preserves multibyte UTF-8 characters split across Buffer chunks', async () => {
const input = new PassThrough()
const output = new PassThrough()
const transport = new JsonRpcLineTransport(input, output)
const notifications: Record<string, unknown>[] = []
transport.onNotification((method, params) => { notifications.push({ method, params }) })
transport.start()
const frame = Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', method: 'message', params: { text: '你好' } })}\n`)
const character = Buffer.from('你')
const characterStart = frame.indexOf(character)
expect(characterStart).toBeGreaterThanOrEqual(0)
input.write(frame.subarray(0, characterStart + 1))
input.write(frame.subarray(characterStart + 1))
await new Promise(resolve => setTimeout(resolve, 10))
expect(notifications).toEqual([{ method: 'message', params: { text: '你好' } }])
transport.close()
})
it('flush waits for all earlier output writes', async () => {
const events: string[] = []
const output = new Writable({
write(chunk: Buffer, _encoding, callback) {
const label = chunk.length === 0 ? 'barrier' : 'frame'
events.push(`start:${label}`)
setTimeout(() => {
events.push(`finish:${label}`)
callback()
}, 5)
},
})
const transport = new JsonRpcLineTransport(new PassThrough(), output)
transport.notify('tick')
await transport.flush()
expect(events).toEqual([
'start:frame',
'finish:frame',
'start:barrier',
'finish:barrier',
])
transport.close()
})
it('reports an output callback failure from flush', async () => {
const output = {
write(_chunk: string, callback?: (error?: Error) => void) {
callback?.(new Error('flush failed'))
return true
},
}
const transport = new JsonRpcLineTransport(new PassThrough(), output as never)
await expect(transport.flush()).rejects.toThrow('flush failed')
})
it('rejects pending requests when the input closes', async () => {
const { aToB, b } = transportPair()
b.start()
const pending = b.request('never-replies', {})
aToB.end()
await expect(pending).rejects.toThrow('JSON-RPC input closed')
b.close()
})
it('rejects pending requests when the input errors', async () => {
const { aToB, b } = transportPair()
b.start()
const pending = b.request('never-replies', {})
aToB.emit('error', new Error('input broke'))
await expect(pending).rejects.toThrow('input broke')
b.close()
})
it('rejects pending requests when the transport closes', async () => {
const { b } = transportPair()
const pending = b.request('never-replies', {})
b.close()
await expect(pending).rejects.toThrow('JSON-RPC transport closed')
})
it('rejects a request when writing the frame throws', async () => {
const input = new PassThrough()
const output = {
write() {
throw new Error('write exploded')
},
}
const transport = new JsonRpcLineTransport(input, output as never)
await expect(transport.request('write-fails', {})).rejects.toThrow('write exploded')
})
it('stringifies non-Error write failures', async () => {
const input = new PassThrough()
const output = {
write() {
throw 'write string'
},
}
const transport = new JsonRpcLineTransport(input, output as never)
await expect(transport.request('write-fails', {})).rejects.toThrow('write string')
})
it('uses a fallback message for malformed JSON-RPC error responses', async () => {
const { aToB, bToA, b } = transportPair()
b.start()
const pending = b.request('remote-error', {})
const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string
const request = JSON.parse(String(requestChunk)) as { id: string }
aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: {} })}\n`)
await expect(pending).rejects.toThrow('JSON-RPC error')
b.close()
})
it('ignores responses that do not match a pending request', async () => {
const { aToB, b } = transportPair()
b.start()
aToB.write('{"jsonrpc":"2.0","id":"unknown","result":{"ignored":true}}\n')
await new Promise(resolve => setTimeout(resolve, 10))
b.close()
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../llm/llm-deepseek"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../subagent/subagent"
}
]
}

View File

@@ -0,0 +1,7 @@
# @deepseek-ai/dsh-permission
User-facing permission presets. Owns the `ctx.permission` service ([`PermissionService`](src/index.ts)): a config-defined preset table — by default `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`) — where each name bundles the two mechanism knobs, `bash/sandbox-mode` and `approval/policy`. The product surface (the ACP bridge's single `Permissions` select) advertises `names` and calls `set()`; the mechanism tiers stay orthogonal capabilities that never learn the product vocabulary.
A switch WRITES THROUGH: `set(session, name)` appends one log-only `permission/preset` event when the name differs from the session's current preset (the audit fact reverse-mapping cannot recover — two presets may share knob values and differ only in composed policy, the planned `agent` preset being the standing example), then each knob event through its own THE-write-path setter, skipping values the session already effectively has — a net-zero switch appends nothing. The current preset DERIVES from the effective knob values (fold ?? composition default per knob): the last-chosen preset when its bundle still matches (presets may share bundles — the fold breaks the tie), else the first matching table entry, else the reserved `custom` — the honest not-a-preset state, shown as the current value only while it holds, switchable FROM and never a target. Every existing knob consumer (executor stamping, the approval gate, narrators, resume) keeps reading its own fold, untouched.
Composing it requires a confining `ctx.bash` executor and the `ctx.approval` seam; a table entry named `custom` throws at load (the name is reserved), while composition defaults outside the table are not an error — a zero-event session simply derives `custom`. See [the acp-agent example's default tree](../../../examples/acp-agent/) for the composed leaf and [the sandbox RFC § Per-session modes](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the switching design this layers over.

View File

@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-permission",
"description": "User-facing permission presets (ctx.permission) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,237 @@
/**
* User-facing PERMISSION PRESETS: one product-level knob over the two
* mechanism knobs. A preset names a bundle — its sandbox mode
* (`bash/sandbox-mode`) and its approval policy (`approval/policy`) — so a
* user picks `workspace-write` or `danger-full-access` while the mechanism
* tiers stay orthogonal capabilities. Switching a preset WRITES THROUGH: one `permission/preset` event
* records the chosen bundle (the audit fact reverse-mapping cannot recover —
* two presets may share knob values and differ only in composed policy, the
* planned `agent` preset being the standing example), then each knob event
* follows through its own THE-write-path setter, skipping values the session
* already effectively has. Every existing consumer (executor stamping, the
* approval gate, narrators, resume) keeps reading its own knob fold,
* untouched.
*
* @module dsh-permission
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
declare module 'cordis' {
interface Context {
permission: PermissionService
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* The session's permission preset was switched — log-only (the
* `bash/sandbox-mode` precedent): durable and replayable, never in the
* model transcript. The LAST such event is the session's preset
* ({@link effectivePermissionPreset}); the knob events the switch wrote
* through follow it in the same turn, and they — not this record of the
* user's choice — are what execution reads.
*/
'permission/preset': { preset: string }
}
}
/**
* One preset's knob bundle — the sandbox mode and approval policy a session
* runs under while the preset is active — plus its presentation.
*/
export interface PresetSpec {
/** The `bash/sandbox-mode` value the preset writes through. */
sandbox: SandboxMode
/** The `approval/policy` value the preset writes through. */
approval: ApprovalPolicy
/** The display label a client shows for this preset; the raw table key when omitted. */
name?: string
/** One user-facing sentence on what the preset means; omitted when not configured. */
description?: string
}
/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
export interface PresetOption {
/** The machine value (`session/set_config_option` vocabulary): the table key, or `custom`. */
value: string
/** The display label. */
name: string
/** One user-facing sentence on what the value means. */
description?: string
}
/**
* The derived not-a-preset state: the session's effective knob values match
* no table entry (composition defaults outside the table, or a knob moved
* out from under the last-chosen preset). Never a switch target and never
* an event payload — {@link PermissionService.current} derives it, and the
* presentation layer shows it as a selectable-FROM-only current value.
*/
export const CUSTOM_PRESET = 'custom'
/**
* The session's permission-preset override: the last `permission/preset` event in the
* log, or undefined when the session never switched (callers apply the
* plugin's configured default). The pure fold — resume needs no catch-up
* machinery because replaying the log IS the state.
* @param events - session events in log order (other event types are skipped).
* @returns the preset of the last switch event, or undefined without one.
*/
export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index] as SessionEvent
if (event.type === 'permission/preset') return event.data.preset
}
return undefined
}
/** The {@link PermissionService} config: the deployment's preset table. */
export interface Config {
/**
* The preset table: name → knob bundle. Defaults to `workspace-write`
* (workspace-write + ask) and `danger-full-access` (danger-full-access +
* never). The name `custom` is reserved for the derived not-a-preset state.
*/
presets?: Record<string, PresetSpec>
}
/**
* The permission service (`ctx.permission`). Owns the deployment's preset
* table and THE write path for preset switches; presentation layers (the ACP
* bridge's single `Permissions` select) advertise {@link names} and call
* {@link set}. Composing it REQUIRES both mechanism knobs — a confining
* `ctx.bash` executor and the `ctx.approval` seam. A knob state matching no
* table entry is not an error but the derived {@link CUSTOM_PRESET} state:
* shown as the current value, never a switch target.
*/
export class PermissionService extends Service {
// Inline schema call: the config catalog walks `static Config` statically.
static Config: z<Config> = z.object({
presets: z.dict(z.object({
sandbox: z.union(SANDBOX_MODES as SandboxMode[]).required(),
approval: z.union(APPROVAL_POLICIES as ApprovalPolicy[]).required(),
name: z.string(),
description: z.string(),
})).default({
// Keep the user-facing preset names explicit about filesystem reach.
'workspace-write': {
sandbox: 'workspace-write', approval: 'ask',
name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.',
},
'danger-full-access': {
sandbox: 'danger-full-access', approval: 'never',
name: 'danger-full-access', description: 'Full file access, no approval prompts.',
},
}),
})
static inject = ['bash', 'approval']
private readonly presets: Record<string, PresetSpec>
constructor(ctx: Context, config: Config) {
super(ctx, 'permission')
// The schema defaulted the table — the cast records that runtime fact.
this.presets = config.presets as Record<string, PresetSpec>
if (CUSTOM_PRESET in this.presets) {
throw new Error(`permission: "${CUSTOM_PRESET}" is reserved for the derived not-a-preset state and cannot name a table entry`)
}
if (ctx.bash.sandboxMode === undefined) {
throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration')
}
}
/**
* The advertised preset names, in the preset table's declaration order.
* @returns every switchable preset name.
*/
get names(): readonly string[] {
return Object.keys(this.presets)
}
/**
* The preset a session is on right now, derived from the EFFECTIVE knob
* values (fold ?? composition default per knob): the last-chosen preset
* when its bundle still matches (presets may share bundles — the fold
* breaks the tie), else the first table entry that matches, else
* {@link CUSTOM_PRESET} — a mismatch is a state, not an error.
* @param events - the session's events in log order.
* @returns the effective preset name, or `custom` when nothing matches.
*/
current(events: readonly SessionEvent[]): string {
const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode
const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask'
const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval
const folded = effectivePermissionPreset(events)
if (folded !== undefined) {
const spec = this.presets[folded]
if (spec !== undefined && matches(spec)) return folded
}
for (const [name, spec] of Object.entries(this.presets)) {
if (matches(spec)) return name
}
return CUSTOM_PRESET
}
/**
* A preset's knob bundle, for consumers presenting or validating one.
* @param name - the preset name to resolve.
* @returns the bundle; throws on a name outside the table (fails loud —
* an unvalidated caller handed the service an unknown preset).
*/
resolve(name: string): PresetSpec {
const spec = this.presets[name]
if (spec === undefined) {
throw new Error(`permission: unknown preset "${name}" (known: ${Object.keys(this.presets).join(', ')})`)
}
return spec
}
/**
* The select-option presentation of one advertisable value: a table entry
* (label/description from its spec, the raw key standing in for a missing
* label) or the derived {@link CUSTOM_PRESET} with its fixed presentation.
* @param name - a table key, or `custom`.
* @returns the option a client renders; throws on any other name.
*/
optionOf(name: string): PresetOption {
if (name === CUSTOM_PRESET) {
return { value: CUSTOM_PRESET, name: 'Custom', description: 'A hand-set knob combination outside the preset table.' }
}
const spec = this.resolve(name)
return { value: name, name: spec.name ?? name, ...spec.description !== undefined ? { description: spec.description } : {} }
}
/**
* THE write path for a preset switch: appends one `permission/preset` event when
* `name` differs from the session's current preset, then writes each knob
* through its own setter, skipping values the session already effectively
* has — a net-zero switch appends nothing (the log records switches, not
* select clicks).
* @param session - the session the switch belongs to.
* @param name - the preset to switch to (validated via {@link resolve}).
*/
set(session: Session, name: string): void {
const spec = this.resolve(name)
if (this.current(session.events) !== name) {
session.append('permission/preset', { preset: name })
}
const events = session.events
if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) {
setSandboxMode(session, spec.sandbox)
}
if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {
setApprovalPolicy(session, spec.approval)
}
}
}
export default PermissionService

View File

@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission'
import type { Config } from '@deepseek-ai/dsh-permission'
/** Mount the service over stand-in bash/approval capabilities (the two facts it validates against). */
async function mounted(options: {
config?: Config
bashDefault?: SandboxMode | undefined
approvalDefault?: ApprovalPolicy | undefined
} = {}): Promise<Context> {
const ctx = new Context()
ctx.provide('bash', { sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write' })
ctx.provide('approval', { config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' } })
await ctx.plugin(PermissionService, options.config ?? {})
return ctx
}
/** A real Session seeded with one opened turn (events append without ceremony in unit scope). */
function freshSession(id: string): Session {
return new Session(SessionId(id))
}
describe('effectivePermissionPreset', () => {
it('folds to the last event, or undefined without one', () => {
const session = freshSession('sess-fold')
expect(effectivePermissionPreset(session.events)).toBeUndefined()
session.append('permission/preset', { preset: 'danger-full-access' })
session.append('permission/preset', { preset: 'workspace-write' })
expect(effectivePermissionPreset(session.events)).toBe('workspace-write')
})
})
describe('PermissionService', () => {
it('advertises the preset table in declaration order and resolves bundles', async () => {
const ctx = await mounted()
expect(ctx.permission.names).toEqual(['workspace-write', 'danger-full-access'])
expect(ctx.permission.resolve('danger-full-access')).toMatchObject({ sandbox: 'danger-full-access', approval: 'never' })
expect(() => ctx.permission.resolve('plan')).toThrow(/unknown preset "plan"/)
})
it('current() derives from the effective knobs: composition defaults hit workspace-write, a switch hits its preset', async () => {
const ctx = await mounted()
const session = freshSession('sess-current')
expect(ctx.permission.current(session.events)).toBe('workspace-write')
ctx.permission.set(session, 'danger-full-access')
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
})
it('a knob state matching no table entry derives custom — a state, not an error', async () => {
const ctx = await mounted()
const session = freshSession('sess-custom')
session.append('bash/sandbox-mode', { mode: 'read-only' })
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
// Switching FROM custom is an ordinary write-through; custom itself is
// never a target.
ctx.permission.set(session, 'danger-full-access')
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
})
it('composition defaults outside the table derive custom at zero events', async () => {
const ctx = await mounted({ approvalDefault: 'never' })
const session = freshSession('sess-defaults-custom')
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
})
it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => {
const ctx = await mounted({ config: { presets: {
'workspace-write': { sandbox: 'workspace-write', approval: 'ask' },
agentish: { sandbox: 'workspace-write', approval: 'ask' },
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' },
} } })
const session = freshSession('sess-tie')
// Same bundle as workspace-write, chosen explicitly: the fold names it.
ctx.permission.set(session, 'agentish')
expect(ctx.permission.current(session.events)).toBe('agentish')
// A knob drifts: the fold's bundle no longer matches → reverse map wins.
session.append('approval/policy', { policy: 'never' })
session.append('bash/sandbox-mode', { mode: 'danger-full-access' })
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
})
it('set() writes through: one preset event plus both knob events', async () => {
const ctx = await mounted()
const session = freshSession('sess-set')
ctx.permission.set(session, 'danger-full-access')
expect(session.events.map(e => [e.type, e.data])).toEqual([
['permission/preset', { preset: 'danger-full-access' }],
['bash/sandbox-mode', { mode: 'danger-full-access' }],
['approval/policy', { policy: 'never' }],
])
})
it('set() to the current preset is a no-op when the knobs already match (clicks are not switches)', async () => {
const ctx = await mounted()
const session = freshSession('sess-noop')
ctx.permission.set(session, 'workspace-write')
expect(session.events).toHaveLength(0)
})
it('re-asserting a preset from a drifted (custom) state re-records the choice and repairs the knob', async () => {
const ctx = await mounted()
const session = freshSession('sess-drift')
ctx.permission.set(session, 'danger-full-access')
// A knob drifts out from under the preset (a direct setter call, a test
// scenario): the session derives custom, and re-asserting the preset is
// a real switch again — choice re-recorded, only the drifted knob moves.
session.append('bash/sandbox-mode', { mode: 'read-only' })
ctx.permission.set(session, 'danger-full-access')
const tail = session.events.slice(4)
expect(tail.map(e => [e.type, e.data])).toEqual([
['permission/preset', { preset: 'danger-full-access' }],
['bash/sandbox-mode', { mode: 'danger-full-access' }],
])
})
it('rejects composition over a non-confining executor at load', async () => {
await expect(mounted({ bashDefault: undefined }))
.rejects.toThrow(/does not confine/)
})
it('optionOf() presents shipped labels/descriptions, falls back to the raw key, and fixes custom', async () => {
const ctx = await mounted()
expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access, no approval prompts.' })
expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'A hand-set knob combination outside the preset table.' })
const bare = await mounted({ config: { presets: { plain: { sandbox: 'workspace-write', approval: 'ask' } } } })
expect(bare.permission.optionOf('plain')).toEqual({ value: 'plain', name: 'plain' })
expect(() => ctx.permission.optionOf('plan')).toThrow(/unknown preset/)
})
it('rejects a table entry named custom (reserved for the derived state)', async () => {
await expect(mounted({ config: { presets: { custom: { sandbox: 'read-only', approval: 'ask' } } } }))
.rejects.toThrow(/reserved for the derived not-a-preset state/)
})
it('reads a schema-less approval stand-in as the ask default', async () => {
const ctx = await mounted({ approvalDefault: undefined })
const session = freshSession('sess-standin')
ctx.permission.set(session, 'workspace-write')
expect(session.events).toHaveLength(0)
expect(ctx.permission.current(session.events)).toBe('workspace-write')
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/session"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../bash/bash"
},
{
"path": "../user-approval"
}
]
}

View File

@@ -99,12 +99,16 @@ export class PerplexitySearchProvider implements WebSearchProvider {
constructor(private readonly options: PerplexitySearchProviderOptions) {}
// Availability checks stay beside each provider's distinct config contract;
// a shared base class would obscure which fields make this backend usable.
/* jscpd:ignore-start */
status(): WebProviderStatus {
if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' }
return { available: true }
}
/* jscpd:ignore-end */
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
let response: Response
@@ -159,6 +163,9 @@ export class PerplexitySearchProvider implements WebSearchProvider {
}
}
// These two predicates are intentionally local: exporting generic internals
// from the public web seam would cost more API surface than these pure checks.
/* jscpd:ignore-start */
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
@@ -168,3 +175,4 @@ function isAbortError(error: unknown): boolean {
function isPositiveInteger(value: number): boolean {
return Number.isInteger(value) && value > 0
}
/* jscpd:ignore-end */

View File

@@ -32,7 +32,7 @@ Unknown options, malformed arguments, unsupported schemas, tripped caps, provide
## Run sequence
`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice.
`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. Source mode uses a data-URL bootstrap that installs the TypeScript transforms inside the worker; built mode passes the sibling CommonJS bundle `lib/worker.cjs` as a filesystem string. CommonJS is required because pkg's VFS Worker hook compiles filesystem-string entries in that format; the same entry also works under ordinary Node resolution. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice.
For each `agent()` call:

View File

@@ -13,14 +13,14 @@
},
"./worker": {
"types": "./lib/types/worker.d.ts",
"default": "./lib/worker.js"
"default": "./lib/worker.cjs"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/worker.js",
"lib/worker.cjs",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"

View File

@@ -43,6 +43,7 @@
import { Worker } from 'node:worker_threads'
import type { WorkerOptions } from 'node:worker_threads'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { assertNever } from '@deepseek-ai/dsh-llm'
@@ -87,12 +88,12 @@ interface ChildRecord {
* AMBIENT channel only — an escapee still holds process-wide privileges
* like fs access (the README's trust premise stands).
* @param init - the run payload, passed as `workerData`.
* @returns the entry URL and the Worker options to spawn it with.
* @returns the entry path or URL and the Worker options to spawn it with.
*/
function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOptions } {
function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: WorkerOptions } {
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */
if (!import.meta.url.endsWith('.ts')) {
return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init, env: {}, execArgv: [] } }
return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: {}, execArgv: [] } }
}
// Resolve tsx lazily: only the unbuilt shape executes this arm, so a built
// consumer never needs the dev-only loader installed. A JavaScript entry is

View File

@@ -8,17 +8,17 @@ import { describe, expect, it } from 'vitest'
const packageRoot = fileURLToPath(new URL('..', import.meta.url))
const builtIndex = join(packageRoot, 'lib', 'index.js')
const builtWorker = join(packageRoot, 'lib', 'worker.js')
const builtWorker = join(packageRoot, 'lib', 'worker.cjs')
const run = promisify(execFile)
/**
* The BUILT-output guard for the worker entry: every other suite runs
* unbuilt (src/ + tsx), so nothing else proves that `lib/index.js` resolves
* its sibling `lib/worker.js` and that the bundle boots a worker under plain
* its sibling `lib/worker.cjs` and that the bundle boots a worker under plain
* node (no tsx loader). Keyless — a zero-agent script needs no provider —
* and self-skips until `pnpm run build` has produced the bundles.
*/
describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built worker entry (lib/worker.js)', () => {
describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built worker entry (lib/worker.cjs)', () => {
it('the built engine spawns its built worker under plain node and completes a run', async () => {
// ESM resolves bare specifiers from the IMPORTING FILE's location, so the
// driver must live inside the package for its node_modules to apply — a

View File

@@ -6,7 +6,9 @@ import { defineConfig } from 'tsdown'
* entries are JS emitted by tsc under lib/types and are bundled as two
* single-entry passes so shared modules (realm, runtime, session) are inlined
* into each instead of split into a hash-named chunk (the worker entry must
* be a self-contained file the Worker constructor can load by path).
* be a self-contained file the Worker constructor can load by path). The
* worker bundle is CommonJS because pkg's VFS Worker hook compiles
* filesystem-string entries as CommonJS.
*/
export default defineConfig([
{
@@ -22,7 +24,7 @@ export default defineConfig([
{
entry: ['lib/types/worker.js'],
outDir: 'lib',
format: ['esm'],
format: ['cjs'],
platform: 'node',
target: 'es2024',
fixedExtension: false,