Merge remote-tracking branch 'origin/master' into codex/enforce-tool-cancellation

This commit is contained in:
Tianyi Cui
2026-07-19 22:37:35 +08:00
240 changed files with 3844 additions and 322 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-bash-local
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
@@ -14,7 +14,8 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
timeoutMs: 120000 # default foreground timeout
maxTimeoutMs: 600000 # cap for per-call overrides
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
graceMs: 3000 # SIGTERM→SIGKILL escalation grace on kills
maxSpillBytes: 67108864 # per-stream full-output spill cap
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
```
## Behavior (and where it came from)
@@ -22,8 +23,8 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file.
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
@@ -41,6 +42,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **Spill files are never deleted** — full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them.
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring.

View File

@@ -10,7 +10,7 @@ import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts'
/** Plugin config (all optional — `static Config` supplies the defaults). */
@@ -23,7 +23,9 @@ export interface Config {
maxTimeoutMs?: number
/** Per-stream in-memory output cap; overflow spills to a temp file. */
maxOutputBytes?: number
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes?: number
/** Grace period for kill escalation and for inherited pipes after shell exit. */
graceMs?: number
}
@@ -46,6 +48,7 @@ export class LocalBashExecutor extends BashExecutor {
timeoutMs: z.number().default(120_000),
maxTimeoutMs: z.number().default(600_000),
maxOutputBytes: z.number().default(64_000),
maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
graceMs: z.number().default(DEFAULT_GRACE_MS),
})
@@ -64,6 +67,7 @@ export class LocalBashExecutor extends BashExecutor {
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
ctx.effect(() => async () => {
// Await closure so even a TERM-trapping child cannot outlive the fiber.
@@ -120,6 +124,7 @@ export class LocalBashExecutor extends BashExecutor {
cwd: spec.workdir,
stdoutMaxBytes: spec.stdoutMaxBytes,
stderrMaxBytes: this.config.maxOutputBytes,
maxSpillBytes: this.config.maxSpillBytes,
graceMs: this.config.graceMs,
signal: d.signal,
stdin: spec.stdin,
@@ -139,6 +144,7 @@ export class LocalBashExecutor extends BashExecutor {
cwd: spec.workdir,
stdoutMaxBytes: this.config.maxOutputBytes,
stderrMaxBytes: this.config.maxOutputBytes,
maxSpillBytes: this.config.maxSpillBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
stdin: spec.stdin,

View File

@@ -8,7 +8,7 @@
import { type ChildProcessByStdio, spawn } from 'node:child_process'
import type { Readable, Writable } from 'node:stream'
import { randomBytes } from 'node:crypto'
import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
@@ -72,7 +72,9 @@ export interface SpawnSpec {
stdoutMaxBytes: number
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
stderrMaxBytes: number
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes: number
/** Grace period for kill escalation and for inherited pipes after shell exit. */
graceMs: number
/**
* Abort signal — kills the process group when it fires. The executor owns
@@ -119,6 +121,9 @@ export interface RunInternals {
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
export const DEFAULT_GRACE_MS = 3_000
/** Default per-stream spill cap (the `maxSpillBytes` config). */
export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
let spillCounter = 0
let defaultSpillDir: string | undefined
@@ -133,9 +138,9 @@ function privateSpillDir(): string {
}
/**
* Collects one stream with a bounded in-memory tail. The FULL stream is
* always recoverable: on first overflow a spill file is created and every
* chunk (including those already collected) is appended there.
* Collects one stream with a bounded in-memory tail. On first overflow a
* spill file is created and every chunk (including those already collected)
* is appended there while the full stream remains within `maxSpillBytes`.
*
* Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
* end of command output; the spill file covers the head.
@@ -146,11 +151,13 @@ export class OutputCollector {
private dropped = false
private spillFd: number | undefined
private spillFile: string | undefined
private spillDisabled = false
/** Total bytes ever pushed (not just retained). */
private total = 0
constructor(
private readonly maxBytes: number,
private readonly maxSpillBytes: number,
private readonly label: string,
private readonly spillDir: string,
) {}
@@ -166,7 +173,7 @@ export class OutputCollector {
push(chunk: Buffer): void {
this.total += chunk.length
const overflows = this.bytes + chunk.length > this.maxBytes
if (overflows || this.spillFd !== undefined) this.spillAll(chunk)
if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
this.chunks.push(chunk)
this.bytes += chunk.length
while (this.bytes > this.maxBytes && this.chunks.length > 1) {
@@ -188,6 +195,10 @@ export class OutputCollector {
/** Open the spill file lazily and append `chunk` (and any prior chunks once). */
private spillAll(chunk: Buffer): void {
if (this.total > this.maxSpillBytes) {
this.discardSpill()
return
}
if (this.spillFd === undefined) {
// Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
// existing path, symlink or not) + owner-only mode: defeats spill-path
@@ -202,6 +213,30 @@ export class OutputCollector {
writeSync(this.spillFd, chunk)
}
/** Stop spilling and remove the file once it can no longer hold the complete stream. */
private discardSpill(): void {
const fd = this.spillFd
const file = this.spillFile
this.spillFd = undefined
this.spillFile = undefined
this.spillDisabled = true
if (fd !== undefined) {
try {
closeSync(fd)
} catch {
// Retain the descriptor so finalize can retry the failed close.
this.spillFd = fd
}
}
if (file !== undefined) {
try {
unlinkSync(file)
} catch {
// A failed unlink leaves at most maxSpillBytes behind, never an unbounded file.
}
}
}
/**
* Incremental read in whole-stream byte coordinates: returns everything
* pushed since `fromByte`. When `fromByte` has already slid out of the
@@ -301,8 +336,8 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
: spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
const stdout = new OutputCollector(spec.stdoutMaxBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.stderrMaxBytes, 'stderr', spillDir)
const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir)
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
@@ -328,12 +363,13 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
}
const done = new Promise<SpawnOutcome>((resolve, reject) => {
child.on('error', (error) => {
// No meaningful close outcome follows a spawn failure.
cleanup()
reject(error)
})
child.on('close', (exitCode, signal) => {
let settled = false
let pipeDrainTimer: NodeJS.Timeout | undefined
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return
settled = true
child.stdout.destroy()
child.stderr.destroy()
cleanup()
resolve({
exitCode,
@@ -341,9 +377,20 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
stdout: stdout.finalize(),
stderr: stderr.finalize(),
})
}
child.on('error', (error) => {
// No meaningful close outcome follows a spawn failure.
settled = true
cleanup()
reject(error)
})
child.on('exit', (exitCode, signal) => {
pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs)
})
child.on('close', settle)
function cleanup(): void {
if (graceTimer !== undefined) clearTimeout(graceTimer)
if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer)
spec.signal?.removeEventListener('abort', onAbort)
}
})

View File

@@ -66,6 +66,7 @@ describe('LocalBashExecutor.run', () => {
await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
const { bash } = await setup()

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, readFileSync, statSync } from 'node:fs'
import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
@@ -6,7 +6,10 @@ import type { DshEnvironment } from '@deepseek-ai/dsh-bash'
import { killGroup, OutputCollector, runBash } from '../src/run.ts'
import type { RunningBash } from '../src/run.ts'
const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
failNextClose: { value: false },
failNextUnlink: { value: false },
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
return {
@@ -18,6 +21,13 @@ vi.mock('node:fs', async (importOriginal) => {
}
actual.closeSync(fd)
},
unlinkSync(path: Parameters<typeof actual.unlinkSync>[0]): void {
if (failNextUnlink.value) {
failNextUnlink.value = false
throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' })
}
actual.unlinkSync(path)
},
}
})
@@ -29,6 +39,7 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
cwd: process.cwd(),
stdoutMaxBytes: 64_000,
stderrMaxBytes: 64_000,
maxSpillBytes: 64 * 1024 * 1024,
graceMs: 3_000,
...overrides,
}
@@ -173,6 +184,22 @@ describe('runBash', () => {
const result = await running.done
expect(result.signal).toBe('SIGTERM')
})
it('bounds inherited-pipe draining after the shell exits', async () => {
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
const started = Date.now()
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
const descendant = await waitForPidFile(pidFile)
try {
const result = await running.done
expect(Date.now() - started).toBeLessThan(1_000)
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('shell-done\n')
} finally {
process.kill(descendant, 'SIGKILL')
await waitGone(descendant)
}
})
})
describe('stdin and extra env (set by in-process plugins)', () => {
@@ -282,7 +309,7 @@ describe('output truncation and spill', () => {
describe('OutputCollector', () => {
it('keeps the tail of a single oversized chunk', () => {
const collector = new OutputCollector(10, 'test', spillDir)
const collector = new OutputCollector(10, 100, 'test', spillDir)
collector.push(Buffer.from('0123456789abcdef'))
const out = collector.finalize()
expect(out.text).toBe('6789abcdef')
@@ -291,7 +318,7 @@ describe('OutputCollector', () => {
})
it('readFrom returns increments and flags lossy reads', () => {
const collector = new OutputCollector(10, 'test', spillDir)
const collector = new OutputCollector(10, 100, 'test', spillDir)
collector.push(Buffer.from('aaaaa'))
const first = collector.readFrom(0)
expect(first.text).toBe('aaaaa')
@@ -312,7 +339,7 @@ describe('OutputCollector', () => {
})
it('contains close failures and drops the spill path', () => {
const collector = new OutputCollector(4, 'closefail', spillDir)
const collector = new OutputCollector(4, 100, 'closefail', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
expect(collector.readFrom(0).spillPath).toBeDefined()
@@ -326,6 +353,46 @@ describe('OutputCollector', () => {
expect(out!.truncated).toBe(true)
expect(out!.spillPath).toBeUndefined()
})
it('discards a spill that exceeds its configured cap', () => {
const collector = new OutputCollector(4, 8, 'bounded', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
const spillPath = collector.readFrom(0).spillPath!
expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb')
collector.push(Buffer.from('c'))
collector.push(Buffer.from('dddd'))
const out = collector.finalize()
expect(out.text).toBe('dddd')
expect(out.truncated).toBe(true)
expect(out.spillPath).toBeUndefined()
expect(() => readFileSync(spillPath)).toThrow()
})
it('does not create a spill when the first overflowing chunk exceeds the cap', () => {
const collector = new OutputCollector(4, 4, 'no-spill', spillDir)
collector.push(Buffer.from('abcdefgh'))
const out = collector.finalize()
expect(out.text).toBe('efgh')
expect(out.truncated).toBe(true)
expect(out.spillPath).toBeUndefined()
})
it('contains cleanup failures while disabling an oversize spill', () => {
const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
const spillPath = collector.readFrom(0).spillPath!
failNextClose.value = true
failNextUnlink.value = true
expect(() => { collector.push(Buffer.from('c')) }).not.toThrow()
expect(failNextClose.value).toBe(false)
expect(failNextUnlink.value).toBe(false)
expect(collector.finalize().spillPath).toBeUndefined()
unlinkSync(spillPath)
})
})
describe('killGroup', () => {

View File

@@ -1,6 +1,6 @@
/**
* Exercises scheduler ordering and cancellation with deterministic gated tools.
* ACP goldens own transcript-facing coverage.
* ACP expected outputs own transcript-facing coverage.
*/
import { describe, expect, it } from 'vitest'

View File

@@ -53,7 +53,7 @@ export interface Config {
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-core. */
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
}
@@ -75,7 +75,7 @@ export const Config: z<Config> = z.object({
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
})
/* jscpd:ignore-end */

View File

@@ -46,7 +46,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
## Why a code bundle, not a shared YAML include
@@ -62,5 +62,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle.
- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit the bundled skills and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
- **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate.

View File

@@ -31,6 +31,8 @@ export const name = 'agent-spine-demo'
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
export interface SkillConfig {
/** Mount the bundled local skill provider and model-facing skill tool (default true). */
enabled?: boolean
/** Registry-level discovery cache settings. */
registry?: SkillRegistryConfig
/** Local filesystem skill provider settings. */
@@ -73,12 +75,13 @@ export interface Config {
skills?: SkillConfig
/** Model-facing bash tool config, including this producer's background opt-in. */
toolBash?: toolBash.Config
/** Generic background-task control-tool wait bounds. */
toolTasks?: toolTasks.Config
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
toolTasks?: toolTasks.Config | false
}
/** The skill config schema exported for app packages that forward `skills`. */
export const SkillConfigSchema: z<SkillConfig> = z.object({
enabled: z.boolean().default(true),
registry: SkillService.Config,
local: SkillLocal.Config,
tool: toolSkill.Config,
@@ -100,7 +103,7 @@ export const Config = z.intersect([
skills: SkillConfigSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
toolTasks: ToolTasksConfigSchema,
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks'>>,
]) as unknown as z<Config>
@@ -150,8 +153,11 @@ export function apply(ctx: Context, config: Config): void {
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
})
ctx.plugin(ToolRegistry, config.tools ?? {})
ctx.plugin(SkillService, config.skills?.registry ?? {})
ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome }))
const skillsEnabled = config.skills?.enabled ?? true
if (skillsEnabled) {
ctx.plugin(SkillService, config.skills?.registry ?? {})
ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome }))
}
ctx.plugin(AgentRegistry)
ctx.plugin(TaskService)
ctx.plugin(invariants)
@@ -161,8 +167,8 @@ export function apply(ctx: Context, config: Config): void {
}
// Both plugins prepend session-prefix messages. Registration order is the
// rendered order, so workspace instructions must precede the skill catalog.
ctx.plugin(toolSkill, config.skills?.tool ?? {})
ctx.plugin(toolTasks, config.toolTasks ?? {})
if (skillsEnabled) ctx.plugin(toolSkill, config.skills?.tool ?? {})
if (config.toolTasks !== false) ctx.plugin(toolTasks, config.toolTasks ?? {})
ctx.plugin(AgentLoop, {
agents: config.agents ?? [],
...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {},

View File

@@ -344,6 +344,21 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('can omit skills and model-facing task controls for a foreground-only deployment', async () => {
const ctx = await mount({
workspaceContext: false,
skills: { enabled: false },
toolBash: { enableRunInBackground: false },
toolTasks: false,
}, true)
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['bash'])
expect(ctx.get('skills')).toBeUndefined()
expect(ctx.get('tasks')).toBeDefined()
await ctx.fiber.dispose()
})
it('picks shared spine config without leaking front-door fields', () => {
const appConfig = {
model: 'front-door-only',
@@ -352,9 +367,9 @@ describe('dsh-agent-spine-demo bundle', () => {
tools: { mode: 'native' as const },
dshHome: '/tmp/dsh-home',
workspaceContext: false as const,
skills: {},
skills: { enabled: false },
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
toolTasks: false as const,
}
expect(agentCore.pickSpineConfig(appConfig)).toEqual({
@@ -363,7 +378,7 @@ describe('dsh-agent-spine-demo bundle', () => {
tools: appConfig.tools,
dshHome: appConfig.dshHome,
workspaceContext: false,
skills: {},
skills: appConfig.skills,
toolBash: appConfig.toolBash,
toolTasks: appConfig.toolTasks,
})

View File

@@ -61,7 +61,7 @@ export const Config: z<Config> = z.object({
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
/* jscpd:ignore-end */

View File

@@ -146,6 +146,21 @@ describe('dsh-cli-demo app composition', () => {
expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
})
it('accepts false to keep task services without model-facing task controls', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
skills: { enabled: false },
toolTasks: false,
workspaceContext: false,
})
expect(ctx.get('tasks')).toBeDefined()
expect(ctx.get('tools')?.get('task_output')).toBeUndefined()
expect(ctx.get('tools')?.get('task_list')).toBeUndefined()
expect(ctx.get('tools')?.get('task_kill')).toBeUndefined()
})
it('exposes the Loader-safe namespace plugin shape and schema', () => {
expect(cliDemo.name).toBe('cli-demo')
expect(cliDemo.Config).toBeDefined()

View File

@@ -97,7 +97,7 @@ export interface Config {
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-core. */
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/**
* If set, the pre-created agent RESUMES this persisted session id instead of
@@ -125,7 +125,7 @@ export const Config: z<Config> = z.object({
ui: UiConfigSchema,
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
resumeSessionId: z.string(),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})

View File

@@ -91,6 +91,9 @@ export class DeepSeekAdapter extends LlmAdapter {
'content-type': 'application/json',
'accept': 'text/event-stream',
...attributionHeaders(),
...options.sessionId !== undefined
? { 'x-deepseek-harness-session-id': String(options.sessionId) }
: {},
},
body: JSON.stringify(body),
...options.signal ? { signal: options.signal } : {},

View File

@@ -3,6 +3,7 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek'
import { httpErrorCode } from '../src/adapter.ts'
@@ -133,6 +134,19 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
})
it('forwards the harness session id for host-side trajectory routing', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url)
await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
sessionId: SessionId('child-session'),
})
expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session')
})
it('forwards thinking config onto the wire', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })

View File

@@ -168,6 +168,26 @@ describe('PiAiAdapter provider routing', () => {
expect(server.paths).toEqual(['/v1/responses'])
})
it('uses OpenAI Responses against an Azure project v1 path with its API key header', async () => {
const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: [{
provider: 'openai',
apiKey: 'test-key',
baseURL: `${server.url}/api/projects/openai/openai/v1`,
headers: { 'api-key': 'test-key', Authorization: '' },
maxRetries: 0,
}],
})
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] })
expect(result.finish.kind).toBe('error')
expect(server.paths).toEqual(['/api/projects/openai/openai/v1/responses'])
expect(server.headers[0]?.['api-key']).toBe('test-key')
expect(server.headers[0]?.authorization).toBe('')
})
it.each([
[401, 'AUTH'],
[400, 'INVALID_REQUEST'],

View File

@@ -0,0 +1,163 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import type { PiAiReplayState } from '../src/replay.ts'
import { assemble, type AssembledResult } from './assemble.ts'
interface ProviderCase {
provider: 'openai' | 'anthropic'
api: 'openai-responses' | 'anthropic-messages'
model: string
apiKey?: string
baseURL?: string
headers?: Record<string, string>
}
const openAIBaseURL = process.env.DSH_PI_AI_OPENAI_BASE_URL
const azureOpenAIKey = process.env.AZURE_OPENAI_API_KEY
const providerCases: ProviderCase[] = [
{
provider: 'openai',
api: 'openai-responses',
model: process.env.DSH_PI_AI_OPENAI_MODEL ?? 'gpt-5.5',
...azureOpenAIKey
? { apiKey: azureOpenAIKey, headers: { 'api-key': azureOpenAIKey, Authorization: '' } }
: {},
...openAIBaseURL ? { baseURL: openAIBaseURL } : {},
},
{
provider: 'anthropic',
api: 'anthropic-messages',
model: process.env.DSH_PI_AI_ANTHROPIC_MODEL ?? 'claude-opus-4-8',
...process.env.ANTHROPIC_API_KEY ? { apiKey: process.env.ANTHROPIC_API_KEY } : {},
},
]
const contexts: Context[] = []
async function harness(): Promise<Context> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: providerCases.map(profile => ({
provider: profile.provider,
...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey },
...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL },
...profile.headers === undefined ? {} : { headers: profile.headers },
})),
})
return ctx
}
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
function ask(text: string): Message[] {
return [{ role: 'user', content: [{ type: 'text', text }] }]
}
function textOf(result: AssembledResult): string {
return result.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
function expectFinish(result: AssembledResult, expected: 'stop' | 'tool-calls'): void {
if (result.finish.kind === 'error') {
throw new Error(`provider request failed (${result.finish.code ?? 'unknown'}): ${result.finish.message}`)
}
expect(result.finish.kind).toBe(expected)
}
function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayState {
const replayState = result.message.provenance?.replayState
expect(replayState).toMatchObject({
kind: 'pi-ai',
version: 1,
api: profile.api,
provider: profile.provider,
model: profile.model,
})
return replayState as PiAiReplayState
}
const lookupTool: ToolSchema = {
name: 'lookup_code',
description: 'Look up the word represented by a short code.',
parameters: {
type: 'object',
properties: { code: { type: 'string', description: 'The code to look up.' } },
required: ['code'],
},
}
for (const profile of providerCases) {
describe.skipIf(profile.apiKey === undefined)(
`llm-pi-ai ${profile.provider} e2e (${profile.api})`,
() => {
it('streams text with usage and native replay metadata', async () => {
const ctx = await harness()
const result = await assemble(ctx, {
provider: profile.provider,
model: profile.model,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 1024,
})
expectFinish(result, 'stop')
expect(textOf(result).toLowerCase()).toContain('pong')
expect(result.usage?.inputTokens).toBeGreaterThan(0)
expect(result.usage?.outputTokens).toBeGreaterThan(0)
expect(expectNativeReplay(result, profile).stopReason).toBe('stop')
})
it('round-trips a tool call with provider-native replay metadata', async () => {
const ctx = await harness()
const prompt = ask('Use lookup_code with code "blue". Do not answer without calling the tool.')
const first = await assemble(ctx, {
provider: profile.provider,
model: profile.model,
messages: prompt,
tools: [lookupTool],
maxTokens: 2048,
})
expectFinish(first, 'tool-calls')
const call = first.message.content.find(block => block.type === 'tool-call')
expect(call).toBeDefined()
expect(call!.name).toBe('lookup_code')
expect(JSON.parse(call!.arguments)).toMatchObject({ code: 'blue' })
expect(expectNativeReplay(first, profile).stopReason).toBe('toolUse')
const second = await assemble(ctx, {
provider: profile.provider,
model: profile.model,
messages: [
...prompt,
first.message,
{
role: 'user',
content: [{
type: 'tool-result',
toolCallId: CallId(call!.id),
content: [{ type: 'text', text: 'The code blue means ocean.' }],
}],
},
],
tools: [lookupTool],
maxTokens: 2048,
})
expectFinish(second, 'stop')
expect(textOf(second).toLowerCase()).toContain('ocean')
expect(expectNativeReplay(second, profile).stopReason).toBe('stop')
})
},
)
}

View File

@@ -6,13 +6,13 @@ The supported package surface is the `create-sdk` bin. The package root exports
The initializer rejects every existing target path, creates one `SdkProject` edit session, validates and commits it, then asks whether to install NPM dependencies and build. Install or build failures keep the generated project and print a retry command.
Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, and `--install`/`--no-install`. Flags prefill matching questions, but creation always requires a TTY.
Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, `--install`/`--no-install`, plus the headless flags `--config <path>` / `--config-json <json>` and `--json`. Interactive flags prefill matching questions; a headless spec (`--config`/`--config-json`) supplies every answer and its feature plan up front, so creation runs without a TTY and drives through a `HeadlessPromptPort` that fails loud on any missing required answer. `--json` emits NDJSON lifecycle events (`done` / `action-required` / `error`) so an agent can fill the named missing input and re-run.
The provider choice is DeepSeek or a custom endpoint backed by `llm-pi-ai`. DeepSeek asks only for an API key and uses the public endpoint plus `deepseek-v4-flash`; custom also asks for a base URL. An empty key requires confirmation and creates a commented empty `.env` variable so provider startup fails clearly until it is filled. Existing plugin defaults are omitted; required SDK presets remain typed against the owning package's Config.
## Model Experience
Indirectly, through the generated project composition and its selected runtime plugins.
Indirectly, through the generated project composition and its selected runtime plugins; the headless `--config-json` + `--json` surface additionally lets an agent create a project end to end and react to `action-required` events.
#### KV Cache effect
@@ -20,4 +20,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **TTY-only creation** — flags prefill questions, but the wizard still requires an interactive terminal before it writes a project.
- **Headless local plugins** — the headless spec supplies project answers and the feature plan; scaffolding a local plugin (the interactive none/plugin/tool choice) is not yet expressible in the spec and defaults to none.

View File

@@ -19,6 +19,9 @@ export interface CreateArgs {
packageManager?: PackageManagerName
install?: boolean
linkWorkspace?: boolean
config?: string
configJson?: string
json?: boolean
help: boolean
}
@@ -32,6 +35,9 @@ interface CommanderCreateOptions {
pm?: PackageManagerName
install?: boolean
linkWorkspace?: boolean
config?: string
configJson?: string
json?: boolean
help?: boolean
}
@@ -60,6 +66,9 @@ function createProgram(): Command {
.addOption(new Option('--install').default(undefined))
.addOption(new Option('--no-install').default(undefined))
.option('--link-workspace')
.option('--config <path>')
.option('--config-json <json>')
.addOption(new Option('--json').default(undefined))
}
/** Parse create-sdk positionals/options through Commander into a domain-neutral value. */
@@ -79,6 +88,9 @@ export function parseCreateArgs(argv: readonly string[]): CreateArgs {
...options.pm === undefined ? {} : { packageManager: options.pm },
...options.install === undefined ? {} : { install: options.install },
...options.linkWorkspace ? { linkWorkspace: true } : {},
...options.config === undefined ? {} : { config: options.config },
...options.configJson === undefined ? {} : { configJson: options.configJson },
...options.json === undefined ? {} : { json: options.json },
help: options.help ?? false,
}
}

View File

@@ -7,12 +7,16 @@
import { readFile } from 'node:fs/promises'
import {
ClackPromptPort,
HeadlessPromptError,
HeadlessPromptPort,
NodeCommandRunner,
PromptCancelledError,
type PackageManagerVersionProbe,
type PromptPort,
} from '@deepseek-ai/dsh-helper'
import { parseCreateArgs } from './args.ts'
import { parseCreateArgs, type CreateArgs } from './args.ts'
import { CreateWizard, type ResolvedCreateRequest } from './create-wizard.ts'
import { resolveHeadless } from './headless.ts'
import { scaffoldProject, type ScaffoldResult } from './project-scaffolder.ts'
import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts'
@@ -42,24 +46,29 @@ export async function createProject(
context: CreateCommandContext,
): Promise<ScaffoldResult | undefined> {
const args = parseCreateArgs(argv)
// Under --json, stdout carries only NDJSON events: human-readable progress
// and package-manager child output move to stderr.
const progress = args.json === true ? context.stderr : context.stdout
if (args.help) {
context.stdout.write(CREATE_TEMPLATES.usage.render({}))
return undefined
}
if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
throw new Error('create-sdk requires an interactive TTY')
const headless = await resolveHeadless(args)
if (!headless && !context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
throw new Error('create-sdk requires an interactive TTY, --config <file>, or --config-json <json>')
}
const wizard = new CreateWizard({
args,
args: headless ? headless.args : args,
/* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
port: context.port ?? new ClackPromptPort(context.stdin, context.stdout),
port: context.port ?? (headless ? new HeadlessPromptPort() : new ClackPromptPort(context.stdin, context.stdout)),
cwd: context.cwd,
releaseVersion: context.releaseVersion ?? await readCreateSdkVersion(),
...context.versionProbe ? { versionProbe: context.versionProbe } : {},
...headless?.features ? { features: headless.features } : {},
})
const resolved = await wizard.run()
const result = await scaffoldProject(resolved.directory, resolved.request)
context.stdout.write(CREATE_TEMPLATES.created.render({
progress.write(CREATE_TEMPLATES.created.render({
name: resolved.request.name,
directory: resolved.directory,
}))
@@ -67,8 +76,9 @@ export async function createProject(
try {
if (context.setup) await context.setup(resolved)
else {
await resolved.request.packageManager.install(resolved.directory)
await resolved.request.packageManager.build(resolved.directory)
const runner = args.json === true ? new NodeCommandRunner(context.stderr) : new NodeCommandRunner()
await resolved.request.packageManager.install(resolved.directory, runner)
await resolved.request.packageManager.build(resolved.directory, runner)
}
} catch (error) {
context.stderr.write(CREATE_TEMPLATES.setupFailure.render({
@@ -79,7 +89,7 @@ export async function createProject(
throw error
}
}
context.stdout.write(CREATE_TEMPLATES.nextSteps.render({
progress.write(CREATE_TEMPLATES.nextSteps.render({
directory: resolved.directory,
setupRequired: !resolved.install,
...packageManagerTemplateModel(resolved.request.packageManager),
@@ -87,6 +97,17 @@ export async function createProject(
return result
}
/** Whether NDJSON lifecycle events were requested, tolerating unparseable argv. */
function wantsJsonEvents(argv: readonly string[]): boolean {
let parsed: CreateArgs
try {
parsed = parseCreateArgs(argv)
} catch {
return false
}
return parsed.json === true
}
/** Run the create command with process defaults and convert cancellation to a clean exit. */
export async function runCreateCommand(
argv: readonly string[] = process.argv.slice(2),
@@ -97,15 +118,27 @@ export async function runCreateCommand(
stderr: process.stderr,
},
): Promise<number> {
const json = wantsJsonEvents(argv)
const emit = (event: Record<string, unknown>): void => {
context.stdout.write(`${JSON.stringify(event)}\n`)
}
try {
await createProject(argv, context)
if (json) emit({ type: 'done' })
return 0
} catch (error) {
if (error instanceof PromptCancelledError) {
context.stderr.write('create-sdk: cancelled\n')
if (json) emit({ type: 'error', reason: 'cancelled' })
else context.stderr.write('create-sdk: cancelled\n')
return 1
}
context.stderr.write(`create-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
if (json && error instanceof HeadlessPromptError) {
emit({ type: 'action-required', prompt: error.prompt })
return 1
}
const message = error instanceof Error ? error.message : String(error)
if (json) emit({ type: 'error', message })
else context.stderr.write(`create-sdk: ${message}\n`)
return 1
}
}

View File

@@ -48,6 +48,7 @@ export class CreateWizard {
private readonly versionProbe: PackageManagerVersionProbe
private readonly userAgent: string
private readonly linkWorkspaceRoot: string | undefined
private readonly featurePlan: readonly FeatureSelection[] | undefined
/** Bind parsed args and infrastructure to one wizard run. */
constructor(options: {
@@ -57,6 +58,7 @@ export class CreateWizard {
releaseVersion: string
versionProbe?: PackageManagerVersionProbe
userAgent?: string
features?: readonly FeatureSelection[]
}) {
this.args = options.args
this.port = options.port
@@ -68,6 +70,7 @@ export class CreateWizard {
this.linkWorkspaceRoot = options.args.linkWorkspace
? fileURLToPath(new URL('../../../../', import.meta.url))
: undefined
this.featurePlan = options.features
}
/** Collect all answers before constructing any project files. */
@@ -129,39 +132,43 @@ export class CreateWizard {
const configurable = registry.all().filter(feature => feature.id === 'bash'
|| feature.id === 'persistence'
|| (!feature.required && feature.isApplicable(profile)))
const selected = [...requireAnswer(await this.port.nestedMultiselect({
message: 'Select features',
options: configurable.map((feature) => {
const nested = feature.mode !== 'single'
const defaults = new Set(feature.defaultOptions(profile))
return {
value: feature.id,
label: feature.summary,
required: feature.required,
default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
|| feature.id === 'skill',
...nested ? {
choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
choices: feature.options.map(option => ({
value: option.id,
label: option.label,
default: defaults.has(option.id),
})),
} : {},
const selected = this.featurePlan
? this.featurePlan.map(feature => ({ value: feature.id, choices: feature.options }))
: [...requireAnswer(await this.port.nestedMultiselect({
message: 'Select features',
options: configurable.map((feature) => {
const nested = feature.mode !== 'single'
const defaults = new Set(feature.defaultOptions(profile))
return {
value: feature.id,
label: feature.summary,
required: feature.required,
default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
|| feature.id === 'skill',
...nested ? {
choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
choices: feature.options.map(option => ({
value: option.id,
label: option.label,
default: defaults.has(option.id),
})),
} : {},
}
}),
}))]
if (!this.featurePlan) {
for (const { value: id } of [...selected]) {
const feature = registry.get(id)
for (const suggestedId of feature.suggests) {
if (selected.some(item => item.value === suggestedId)) continue
const suggested = registry.get(suggestedId)
const add = requireAnswer(await new ConfirmQuestion({
id: `${feature.id}.${suggested.id}`,
message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
initialValue: true,
}).resolve(this.port))
if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
}
}),
}))]
for (const { value: id } of [...selected]) {
const feature = registry.get(id)
for (const suggestedId of feature.suggests) {
if (selected.some(item => item.value === suggestedId)) continue
const suggested = registry.get(suggestedId)
const add = requireAnswer(await new ConfirmQuestion({
id: `${feature.id}.${suggested.id}`,
message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
initialValue: true,
}).resolve(this.port))
if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
}
}
const fixed = new Set(selections.map(selection => selection.id))
@@ -174,12 +181,16 @@ export class CreateWizard {
for (const choice of selected) {
choices.set(choice.value, choice.choices.length > 0 ? choice.choices : undefined)
}
const plannedById = new Map((this.featurePlan ?? []).map(feature => [feature.id, feature]))
for (const [id, options] of choices) {
const planned = plannedById.get(id)
selections.push(await configurator.configure(
registry.get(id),
profile,
undefined,
options,
planned?.secrets ?? {},
planned?.values ?? {},
))
}
return selections

View File

@@ -0,0 +1,98 @@
/**
* Headless create input: a structured project spec supplied by an agent or CI
* instead of interactive prompts.
*
* @module @deepseek-ai/create-sdk/headless
*/
import { readFile } from 'node:fs/promises'
import type { FeatureSelection, PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper'
import type { CreateArgs } from './args.ts'
/**
* Structured, non-interactive create input. Scalar fields mirror {@link CreateArgs}
* project answers; `features` is the headless feature plan handed to `CreateWizard`
* (the interactive tree/suggests prompts are skipped). Absent required answers make
* the run fail loud through `HeadlessPromptPort` rather than blocking.
*/
interface HeadlessCreateSpec {
directory?: string
description?: string
provider?: 'deepseek' | 'custom'
baseURL?: string
apiKey?: string
model?: string
interface?: RunInterface
pm?: PackageManagerName
install?: boolean
linkWorkspace?: boolean
features?: readonly FeatureSelection[]
}
/** Resolved headless input: the args the wizard reads plus the feature plan. */
export interface ResolvedHeadless {
args: CreateArgs
features: readonly FeatureSelection[] | undefined
}
function asRecord(value: unknown, source: string): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${source}: expected a JSON object`)
}
return value as Record<string, unknown>
}
/** Parse and shallow-validate a headless spec from JSON text. */
function parseHeadlessSpec(text: string, source: string): HeadlessCreateSpec {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch (error) {
/* v8 ignore next -- JSON.parse only throws Error instances; the String() branch is defensive */
throw new Error(`${source}: invalid JSON (${error instanceof Error ? error.message : String(error)})`)
}
const record = asRecord(parsed, source)
if (record.features !== undefined && !Array.isArray(record.features)) {
throw new Error(`${source}: "features" must be an array`)
}
return record
}
/**
* Load a headless spec from `--config-json` (inline) or `--config` (a JSON file),
* returning `undefined` when neither is supplied.
* @param args - parsed create args.
* @param readFileText - file reader seam for tests.
* @returns the resolved args + feature plan, or `undefined` for interactive runs.
*/
export async function resolveHeadless(
args: CreateArgs,
readFileText: (path: string) => Promise<string> = path => readFile(path, 'utf8'),
): Promise<ResolvedHeadless | undefined> {
let text: string
let source: string
if (args.configJson !== undefined) {
text = args.configJson
source = '--config-json'
} else if (args.config !== undefined) {
source = args.config
text = await readFileText(args.config)
} else {
return undefined
}
const spec = parseHeadlessSpec(text, source)
const resolvedArgs: CreateArgs = {
...spec.directory === undefined ? {} : { directory: spec.directory },
...spec.description === undefined ? {} : { description: spec.description },
...spec.provider === undefined ? {} : { provider: spec.provider },
...spec.baseURL === undefined ? {} : { baseURL: spec.baseURL },
...spec.apiKey === undefined ? {} : { apiKey: spec.apiKey },
...spec.model === undefined ? {} : { model: spec.model },
...spec.interface === undefined ? {} : { runInterface: spec.interface },
...spec.pm === undefined ? {} : { packageManager: spec.pm },
...spec.install === undefined ? {} : { install: spec.install },
...spec.linkWorkspace ? { linkWorkspace: true } : {},
help: false,
}
return { args: resolvedArgs, features: spec.features }
}

View File

@@ -9,3 +9,6 @@ Options:
--interface <acp|stdio|embed>
--pm <npm|pnpm|yarn>
--install / --no-install
--config <path>
--config-json <json>
--json

View File

@@ -5,9 +5,12 @@ import { PassThrough, Writable } from 'node:stream'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
HeadlessPromptPort,
LocalPluginBlueprint,
featureId,
NodeCommandRunner,
NpmPackageManager,
type FeatureSelection,
type NestedMultiSelectValue,
type PromptPort,
} from '@deepseek-ai/dsh-helper'
@@ -28,6 +31,7 @@ import {
type CreateCommandContext,
} from '../src/command.ts'
import { CreateWizard } from '../src/create-wizard.ts'
import { resolveHeadless } from '../src/headless.ts'
import { scaffoldProject } from '../src/project-scaffolder.ts'
class ScriptedPort implements PromptPort {
@@ -233,6 +237,54 @@ describe('CreateWizard and scaffolder', () => {
expect(resolved.request.features.find(item => item.id === 'hmr')).toMatchObject({ options: ['default'] })
})
it('runs headlessly from a feature plan without reaching the terminal', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-headless-'))
temporary.push(cwd)
const features: FeatureSelection[] = [
{ id: featureId('persistence'), options: ['sqlite'], values: { region: 'us' } },
{ id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } },
]
const resolved = await new CreateWizard({
args: parseCreateArgs([
'my-agent', '--description=demo', '--provider=deepseek', '--api-key=deepseek-key',
'--model=deepseek-v4-flash', '--interface=stdio', '--pm=npm', '--no-install',
]),
port: new HeadlessPromptPort(),
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
features,
}).run()
expect(resolved.install).toBe(false)
expect(resolved.request.localPlugins).toEqual([])
expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
options: ['exa'], secrets: { apiKey: 'exa-key' },
})
expect(resolved.request.features.find(item => item.id === 'persistence')).toMatchObject({ options: ['sqlite'] })
expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
secrets: { apiKey: 'deepseek-key' },
})
})
it('rejects a non-string feature value in a headless plan', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-headless-bad-'))
temporary.push(cwd)
const features = [
{ id: featureId('persistence'), options: ['sqlite'], values: { bad: 1 } },
] as unknown as FeatureSelection[]
await expect(new CreateWizard({
args: parseCreateArgs([
'my-agent', '--description=demo', '--provider=deepseek', '--api-key=k',
'--model=m', '--interface=stdio', '--pm=npm', '--no-install',
]),
port: new HeadlessPromptPort(),
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
features,
}).run()).rejects.toThrow('must be a string')
})
it('writes the project once and refuses every existing target', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-scaffold-'))
temporary.push(root)
@@ -427,12 +479,65 @@ describe('create command composition', () => {
context.stdout.isTTY = false
await expect(createProject(['--help'], context)).resolves.toBeUndefined()
expect(context.readStdout()).toContain('Usage: create-sdk')
expect(context.readStdout()).toContain('--config-json <json>')
expect(context.readStdout()).not.toContain('--link-workspace')
await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
context.stdin.isTTY = true
await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
})
it('creates headlessly from --config-json with no TTY', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-headless-cmd-'))
temporary.push(root)
const spec = JSON.stringify({
directory: 'agent', description: 'test', provider: 'deepseek', apiKey: 'key',
model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
features: [{ id: 'persistence', options: ['jsonl'] }],
})
const context = commandContext(root)
context.stdin.isTTY = false
context.stdout.isTTY = false
const result = await createProject(['--config-json', spec], context)
expect(result?.project.root).toBe(join(root, 'agent'))
})
it('emits NDJSON lifecycle events under --json', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-headless-json-'))
temporary.push(root)
const base = {
description: 'test', model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
}
const ok = commandContext(root)
ok.stdin.isTTY = false
ok.stdout.isTTY = false
const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek', apiKey: 'key', features: [] })
await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0)
expect(ok.readStdout()).toContain('{"type":"done"}')
// stdout stays pure NDJSON: every line parses, human progress goes to stderr
for (const line of ok.readStdout().split('\n').filter(line => line.length > 0)) {
expect(() => { JSON.parse(line) }).not.toThrow()
}
expect(ok.readStderr()).toContain('Created done-agent')
expect(ok.readStderr()).toContain('Next: cd')
const missing = commandContext(root)
missing.stdin.isTTY = false
missing.stdout.isTTY = false
const missingSpec = JSON.stringify({ ...base, directory: 'miss-agent', provider: 'custom', baseURL: 'https://x', features: [] })
await expect(runCreateCommand(['--config-json', missingSpec, '--json'], missing)).resolves.toBe(1)
expect(missing.readStdout()).toContain('"type":"action-required"')
const broken = commandContext(root)
broken.stdin.isTTY = false
broken.stdout.isTTY = false
await expect(runCreateCommand(['--config-json', '{bad', '--json'], broken)).resolves.toBe(1)
expect(broken.readStdout()).toContain('"type":"error"')
const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
await expect(runCreateCommand(['--json', ...argv('cancel-agent', false)], cancelled)).resolves.toBe(1)
expect(cancelled.readStdout()).toContain('"reason":"cancelled"')
})
it('creates through an injected prompt port and delegates optional setup', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-command-success-'))
temporary.push(root)
@@ -467,6 +572,17 @@ describe('create command composition', () => {
await createProject(argv('agent', true), context)
expect(install).toHaveBeenCalledOnce()
expect(build).toHaveBeenCalledOnce()
const spec = JSON.stringify({
directory: 'json-agent', description: 'test', provider: 'deepseek', apiKey: 'key',
model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: true, features: [],
})
const json = commandContext(root)
json.stdin.isTTY = false
json.stdout.isTTY = false
await createProject(['--config-json', spec, '--json'], json)
// json mode hands install/build a runner that redirects child output to stderr
expect(install).toHaveBeenCalledTimes(2)
expect(install.mock.calls[1]?.[1]).toBeInstanceOf(NodeCommandRunner)
install.mockRestore()
build.mockRestore()
})
@@ -501,3 +617,58 @@ describe('create command composition', () => {
await expect(runCreateCommand(['--help'], help)).resolves.toBe(0)
})
})
describe('resolveHeadless', () => {
it('returns undefined without a config source', async () => {
expect(await resolveHeadless(parseCreateArgs(['agent']))).toBeUndefined()
})
it('maps every inline --config-json field into args plus the feature plan', async () => {
const spec = JSON.stringify({
directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
model: 'm', interface: 'acp', pm: 'pnpm', install: true, linkWorkspace: true,
features: [{ id: 'todo', options: ['default'] }],
})
const resolved = await resolveHeadless(parseCreateArgs(['--config-json', spec]))
expect(resolved?.args).toMatchObject({
directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
model: 'm', runInterface: 'acp', packageManager: 'pnpm', install: true, linkWorkspace: true, help: false,
})
expect(resolved?.features).toEqual([{ id: 'todo', options: ['default'] }])
})
it('reads --config from a file via the injected reader and omits absent fields', async () => {
const resolved = await resolveHeadless(
parseCreateArgs(['--config', '/spec.json']),
async () => JSON.stringify({ description: 'from-file' }),
)
expect(resolved?.args.description).toBe('from-file')
expect(resolved?.args.directory).toBeUndefined()
expect(resolved?.args.linkWorkspace).toBeUndefined()
expect(resolved?.features).toBeUndefined()
})
it('reads --config from disk with the default reader', async () => {
const dir = await mkdtemp(join(tmpdir(), 'create-headless-file-'))
temporary.push(dir)
const file = join(dir, 'spec.json')
await writeFile(file, JSON.stringify({ description: 'on-disk' }))
const resolved = await resolveHeadless(parseCreateArgs(['--config', file]))
expect(resolved?.args.description).toBe('on-disk')
})
it('fails loud on invalid JSON, a non-object root, or a non-array features field', async () => {
await expect(resolveHeadless(parseCreateArgs(['--config-json', '{bad']))).rejects.toThrow('invalid JSON')
await expect(resolveHeadless(parseCreateArgs(['--config-json', '[]']))).rejects.toThrow('expected a JSON object')
await expect(resolveHeadless(parseCreateArgs(['--config-json', 'null']))).rejects.toThrow('expected a JSON object')
await expect(resolveHeadless(parseCreateArgs(['--config-json', '5']))).rejects.toThrow('expected a JSON object')
await expect(resolveHeadless(parseCreateArgs(['--config-json', '{"features":1}']))).rejects.toThrow('must be an array')
})
it('accepts a minimal spec, leaving unspecified answers undefined', async () => {
const resolved = await resolveHeadless(parseCreateArgs(['--config-json', '{"directory":"x"}']))
expect(resolved?.args.directory).toBe('x')
expect(resolved?.args.description).toBeUndefined()
expect(resolved?.features).toBeUndefined()
})
})

View File

@@ -26,6 +26,7 @@ export class FeatureConfigurator {
* @param current - currently installed selection, when configuring.
* @param prefilledOptions - options already chosen by a tree picker.
* @param prefilledSecrets - non-interactive secret values supplied by creation.
* @param prefilledValues - non-interactive value inputs supplied by a headless spec.
* @returns normalized selection with captured values and secrets.
*/
async configure(
@@ -34,6 +35,7 @@ export class FeatureConfigurator {
current?: FeatureSelection,
prefilledOptions?: readonly string[],
prefilledSecrets: Readonly<Record<string, string>> = {},
prefilledValues: Readonly<Record<string, unknown>> = {},
): Promise<FeatureSelection> {
let options: readonly string[]
switch (feature.mode) {
@@ -69,6 +71,11 @@ export class FeatureConfigurator {
id: feature.id,
options,
}
const coercedPrefilled: Record<string, string> = {}
for (const [key, value] of Object.entries(prefilledValues)) {
if (typeof value !== 'string') throw new Error(`${feature.id}.${key} value must be a string`)
coercedPrefilled[key] = value
}
const values: Record<string, string> = {}
for (const input of feature.valueInputs(selected, profile)) {
const existing = current?.values?.[input.id]
@@ -81,7 +88,7 @@ export class FeatureConfigurator {
...existing === undefined ? {} : { initialValue: existing },
validate: value => value.trim().length === 0 ? 'A value is required' : undefined,
})
values[input.id] = requireAnswer(await question.resolve(this.port))
values[input.id] = requireAnswer(await question.resolve(this.port, coercedPrefilled[input.id]))
}
const base: FeatureSelection = Object.keys(values).length === 0
? selected

View File

@@ -43,3 +43,4 @@ export {
} from './questions/question.ts'
export type { Question } from './questions/question.ts'
export { ClackPromptPort } from './questions/clack-prompt-port.ts'
export { HeadlessPromptError, HeadlessPromptPort } from './questions/headless-prompt-port.ts'

View File

@@ -58,17 +58,38 @@ export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env):
/** Node child-process command runner with inherited stdio and quiescent completion. */
export class NodeCommandRunner implements CommandRunner {
/** Spawn one child and settle only after its exit. */
private readonly output: NodeJS.WritableStream | undefined
/**
* @param output - redirect target for child stdout+stderr; the child inherits
* this process's stdio when absent. Callers whose own stdout carries a machine
* protocol (create-sdk --json NDJSON) redirect child output to keep the
* protocol stream pure.
*/
constructor(output?: NodeJS.WritableStream) {
this.output = output
}
/** Spawn one child and settle only after exit, with redirected stdio drained. */
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const output = this.output
if (output === undefined) {
const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), stdio: 'inherit', shell: false })
child.once('error', reject)
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
return
}
const child = spawn(command, [...args], {
cwd,
env: scrubEnvironment(),
stdio: 'inherit',
stdio: ['inherit', 'pipe', 'pipe'],
shell: false,
})
child.stdout.pipe(output, { end: false })
child.stderr.pipe(output, { end: false })
child.once('error', reject)
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) })
})
}
}
@@ -148,6 +169,25 @@ export abstract class PackageManager {
await this.runChecked(runner, this.buildCommand(), cwd, 'build')
}
/**
* Build add-dependency command arguments for one already-normalized source spec.
* @param spec - a package-manager-native dependency source (`pkg@version` or `github:owner/repo#ref`).
* @returns arguments following the manager executable.
*/
addCommand(spec: string): readonly string[] {
return ['add', spec]
}
/**
* Add one dependency from a native source spec and fail on non-zero or signalled exit.
* @param spec - a package-manager-native dependency source.
* @param cwd - project directory.
* @param runner - optional subprocess boundary.
*/
async add(spec: string, cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
await this.runChecked(runner, this.addCommand(spec), cwd, 'add')
}
private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise<void> {
const result = await runner.run(this.name, args, cwd)
if (result.signal !== null) {
@@ -184,6 +224,11 @@ export class NpmPackageManager extends PackageManager {
override linkSpec(relativePath: string): string {
return `file:${relativePath}`
}
/** npm adds a dependency through `install <spec>` rather than an `add` verb. */
override addCommand(spec: string): readonly string[] {
return ['install', spec]
}
}
/** pnpm workspace behavior. */

View File

@@ -220,6 +220,23 @@ export class ProjectEditSession implements FeatureProjectView {
this.addedPlugins.add(entry.id)
}
/**
* Mount a Cordis entry for an external dependency the package manager has already
* added (github or npm), without generating files or re-adding the dependency.
* @param id - stable Cordis config entry id.
* @param packageName - the installed dependency's package name.
*/
addExternalPlugin(id: string, packageName: string): void {
this.assertOpen()
if (!this.manifest().npmDependency(packageName)) {
throw new Error(`external plugin dependency is not installed: ${packageName}`)
}
const cordis = this.cordis()
if (cordis.entry(id)) throw new Error(`Cordis config entry already exists: ${id}`)
cordis.addEntry({ id, name: packageName })
this.addedPlugins.add(id)
}
/** Enable or disable one custom/manual Cordis config entry by stable id. */
setCustomPluginDisabled(id: string, disabled: boolean): void {
this.assertOpen()

View File

@@ -0,0 +1,97 @@
/**
* Non-interactive prompt port for headless create/config and skill-driven runs.
*
* @module @deepseek-ai/dsh-helper/questions/headless-prompt-port
*/
import type {
ConfirmPromptRequest,
MultiSelectPromptRequest,
NestedMultiSelectRequest,
NestedMultiSelectValue,
PromptOutcome,
PromptPort,
SecretPromptRequest,
SelectPromptRequest,
TextPromptRequest,
} from './prompt-port.ts'
/**
* Raised when a headless run reaches a decision that was neither prefilled nor
* carries a usable default. The message names the unanswered prompt so an agent
* or CI caller can see exactly which input the spec must supply.
*/
export class HeadlessPromptError extends Error {
/** The unanswered prompt's user-facing message. */
readonly prompt: string
/** Build an error naming the unanswered prompt. */
constructor(prompt: string) {
super(`headless run needs an answer for: ${prompt}`)
this.name = 'HeadlessPromptError'
this.prompt = prompt
}
}
/** Resolve an answered outcome. */
function answered<T>(value: T): Promise<PromptOutcome<T>> {
return Promise.resolve({ status: 'answered', value })
}
/** Reject with a named unanswered-prompt error. */
function unanswered<T>(message: string): Promise<PromptOutcome<T>> {
return Promise.reject(new HeadlessPromptError(message))
}
/**
* A {@link PromptPort} that never blocks on a terminal.
*
* Answers are expected to arrive as prefilled values through the `Question` /
* `FeatureConfigurator` layers, so in a fully specified run this port is never
* reached. When it *is* reached, it takes the prompt's own declared default
* (`defaultValue` / `initialValue`) if one exists; otherwise it fails loud with
* {@link HeadlessPromptError}. Nested feature selection has no scalar default,
* so it always fails loud — headless callers must supply the feature set through
* the spec rather than the tree picker.
*/
export class HeadlessPromptPort implements PromptPort {
/** Answer visible text from its default, or fail loud. */
text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
const fallback = request.initialValue ?? request.defaultValue
if (fallback === undefined) return unanswered(request.message)
const diagnostic = request.validate?.(fallback)
if (diagnostic) return unanswered(`${request.message} (${diagnostic})`)
return answered(fallback)
}
/** A secret has no safe default: always fail loud. */
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
return unanswered(request.message)
}
/** Answer a single choice from its initial value, or fail loud. */
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
if (request.initialValue === undefined) return unanswered(request.message)
return answered(request.initialValue)
}
/** Answer a multi-choice from its initial values, or fail loud when required. */
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
const initial = request.initialValues ?? []
if (request.required && initial.length === 0) return unanswered(request.message)
return answered(initial)
}
/** Answer a confirmation from its initial value, or fail loud. */
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
if (request.initialValue === undefined) return unanswered(request.message)
return answered(request.initialValue)
}
/** Nested feature selection has no scalar default: always fail loud. */
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
return unanswered(request.message)
}
}

View File

@@ -1,6 +1,7 @@
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Writable } from 'node:stream'
import { afterEach, describe, expect, it } from 'vitest'
import { CordisYamlFile, JsExpression } from '../src/documents/cordis-yaml-file.ts'
import { EnvFile } from '../src/documents/env-file.ts'
@@ -299,6 +300,11 @@ describe('package manager strategies', () => {
await npm.install('/tmp', runner)
await npm.build('/tmp', runner)
expect(calls).toEqual([['npm', 'install'], ['npm', 'run', 'build']])
await npm.add('some-pkg@1.0.0', '/tmp', runner)
const pnpm = createPackageManager('pnpm', '10.0.0')
await pnpm.add('github:o/r#sha', '/tmp', runner)
expect(calls).toContainEqual(['npm', 'install', 'some-pkg@1.0.0'])
expect(calls).toContainEqual(['pnpm', 'add', 'github:o/r#sha'])
const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) }
await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2')
const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) }
@@ -321,6 +327,19 @@ describe('package manager strategies', () => {
const runner = new NodeCommandRunner()
await expect(runner.run(process.execPath, ['-e', ''], root)).resolves.toEqual({ exitCode: 0, signal: null })
await expect(runner.run('missing-dsh-command', [], root)).rejects.toThrow()
let redirected = ''
const output = new Writable({
write(chunk, _encoding, callback) { redirected += String(chunk); callback() },
})
const redirecting = new NodeCommandRunner(output)
await expect(redirecting.run(
process.execPath,
['-e', 'process.stdout.write("child-out"); process.stderr.write("child-err")'],
root,
)).resolves.toEqual({ exitCode: 0, signal: null })
expect(redirected).toContain('child-out')
expect(redirected).toContain('child-err')
await expect(redirecting.run('missing-dsh-command', [], root)).rejects.toThrow()
})
it('discovers and rewrites a repository-local NPM dependency closure', async () => {

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { HeadlessPromptError, HeadlessPromptPort } from '../src/questions/headless-prompt-port.ts'
/** Unwrap an answered outcome or fail the test. */
async function answered<T>(promise: Promise<{ status: 'answered'; value: T } | { status: 'cancelled' }>): Promise<T> {
const outcome = await promise
if (outcome.status !== 'answered') throw new Error('expected an answered outcome')
return outcome.value
}
describe('HeadlessPromptError', () => {
it('names the unanswered prompt', () => {
const error = new HeadlessPromptError('DeepSeek API key')
expect(error).toBeInstanceOf(Error)
expect(error.name).toBe('HeadlessPromptError')
expect(error.prompt).toBe('DeepSeek API key')
expect(error.message).toContain('DeepSeek API key')
})
})
describe('HeadlessPromptPort', () => {
const port = new HeadlessPromptPort()
describe('text', () => {
it('takes the initial value when present', async () => {
expect(await answered(port.text({ message: 'name', initialValue: 'agent' }))).toBe('agent')
})
it('falls back to the default value', async () => {
expect(await answered(port.text({ message: 'dir', defaultValue: 'my-agent' }))).toBe('my-agent')
})
it('prefers the initial value over the default value', async () => {
expect(await answered(port.text({ message: 'dir', initialValue: 'given', defaultValue: 'my-agent' }))).toBe('given')
})
it('fails loud when no default exists', async () => {
await expect(port.text({ message: 'base URL' })).rejects.toThrow(HeadlessPromptError)
})
it('fails loud when the default is invalid', async () => {
await expect(port.text({
message: 'name',
defaultValue: '',
validate: value => value.length === 0 ? 'required' : undefined,
})).rejects.toThrow(/required/)
})
})
describe('secret', () => {
it('always fails loud', async () => {
await expect(port.secret({ message: 'API key' })).rejects.toThrow(HeadlessPromptError)
})
})
describe('select', () => {
it('takes the initial value when present', async () => {
expect(await answered(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }], initialValue: 'npm' }))).toBe('npm')
})
it('fails loud without an initial value', async () => {
await expect(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }] })).rejects.toThrow(HeadlessPromptError)
})
})
describe('multiselect', () => {
it('returns the initial values', async () => {
expect(await answered(port.multiselect({ message: 'x', options: [], initialValues: ['a', 'b'] }))).toEqual(['a', 'b'])
})
it('returns an empty selection when none are supplied and none are required', async () => {
expect(await answered(port.multiselect({ message: 'x', options: [] }))).toEqual([])
})
it('fails loud when required and nothing is preselected', async () => {
await expect(port.multiselect({ message: 'x', options: [], required: true })).rejects.toThrow(HeadlessPromptError)
})
})
describe('confirm', () => {
it('takes the initial value when present', async () => {
expect(await answered(port.confirm({ message: 'install?', initialValue: false }))).toBe(false)
})
it('fails loud without an initial value', async () => {
await expect(port.confirm({ message: 'apply?' })).rejects.toThrow(HeadlessPromptError)
})
})
describe('nestedMultiselect', () => {
it('always fails loud', async () => {
await expect(port.nestedMultiselect({ message: 'Select features', options: [] })).rejects.toThrow(HeadlessPromptError)
})
})
})

View File

@@ -703,6 +703,28 @@ describe('SdkProject and ProjectEditSession', () => {
expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/)
expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent')
})
it('mounts an external plugin dependency and rejects missing deps or duplicate entries', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-external-plugin-'))
temporary.push(root)
const creation = request()
const project = SdkProject.create(root, creation)
const registry = createBuiltinRegistry(project.profile)
const edit = project.edit(registry)
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
await edit.commit()
const manifestPath = join(root, 'package.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { dependencies?: Record<string, string> }
manifest.dependencies = { ...manifest.dependencies, 'ext-plugin': 'github:o/r#sha' }
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
const reopened = await SdkProject.open(root)
const edit2 = reopened.edit(createBuiltinRegistry(reopened.profile))
edit2.addExternalPlugin('ext-plugin', 'ext-plugin')
expect(() => { edit2.addExternalPlugin('ext-plugin', 'ext-plugin') }).toThrow('already exists')
expect(() => { edit2.addExternalPlugin('missing', 'not-a-dep') }).toThrow('not installed')
const commit = await edit2.commit()
expect(commit.project.cordis.entry('ext-plugin')?.name).toBe('ext-plugin')
})
})
describe('extension points', () => {

View File

@@ -450,4 +450,35 @@ describe('feature configurator', () => {
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile))
.rejects.toThrow('has no default option')
})
it('configures fully from prefilled options, values, and secrets without prompting', async () => {
const registry = createBuiltinRegistry(profile)
const port = new QueuePromptPort([])
const result = await new FeatureConfigurator(port).configure(
registry.get(featureId('provider')),
profile,
undefined,
['custom'],
{ apiKey: 'prefilled-key' },
{ baseURL: 'https://prefilled' },
)
expect(result).toMatchObject({
options: ['custom'],
values: { baseURL: 'https://prefilled' },
secrets: { apiKey: 'prefilled-key' },
})
expect(port.requests).toEqual([])
})
it('rejects a non-string prefilled feature value', async () => {
const registry = createBuiltinRegistry(profile)
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(
registry.get(featureId('provider')),
profile,
undefined,
['custom'],
{ apiKey: 'k' },
{ baseURL: 123 },
)).rejects.toThrow('must be a string')
})
})

View File

@@ -8,6 +8,7 @@ The `dsh-sdk` launcher owns SDK project startup and configuration.
| `dsh-sdk dev [target] [-- args…]` | Register TypeScript and local-workspace source resolution, then use the start path |
| `dsh-sdk build [args…]` | Invoke the project's installed tsdown with the project arguments |
| `dsh-sdk config` | Open one interactive edit session, review accumulated changes, commit once, and install once when NPM dependencies changed |
| `dsh-sdk create <source>` | Add an external Cordis plugin from a native package-manager source (`pkg@version` or `github:owner/repo#ref`): confirm, `<pm> add <source>`, then mount the resolved dependency in `cordis.yml`. No giget/pacote; the package manager resolves and pins the source (github deps build via their own `prepare` under the manager's policy) |
`ProjectBuild(tsdownConfig)` and `PluginBuild(tsdownConfig)` are exported only from `@deepseek-ai/dsh-scripts/dev/tsdown-config`. Development and production read the same `cordis.yml`.

View File

@@ -32,6 +32,7 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-helper": "workspace:^",
"@deepseek-ai/dsh-telemetry": "workspace:^",
"commander": "^15.0.0",
"node-addon-require-builtin": "^0.1.0"
},

View File

@@ -8,12 +8,13 @@ import { parseArgs as parseNodeArgs } from 'node:util'
import { Command } from 'commander'
/** Commands implemented by the dsh-sdk launcher. */
type DshSdkCommand = 'start' | 'dev' | 'build' | 'config'
type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' | 'create'
/** Parsed dsh-sdk invocation. */
export interface DshSdkArgs {
command?: DshSdkCommand
target?: string
source?: string
forwarded: readonly string[]
help: boolean
}
@@ -60,6 +61,9 @@ export function parseDshSdkArgs(argv: readonly string[]): DshSdkArgs {
program.command('config').helpOption(false).action(() => {
parsed = { command: 'config', forwarded: [], help: false }
})
program.command('create <source>').helpOption(false).action((source: string) => {
parsed = { command: 'create', source, forwarded: [], help: false }
})
program.parse([...launcherArgv], { from: 'user' })
/* v8 ignore next -- every registered Commander action above assigns parsed or Commander throws */
if (!parsed) throw new Error('dsh-sdk command did not resolve')

View File

@@ -7,7 +7,9 @@
import { parseDshSdkArgs } from './args.ts'
import { runProjectBuild } from './build.ts'
import { runConfigCommand, type ConfigCommandContext } from './config.ts'
import { runCreatePluginCommand } from './create-plugin.ts'
import { runSDK } from './runtime.ts'
import { reportCommandTelemetry, type CommandTelemetryEvent } from './telemetry.ts'
import { DSH_SDK_TEMPLATES } from './templates/dsh-sdk-templates.ts'
/** Injectable process and command boundaries used by the dsh-sdk bin. */
@@ -19,6 +21,8 @@ export interface DshSdkCommandContext extends ConfigCommandContext {
run?: typeof runSDK
build?: typeof runProjectBuild
config?: typeof runConfigCommand
createPlugin?: typeof runCreatePluginCommand
telemetry?: (event: CommandTelemetryEvent) => Promise<void>
}
/** Run one parsed dsh-sdk command and return its process exit code. */
@@ -31,28 +35,42 @@ export async function runDshSdkCommand(
stderr: process.stderr,
},
): Promise<number> {
const startedAt = Date.now()
let command: string | undefined
let success = true
try {
const args = parseDshSdkArgs(argv)
if (args.help || !args.command) {
context.stdout.write(DSH_SDK_TEMPLATES.usage.render({}))
return 0
}
command = args.command
const run = context.run ?? runSDK
const build = context.build ?? runProjectBuild
const config = context.config ?? runConfigCommand
const createPlugin = context.createPlugin ?? runCreatePluginCommand
switch (args.command) {
case 'start': await run(args.target, { cwd: context.cwd, argv: args.forwarded }); break
case 'dev': await run(args.target, { cwd: context.cwd, dev: true, argv: args.forwarded }); break
case 'build': await build(args.forwarded, context.cwd); break
case 'config': {
const result = await config(context)
if (result.installError) return 1
if (result.installError) { success = false; return 1 }
break
}
/* v8 ignore next -- Commander requires <source>, so create never dispatches without it */
case 'create': await createPlugin(args.source ?? '', context); break
}
return 0
} catch (error) {
success = false
context.stderr.write(`dsh-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
return 1
} finally {
if (command !== undefined) {
/* v8 ignore next -- production telemetry wiring is exercised by the built-bin smoke */
const telemetry = context.telemetry ?? reportCommandTelemetry
await telemetry({ command, cwd: context.cwd, durationMs: Date.now() - startedAt, success })
}
}
}

View File

@@ -28,6 +28,17 @@ export interface ConfigWorkflowResult {
installError?: Error
}
/**
* Non-interactive desired end-state for a config run: the complete set of enabled
* features, with options and any secrets/values a newly installed feature needs.
* Features not listed are reconciled to disabled, exactly as an interactive tree
* selection would be. Custom (non-feature) cordis plugins keep their current state;
* toggling them headlessly is not yet supported.
*/
export interface ConfigPlan {
features: readonly FeatureSelection[]
}
function featureTarget(feature: Feature): string {
return `feature:${feature.id}`
}
@@ -66,48 +77,58 @@ export class ConfigWorkflow {
}
/** Select desired state, reconcile the working copy, review, and apply. */
async run(project: SdkProject, registry: FeatureRegistry): Promise<ConfigWorkflowResult> {
async run(project: SdkProject, registry: FeatureRegistry, plan?: ConfigPlan): Promise<ConfigWorkflowResult> {
const edit = project.edit(registry)
const configurator = new FeatureConfigurator(this.port)
const features = registry.all().filter(feature => feature.isApplicable(project.profile))
const inspections = new Map(edit.inspections().map(item => [item.id, item]))
const custom = edit.cordisConfigEntries().filter(entry => !registry.ownerOfPackage(entry.name, project.profile))
const desired = requireAnswer(await this.port.nestedMultiselect<string, string>({
message: 'Configure the project',
showChanges: true,
options: [
...features.map((feature) => {
const installation = inspections.get(feature.id)
/* v8 ignore next -- inspections() is built from this exact feature registry */
if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`)
const inconsistent = installation.state === 'inconsistent'
const selectedOptions = new Set(installation.options.length > 0
? installation.options
: feature.defaultOptions(project.profile))
return {
value: featureTarget(feature),
label: feature.summary,
required: feature.required,
default: feature.required || installation.state === 'enabled' || inconsistent,
disabled: inconsistent,
...inconsistent ? { warning: installation.diagnostics.join('; ') } : {},
...feature.mode === 'single' ? {} : {
choiceMode: feature.mode,
choices: feature.options.map(option => ({
value: option.id,
label: option.label,
default: selectedOptions.has(option.id),
})),
},
}
}),
...custom.map(entry => ({
value: pluginTarget(entry.id),
label: `${entry.name} [custom]`,
default: !entry.disabled,
const desired = plan
? [
...plan.features.map(selection => ({
value: featureTarget(registry.get(selection.id)),
choices: selection.options,
})),
],
}))
...custom
.filter(entry => !entry.disabled)
.map(entry => ({ value: pluginTarget(entry.id), choices: [] as readonly string[] })),
]
: requireAnswer(await this.port.nestedMultiselect<string, string>({
message: 'Configure the project',
showChanges: true,
options: [
...features.map((feature) => {
const installation = inspections.get(feature.id)
/* v8 ignore next -- inspections() is built from this exact feature registry */
if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`)
const inconsistent = installation.state === 'inconsistent'
const selectedOptions = new Set(installation.options.length > 0
? installation.options
: feature.defaultOptions(project.profile))
return {
value: featureTarget(feature),
label: feature.summary,
required: feature.required,
default: feature.required || installation.state === 'enabled' || inconsistent,
disabled: inconsistent,
...inconsistent ? { warning: installation.diagnostics.join('; ') } : {},
...feature.mode === 'single' ? {} : {
choiceMode: feature.mode,
choices: feature.options.map(option => ({
value: option.id,
label: option.label,
default: selectedOptions.has(option.id),
})),
},
}
}),
...custom.map(entry => ({
value: pluginTarget(entry.id),
label: `${entry.name} [custom]`,
default: !entry.disabled,
})),
],
}))
const desiredByTarget = new Map(desired.map(item => [item.value, item]))
const targetProfile = {
...project.profile,
@@ -117,6 +138,9 @@ export class ConfigWorkflow {
if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature))
}
const plannedById = new Map<FeatureSelection['id'], FeatureSelection>(
(plan?.features ?? []).map(selection => [selection.id, selection]),
)
for (const feature of features) {
const installation = inspections.get(feature.id)
/* v8 ignore next -- inspections() is built from this exact feature registry */
@@ -124,7 +148,7 @@ export class ConfigWorkflow {
if (installation.state === 'inconsistent') continue
const choice = desiredByTarget.get(featureTarget(feature))
if (!choice && !feature.required) continue
await this.enableOrConfigure(feature, installation, choice, project, edit, configurator)
await this.enableOrConfigure(feature, installation, choice, project, edit, configurator, plannedById.get(feature.id))
}
for (const feature of [...features].reverse()) {
@@ -176,6 +200,7 @@ export class ConfigWorkflow {
project: SdkProject,
edit: ReturnType<SdkProject['edit']>,
configurator: FeatureConfigurator,
planned?: FeatureSelection,
): Promise<void> {
const options = choice?.choices.length
? choice.choices
@@ -183,7 +208,9 @@ export class ConfigWorkflow {
? installation.options
: feature.defaultOptions(project.profile)
if (installation.state === 'absent') {
const selection = await configurator.configure(feature, project.profile, undefined, options)
const selection = await configurator.configure(
feature, project.profile, undefined, options, planned?.secrets ?? {}, planned?.values ?? {},
)
edit.installFeature(feature, selection)
return
}
@@ -191,10 +218,7 @@ export class ConfigWorkflow {
if (!installation.selection) throw new Error(`feature ${feature.id} has no readable selection`)
if (!sameOptions(installation.options, options)) {
const selection: FeatureSelection = await configurator.configure(
feature,
project.profile,
installation.selection,
options,
feature, project.profile, installation.selection, options, planned?.secrets ?? {}, planned?.values ?? {},
)
edit.configureFeature(feature, selection)
}

View File

@@ -0,0 +1,91 @@
/**
* dsh-sdk create command: add an external Cordis plugin (github or npm) as a
* native package-manager dependency and mount it in cordis.yml.
*
* @module @deepseek-ai/dsh-scripts/create-plugin
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
ClackPromptPort,
ConfirmQuestion,
SdkProject,
createBuiltinRegistry,
requireAnswer,
type PackageManager,
type ProjectCommitResult,
type PromptPort,
} from '@deepseek-ai/dsh-helper'
/** Process and interaction slice required by dsh-sdk create. */
export interface CreatePluginContext {
cwd: string
stdin: NodeJS.ReadStream
stdout: NodeJS.WriteStream
port?: PromptPort
add?: (manager: PackageManager, spec: string, cwd: string) => Promise<void>
}
/** Result of a create run; `undefined` when the confirmation was declined. */
export type CreatePluginResult = ProjectCommitResult<SdkProject> | undefined
/** Derive a stable cordis entry id from a package name's last path segment. */
function pluginId(packageName: string): string {
const base = packageName.startsWith('@') ? packageName.slice(packageName.indexOf('/') + 1) : packageName
const id = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
/* v8 ignore next -- a valid npm package name always yields a non-empty id */
if (!id) throw new Error(`cannot derive a plugin id from package name: ${packageName}`)
return id
}
/** Read the direct dependency names declared in a project's package.json. */
async function dependencyNames(cwd: string): Promise<Set<string>> {
const manifest = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) as {
dependencies?: Record<string, unknown>
}
/* v8 ignore next -- generated projects always declare a dependencies map */
return new Set(Object.keys(manifest.dependencies ?? {}))
}
/**
* Add one external plugin dependency to the current project and mount it.
* @param source - a package-manager-native source (`pkg@version` or `github:owner/repo#ref`).
* @param context - process, interaction, and dependency-add boundaries.
* @returns the commit result, or `undefined` when the confirmation was declined.
*/
export async function runCreatePluginCommand(
source: string,
context: CreatePluginContext,
): Promise<CreatePluginResult> {
const spec = source.trim()
if (!spec) throw new Error('dsh-sdk create requires a plugin source (pkg@version or github:owner/repo#ref)')
if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
throw new Error('dsh-sdk create requires an interactive TTY')
}
const project = await SdkProject.open(context.cwd)
/* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
const port = context.port ?? new ClackPromptPort(context.stdin, context.stdout)
const confirmed = requireAnswer(await new ConfirmQuestion({
id: 'create.confirm',
message: `Add plugin '${spec}' as a dependency and mount it in cordis.yml?`,
initialValue: true,
}).resolve(port))
if (!confirmed) return undefined
const before = await dependencyNames(context.cwd)
/* v8 ignore next -- production package-manager wiring is exercised by the built-bin smoke */
const add = context.add ?? ((manager, source, cwd) => manager.add(source, cwd))
await add(project.profile.packageManager, spec, context.cwd)
const after = await dependencyNames(context.cwd)
const added = [...after].filter(name => !before.has(name))
if (added.length === 0) throw new Error(`dsh-sdk create: '${spec}' added no new dependency`)
const reopened = await SdkProject.open(context.cwd)
const registry = createBuiltinRegistry(reopened.profile)
const edit = reopened.edit(registry)
for (const packageName of added) edit.addExternalPlugin(pluginId(packageName), packageName)
const commit = await edit.commit()
context.stdout.write(`Mounted ${added.join(', ')} in cordis.yml.\n`)
return commit
}

View File

@@ -0,0 +1,63 @@
/**
* Launcher-side telemetry wiring: resolve consent and send one fire-and-forget
* event around each dsh-sdk command. Best-effort — never affects the command's
* outcome or exit code.
*
* @module @deepseek-ai/dsh-scripts/telemetry
*/
import {
ConsentResolver,
TelemetryReporter,
buildTelemetryPayload,
type ConsentDecision,
} from '@deepseek-ai/dsh-telemetry'
/** One command's telemetry lifecycle facts. */
export interface CommandTelemetryEvent {
/** The dsh-sdk command that ran. */
command: string
/** Project directory whose consent, `cordis.yml`, and `package.json` are read. */
cwd: string
/** Wall-clock duration in milliseconds. */
durationMs: number
/** Whether the command completed without error. */
success: boolean
}
/** Injectable consent and delivery seams for tests. */
export interface CommandTelemetryDeps {
resolve?: (cwd: string) => Promise<ConsentDecision>
reporter?: Pick<TelemetryReporter, 'report' | 'flush'>
}
/**
* Resolve consent for the project and, when allowed, assemble and send one
* telemetry event, draining in-flight sends before returning. Swallows every
* error so telemetry can never change a command's result.
* @param event - the command lifecycle facts.
* @param deps - consent and delivery seams; defaults hit the real endpoint.
*/
export async function reportCommandTelemetry(
event: CommandTelemetryEvent,
deps: CommandTelemetryDeps = {},
): Promise<void> {
try {
/* v8 ignore next -- the production ConsentResolver is exercised by the built-bin smoke */
const resolve = deps.resolve ?? (cwd => new ConsentResolver().resolve(cwd))
const consent = await resolve(event.cwd)
if (!consent.allowed) return
const payload = await buildTelemetryPayload({
command: event.command,
durationMs: event.durationMs,
success: event.success,
projectDir: event.cwd,
})
/* v8 ignore next -- the production TelemetryReporter is exercised by the built-bin smoke */
const reporter = deps.reporter ?? new TelemetryReporter()
reporter.report(payload, consent)
await reporter.flush()
} catch {
// Telemetry is best-effort; a consent, payload, or delivery fault never reaches the command.
}
}

View File

@@ -5,3 +5,4 @@ Commands:
dev [target] [-- args...] Start with TypeScript and local-plugin source resolution
build [args...] Run the project's installed tsdown
config Interactively edit project features
create <source> Add an external plugin dependency (pkg@version or github:owner/repo#ref) and mount it in cordis.yml

View File

@@ -5,6 +5,7 @@ import { PassThrough, Writable } from 'node:stream'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import {
HeadlessPromptPort,
LocalPluginBlueprint,
NpmPackageManager,
SdkProject,
@@ -29,7 +30,9 @@ import { parseDshSdkArgs, parseSdkBootArgs } from '../src/args.ts'
import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts'
import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts'
import { runConfigCommand } from '../src/config.ts'
import { ConfigWorkflow } from '../src/config/config-workflow.ts'
import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts'
import { runCreatePluginCommand } from '../src/create-plugin.ts'
import { reportCommandTelemetry, type CommandTelemetryEvent } from '../src/telemetry.ts'
import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts'
const temporary: string[] = []
@@ -165,6 +168,7 @@ describe('Commander launcher arguments', () => {
await expect(runDshSdkCommand(['unknown'], context)).resolves.toBe(1)
await expect(runDshSdkCommand([], context)).resolves.toBe(0)
expect(context.readStdout()).toContain('Usage: dsh-sdk')
expect(context.readStdout()).toContain('create <source>')
const defaults = commandContext(root)
await writeFile(join(root, 'main.mjs'), 'export function main() { return "ok" }\n')
@@ -399,6 +403,28 @@ describe('ConfigWorkflow', () => {
expect(output.read()).toContain('Disable feature: todo')
})
it('reconciles a headless plan without prompting and preserves custom plugins', async () => {
const project = await committedProject([], [new LocalPluginBlueprint('plugin', 'plugin')])
const registry = createBuiltinRegistry(project.profile)
const output = outputBuffer()
let installs = 0
const plan: ConfigPlan = {
features: [
{ id: featureId('bash'), options: ['local'] },
{ id: featureId('persistence'), options: ['jsonl'] },
{ id: featureId('todo'), options: ['default'] },
{ id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } },
],
}
const result = await new ConfigWorkflow(
new HeadlessPromptPort(), output.stream, async () => { installs += 1 },
).run(project, registry, plan)
expect(result.commit?.project.cordis.entry('tool-todo')).toBeDefined()
// the unlisted custom local plugin keeps its enabled state (not nuked by the plan)
expect(result.commit?.project.cordis.entry('plugin')?.disabled).toBeFalsy()
expect(installs).toBe(1)
})
it('installs once after NPM dependency changes and keeps committed files on install failure', async () => {
const project = await committedProject()
const registry = createBuiltinRegistry(project.profile)
@@ -536,3 +562,108 @@ describe('ConfigWorkflow', () => {
expect(output.read()).toContain('Disable feature: ask-user')
})
})
describe('dsh-sdk create', () => {
const writeDependency = (name: string) => async (_m: unknown, spec: string, cwd: string): Promise<void> => {
const path = join(cwd, 'package.json')
const manifest = JSON.parse(await readFile(path, 'utf8')) as { dependencies?: Record<string, string> }
manifest.dependencies = { ...manifest.dependencies, [name]: spec }
await writeFile(path, JSON.stringify(manifest, null, 2))
}
it('adds a dependency and mounts it after confirmation', async () => {
const project = await committedProject()
const context = { ...commandContext(project.root), port: new QueuePort([true]), add: writeDependency('my-ext-plugin') }
const result = await runCreatePluginCommand('github:o/r#sha', context)
expect(result?.project.cordis.entry('my-ext-plugin')?.name).toBe('my-ext-plugin')
expect(context.readStdout()).toContain('Mounted my-ext-plugin')
})
it('derives the cordis id from a scoped package name', async () => {
const project = await committedProject()
const context = { ...commandContext(project.root), port: new QueuePort([true]), add: writeDependency('@acme/cool-plugin') }
const result = await runCreatePluginCommand('@acme/cool-plugin@1.0.0', context)
expect(result?.project.cordis.entry('cool-plugin')?.name).toBe('@acme/cool-plugin')
})
it('returns undefined and adds nothing when declined', async () => {
const project = await committedProject()
let added = false
const context = {
...commandContext(project.root),
port: new QueuePort([false]),
add: async () => { added = true },
}
await expect(runCreatePluginCommand('pkg@1.0.0', context)).resolves.toBeUndefined()
expect(added).toBe(false)
})
it('rejects an empty source, a non-TTY session, and a no-op add', async () => {
const project = await committedProject()
await expect(runCreatePluginCommand(' ', { ...commandContext(project.root), port: new QueuePort([]) }))
.rejects.toThrow('requires a plugin source')
const noTty = commandContext(project.root)
noTty.stdin.isTTY = false
noTty.stdout.isTTY = false
await expect(runCreatePluginCommand('pkg@1.0.0', noTty)).rejects.toThrow('interactive TTY')
const noOutTty = commandContext(project.root)
noOutTty.stdout.isTTY = false
await expect(runCreatePluginCommand('pkg@1.0.0', noOutTty)).rejects.toThrow('interactive TTY')
await expect(runCreatePluginCommand('pkg@1.0.0', {
...commandContext(project.root), port: new QueuePort([true]), add: async () => {},
})).rejects.toThrow('added no new dependency')
})
it('dispatches create through the launcher', async () => {
const project = await committedProject()
const context = commandContext(project.root)
context.createPlugin = async () => undefined
await expect(runDshSdkCommand(['create', 'pkg@1.0.0'], context)).resolves.toBe(0)
})
})
describe('command telemetry', () => {
it('reports when consent allows and skips when denied or faulting', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-telemetry-'))
temporary.push(dir)
const sent: unknown[] = []
const reporter = { report: () => { sent.push(1) }, flush: async () => {} }
await reportCommandTelemetry(
{ command: 'build', cwd: dir, durationMs: 5, success: true },
{ resolve: async () => ({ allowed: true, reason: 'absent' }), reporter },
)
expect(sent).toHaveLength(1)
await reportCommandTelemetry(
{ command: 'build', cwd: dir, durationMs: 5, success: true },
{ resolve: async () => ({ allowed: false, reason: 'disabled' }), reporter },
)
expect(sent).toHaveLength(1)
await expect(reportCommandTelemetry(
{ command: 'build', cwd: dir, durationMs: 5, success: true },
{ resolve: async () => { throw new Error('boom') }, reporter },
)).resolves.toBeUndefined()
expect(sent).toHaveLength(1)
})
it('emits a telemetry event carrying each command outcome', async () => {
const project = await committedProject()
const events: CommandTelemetryEvent[] = []
const context = commandContext(project.root)
context.telemetry = async (event) => { events.push(event) }
context.build = async () => {}
await expect(runDshSdkCommand(['build'], context)).resolves.toBe(0)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ command: 'build', cwd: project.root, success: true })
await runDshSdkCommand([], context)
expect(events).toHaveLength(1)
context.build = async () => { throw new Error('boom') }
await expect(runDshSdkCommand(['build'], context)).resolves.toBe(1)
expect(events[1]).toMatchObject({ command: 'build', success: false })
context.config = async () => ({ installError: new Error('offline') })
await expect(runDshSdkCommand(['config'], context)).resolves.toBe(1)
expect(events.at(-1)).toMatchObject({ command: 'config', success: false })
})
})

View File

@@ -7,6 +7,7 @@
"include": ["src"],
"references": [
{ "path": "../helper" },
{ "path": "../telemetry" },
{ "path": "../../ui/app-boot" },
{ "path": "../../../vendor/cordis" }
]

View File

@@ -0,0 +1,28 @@
# `@deepseek-ai/dsh-telemetry`
Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain library the launcher imports around each command; it is **not** a Cordis plugin, because `build` and first-init `create` never boot Cordis. Wiring the reporter into the launcher command dispatch and adding the telemetry consent feature to the `dsh-helper` catalog live in their owning packages, not here.
| Export | Role |
|---|---|
| `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. |
| `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. |
| `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`; `package.json` ships only alongside a `cordis.yml`, so a command run in a non-SDK directory never uploads that directory's unrelated manifest. |
| `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). |
| `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. |
Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`.
The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release.
## Model Experience
None, as the reporter sends developer-cycle telemetry from the launcher and never reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the real endpoint is set.
- **Redaction is heuristic** — a conservative backstop, not a guarantee; secrets belong in `.env`, which is never read or reported.

View File

@@ -0,0 +1,35 @@
{
"name": "@deepseek-ai/dsh-telemetry",
"description": "Launcher-side dsh-sdk telemetry: secret redaction, consent resolution, anonymous id, payload builder, and fire-and-forget reporter",
"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": {
"yaml": "^2.9.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,106 @@
/**
* Per-machine anonymous telemetry id.
*
* The id is a random UUID persisted in a per-user GLOBAL config file — never in
* the project, and never derived from the git remote, repository URL, or any
* other identifying source (a derived id would make "anonymous" a fiction). The
* same id is reused across projects on one machine so telemetry counts machines,
* not repositories.
*
* @module @deepseek-ai/dsh-telemetry/anonymous-id
*/
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import type { Branded } from '@deepseek-ai/dsh-brand'
/** A machine-scoped anonymous telemetry id (random UUID v4). */
export type AnonymousId = Branded<'AnonymousId'>
/** Config directory name owned by the DeepSeek Harness across tools. */
const CONFIG_NAMESPACE = 'deepseek-harness'
/** Default file, inside the global config dir, storing the anonymous id. */
export const ANONYMOUS_ID_FILE_NAME = 'telemetry.json'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** Ambient seams for locating and generating the id; every field has a default. */
export interface AnonymousIdOptions {
/** Environment consulted for `DSH_CONFIG_HOME`/`XDG_CONFIG_HOME`/`APPDATA`; defaults to `process.env`. */
env?: NodeJS.ProcessEnv
/** Platform string used to pick the Windows path; defaults to `process.platform`. */
platform?: NodeJS.Platform
/** Home directory resolver; defaults to `os.homedir`. */
homeDir?: () => string
/** UUID generator; defaults to `crypto.randomUUID` (test seam). */
randomUUID?: () => string
}
/**
* Resolve the per-user global config directory for harness tooling.
* Precedence: `DSH_CONFIG_HOME` (explicit override) > `XDG_CONFIG_HOME` >
* platform default (`%APPDATA%` on Windows, else `~/.config`).
* @param options - environment, platform, and home-directory seams.
* @returns absolute config directory path for the harness namespace.
*/
export function globalConfigDir(options: AnonymousIdOptions = {}): string {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const home = options.homeDir ?? homedir
if (env.DSH_CONFIG_HOME !== undefined && env.DSH_CONFIG_HOME.length > 0) return env.DSH_CONFIG_HOME
if (env.XDG_CONFIG_HOME !== undefined && env.XDG_CONFIG_HOME.length > 0) {
return join(env.XDG_CONFIG_HOME, CONFIG_NAMESPACE)
}
if (platform === 'win32' && env.APPDATA !== undefined && env.APPDATA.length > 0) {
return join(env.APPDATA, CONFIG_NAMESPACE)
}
return join(home(), '.config', CONFIG_NAMESPACE)
}
/** Read a valid persisted id from the store, or `undefined` when absent/corrupt. */
async function readPersistedId(file: string): Promise<AnonymousId | undefined> {
let text: string
try {
text = await readFile(file, 'utf8')
} catch {
// Absent or unreadable: the caller mints and persists a fresh id.
return undefined
}
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
// Corrupt JSON: the caller overwrites the store with a fresh id.
return undefined
}
if (parsed !== null && typeof parsed === 'object') {
const value = (parsed as Record<string, unknown>).anonymousId
if (typeof value === 'string' && UUID_PATTERN.test(value)) return value as AnonymousId
}
return undefined
}
/**
* Return the machine's anonymous id, creating and persisting one on first use.
* Persistence is best-effort: a write failure still returns a usable id for the
* current run so telemetry is never blocked by config-dir permissions.
* @param options - config-location and UUID-generation seams.
* @returns the stable per-machine anonymous id.
*/
export async function getOrCreateAnonymousId(options: AnonymousIdOptions = {}): Promise<AnonymousId> {
const file = join(globalConfigDir(options), ANONYMOUS_ID_FILE_NAME)
const existing = await readPersistedId(file)
if (existing !== undefined) return existing
const generate = options.randomUUID ?? randomUUID
const created = generate() as AnonymousId
try {
await mkdir(dirname(file), { recursive: true })
await writeFile(file, `${JSON.stringify({ anonymousId: created }, null, 2)}\n`, 'utf8')
} catch {
// Best-effort persistence: return the fresh id even when the store is unwritable.
}
return created
}

View File

@@ -0,0 +1,125 @@
/**
* Consent resolution for dsh-sdk telemetry.
*
* Telemetry is OFF only when `cordis.yml` contains a telemetry entry that is
* explicitly `disabled`; every other file state reports (no `cordis.yml`, an
* enabled entry, or no telemetry entry at all). The resolver PARSES `cordis.yml`
* — it never boots a Cordis application — because several launcher commands
* (`build`, `create`) never boot Cordis at all. `DO_NOT_TRACK` and CI
* environment signals force a denial regardless of file state.
*
* @module @deepseek-ai/dsh-telemetry/consent-resolver
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { parseDocument, type ScalarTag } from 'yaml'
/** Default `cordis.yml` entry name that carries telemetry consent. */
export const DEFAULT_TELEMETRY_PLUGIN_NAME = '@deepseek-ai/dsh-telemetry'
/**
* Passthrough for Cordis' `!!js` expression tag so parsing consent never fails
* on projects that inline JavaScript expressions; the resolver only reads plain
* `name`/`disabled` scalars and does not evaluate expressions.
*/
const JS_EXPRESSION_TAG: ScalarTag = {
tag: 'tag:yaml.org,2002:js',
resolve: value => value,
}
/** Why telemetry is or is not permitted for one command. */
export type ConsentReason =
| 'enabled'
| 'disabled'
| 'absent'
| 'no-config'
| 'do-not-track'
| 'ci'
| 'unreadable'
/** Resolved telemetry consent for one command invocation. */
export interface ConsentDecision {
/** Whether telemetry may be sent. */
allowed: boolean
/** The signal that determined {@link allowed}. */
reason: ConsentReason
}
/** Tuning for {@link ConsentResolver}; every field defaults to a documented value. */
export interface ConsentResolverOptions {
/** `cordis.yml` entry name whose enabled state carries consent. */
telemetryPluginName?: string
/** Environment used for `DO_NOT_TRACK`/CI checks; defaults to `process.env`. */
env?: NodeJS.ProcessEnv
/** Honor `DO_NOT_TRACK`/CI env signals as a hard opt-out. Defaults to `true`. */
honorEnvOptOut?: boolean
/** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */
allowWhenNoConfig?: boolean
/** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `true` (report unless a present entry is disabled). */
allowWhenEntryAbsent?: boolean
}
/** Whether an environment variable is set to a non-empty, non-"0"/"false" value. */
function envEnabled(value: string | undefined): boolean {
if (value === undefined) return false
const normalized = value.trim().toLowerCase()
return normalized.length > 0 && normalized !== '0' && normalized !== 'false'
}
/** Read a `cordis.yml` entry's `name`/`disabled` scalars, tolerating `!!js` tags. */
function readTelemetryEntry(text: string, pluginName: string): { present: boolean; disabled: boolean } {
const document = parseDocument(text, { customTags: [JS_EXPRESSION_TAG] })
const contents: unknown = document.toJS({ maxAliasCount: -1 })
if (!Array.isArray(contents)) return { present: false, disabled: false }
for (const entry of contents) {
if (entry === null || typeof entry !== 'object') continue
const record = entry as Record<string, unknown>
if (record.name === pluginName) return { present: true, disabled: record.disabled === true }
}
return { present: false, disabled: false }
}
/** Resolve telemetry consent by parsing a project's `cordis.yml` and the environment. */
export class ConsentResolver {
readonly #pluginName: string
readonly #env: NodeJS.ProcessEnv
readonly #honorEnvOptOut: boolean
readonly #allowWhenNoConfig: boolean
readonly #allowWhenEntryAbsent: boolean
/** @param options - plugin name, environment, and default-decision knobs. */
constructor(options: ConsentResolverOptions = {}) {
this.#pluginName = options.telemetryPluginName ?? DEFAULT_TELEMETRY_PLUGIN_NAME
this.#env = options.env ?? process.env
this.#honorEnvOptOut = options.honorEnvOptOut ?? true
this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true
this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? true
}
/**
* Resolve consent for a command run in the given project directory.
* @param projectDir - absolute or relative project root containing `cordis.yml`.
* @returns the consent decision and the signal that produced it.
*/
async resolve(projectDir: string): Promise<ConsentDecision> {
if (this.#honorEnvOptOut) {
if (envEnabled(this.#env.DO_NOT_TRACK)) return { allowed: false, reason: 'do-not-track' }
if (envEnabled(this.#env.CI)) return { allowed: false, reason: 'ci' }
}
let text: string
try {
text = await readFile(join(projectDir, 'cordis.yml'), 'utf8')
} catch (error) {
// Missing cordis.yml is the first-init (`create`) path; any other read
// fault is treated conservatively as its own reason.
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { allowed: this.#allowWhenNoConfig, reason: 'no-config' }
}
return { allowed: false, reason: 'unreadable' }
}
const entry = readTelemetryEntry(text, this.#pluginName)
if (!entry.present) return { allowed: this.#allowWhenEntryAbsent, reason: 'absent' }
return entry.disabled ? { allowed: false, reason: 'disabled' } : { allowed: true, reason: 'enabled' }
}
}

View File

@@ -0,0 +1,45 @@
/**
* Launcher-side telemetry for the dsh-sdk toolchain: secret redaction, consent
* resolution, anonymous id, payload assembly, and a fire-and-forget reporter.
*
* This package is a plain library the launcher imports around each command — it
* is NOT a Cordis plugin (several commands never boot Cordis). Wiring it into
* the launcher command dispatch and the helper feature catalog lives outside
* this package.
*
* @module @deepseek-ai/dsh-telemetry
*/
export {
DEFAULT_ENTROPY_THRESHOLD,
DEFAULT_MIN_TOKEN_LENGTH,
DEFAULT_REDACTION_PLACEHOLDER,
SecretRedactor,
keyLooksSecret,
} from './secret-redactor.ts'
export type { SecretRedactorOptions } from './secret-redactor.ts'
export {
ConsentResolver,
DEFAULT_TELEMETRY_PLUGIN_NAME,
} from './consent-resolver.ts'
export type {
ConsentDecision,
ConsentReason,
ConsentResolverOptions,
} from './consent-resolver.ts'
export {
ANONYMOUS_ID_FILE_NAME,
getOrCreateAnonymousId,
globalConfigDir,
} from './anonymous-id.ts'
export type { AnonymousId, AnonymousIdOptions } from './anonymous-id.ts'
export { buildTelemetryPayload } from './payload.ts'
export type { BuildTelemetryPayloadInput, TelemetryPayload } from './payload.ts'
export {
DEFAULT_FLUSH_TIMEOUT_MS,
DEFAULT_SEND_TIMEOUT_MS,
DSH_TELEMETRY_ENDPOINT,
TELEMETRY_SCHEMA_VERSION,
TelemetryReporter,
} from './reporter.ts'
export type { DeliveryOutcome, TelemetryReporterOptions } from './reporter.ts'

View File

@@ -0,0 +1,82 @@
/**
* Telemetry payload assembly.
*
* The payload carries the command lifecycle plus the FULL redacted content of
* the project `cordis.yml` and `package.json`. It NEVER reads or includes `.env`
* — secrets live only in `.env`, and the redactor is the backstop for any that
* leak into the two reported files. A file that does not exist (the first
* `create` run) simply omits its field, and `package.json` ships only when
* `cordis.yml` is present: without it the directory is not an SDK project, and
* its manifest belongs to whatever unrelated project the command ran in.
*
* @module @deepseek-ai/dsh-telemetry/payload
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { SecretRedactor } from './secret-redactor.ts'
/** Project files whose full (redacted) content ships with the payload. */
const REPORTED_FILES = ['cordis.yml', 'package.json'] as const
/** One command's telemetry payload. */
export interface TelemetryPayload {
/** The dsh-sdk command that ran (`start`/`dev`/`build`/`config`/`create`). */
command: string
/** Wall-clock duration of the command in milliseconds. */
durationMs: number
/** Whether the command completed without error. */
success: boolean
/** Redacted full text of the project `cordis.yml`, absent when the file does not exist. */
cordisYmlContent?: string
/** Redacted full text of the project `package.json`, absent when it or `cordis.yml` does not exist. */
packageJsonContent?: string
}
/** Inputs for {@link buildTelemetryPayload}. */
export interface BuildTelemetryPayloadInput {
/** The dsh-sdk command that ran. */
command: string
/** Wall-clock duration of the command in milliseconds. */
durationMs: number
/** Whether the command completed without error. */
success: boolean
/** Project root whose `cordis.yml` and `package.json` are read. */
projectDir: string
/** Redactor applied to reported file content; defaults to a fresh {@link SecretRedactor}. */
redactor?: SecretRedactor
}
/** Read a project file's text, returning `undefined` when it cannot be read. */
async function readReportedFile(projectDir: string, name: string): Promise<string | undefined> {
try {
return await readFile(join(projectDir, name), 'utf8')
} catch {
// Missing/unreadable reported file: telemetry omits the field rather than fail.
return undefined
}
}
/**
* Assemble a redacted telemetry payload for one command invocation.
* @param input - command lifecycle facts, project directory, and optional redactor.
* @returns the payload with redacted `cordis.yml`/`package.json` content.
*/
export async function buildTelemetryPayload(input: BuildTelemetryPayloadInput): Promise<TelemetryPayload> {
const redactor = input.redactor ?? new SecretRedactor()
const [cordisYml, packageJson] = await Promise.all(
REPORTED_FILES.map(name => readReportedFile(input.projectDir, name)),
)
return {
command: input.command,
durationMs: input.durationMs,
success: input.success,
...cordisYml !== undefined ? { cordisYmlContent: redactor.redactText(cordisYml) } : {},
// package.json is an SDK-project manifest only alongside cordis.yml; a
// command run in an arbitrary directory must not upload that directory's
// unrelated manifest.
...cordisYml !== undefined && packageJson !== undefined
? { packageJsonContent: redactor.redactText(packageJson) }
: {},
}
}

View File

@@ -0,0 +1,149 @@
/**
* Fire-and-forget telemetry reporter for the dsh-sdk launcher.
*
* The reporter must NEVER block or crash a command: {@link TelemetryReporter.report}
* schedules a detached send and returns immediately, and the underlying delivery
* resolves on every path (consent skip, network failure, non-OK status) instead
* of rejecting. {@link TelemetryReporter.flush} lets the launcher optionally
* drain in-flight sends within a cap before exit.
*
* @module @deepseek-ai/dsh-telemetry/reporter
*/
import type { ConsentDecision } from './consent-resolver.ts'
import type { TelemetryPayload } from './payload.ts'
import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts'
import { SecretRedactor } from './secret-redactor.ts'
/**
* Placeholder collection endpoint. This is a fixed protocol constant, not a
* deployment tunable.
*
* FIXME(ccyu): replace with the real telemetry endpoint before release. The
* `.invalid` TLD guarantees delivery fails harmlessly until then.
*/
export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk'
/** Wire-envelope schema version; bump on any incompatible body change. */
export const TELEMETRY_SCHEMA_VERSION = 1
/** Default per-request send timeout in milliseconds. */
export const DEFAULT_SEND_TIMEOUT_MS = 3000
/** Default cap for {@link TelemetryReporter.flush} in milliseconds. */
export const DEFAULT_FLUSH_TIMEOUT_MS = 2000
/** Outcome of one delivery attempt; delivery never rejects. */
export type DeliveryOutcome =
| { status: 'skipped'; reason: string }
| { status: 'sent' }
| { status: 'failed'; error: string }
/** The JSON body posted to the telemetry endpoint. */
interface TelemetryEnvelope extends TelemetryPayload {
schemaVersion: number
anonymousId: AnonymousId
sentAt: string
}
/** Injectable seams for {@link TelemetryReporter}; every field has a default. */
export interface TelemetryReporterOptions {
/** Collection endpoint; defaults to {@link DSH_TELEMETRY_ENDPOINT}. */
endpoint?: string
/** `fetch` implementation; defaults to the global `fetch`. */
fetch?: typeof globalThis.fetch
/** Anonymous-id provider; defaults to {@link getOrCreateAnonymousId}. */
anonymousId?: () => Promise<AnonymousId>
/** Redactor applied to the assembled envelope as a final backstop; defaults to a fresh {@link SecretRedactor}. */
redactor?: SecretRedactor
/** Per-request send timeout in milliseconds. */
timeoutMs?: number
/** Clock for the envelope timestamp; defaults to `Date.now`. */
now?: () => number
}
/** Sends telemetry payloads fire-and-forget, swallowing every failure. */
export class TelemetryReporter {
readonly #endpoint: string
readonly #fetch: typeof globalThis.fetch
readonly #anonymousId: () => Promise<AnonymousId>
readonly #redactor: SecretRedactor
readonly #timeoutMs: number
readonly #now: () => number
readonly #inflight = new Set<Promise<DeliveryOutcome>>()
/** @param options - endpoint, transport, id provider, and timing seams. */
constructor(options: TelemetryReporterOptions = {}) {
this.#endpoint = options.endpoint ?? DSH_TELEMETRY_ENDPOINT
this.#fetch = options.fetch ?? globalThis.fetch
this.#anonymousId = options.anonymousId ?? getOrCreateAnonymousId
this.#redactor = options.redactor ?? new SecretRedactor()
this.#timeoutMs = options.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS
this.#now = options.now ?? Date.now
}
/**
* Schedule a detached, non-blocking send. Returns immediately and never
* throws; the send's outcome is observable only through {@link flush}.
* @param payload - the command payload to report.
* @param consent - resolved consent; a denial short-circuits to a skip.
*/
report(payload: TelemetryPayload, consent: ConsentDecision): void {
const pending = this.#deliver(payload, consent)
this.#inflight.add(pending)
void pending.finally(() => this.#inflight.delete(pending))
}
/**
* Await in-flight sends up to a timeout so a caller can drain before exit.
* Resolves on the cap regardless of send progress; never rejects.
* @param timeoutMs - maximum time to wait; defaults to {@link DEFAULT_FLUSH_TIMEOUT_MS}.
*/
async flush(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise<void> {
if (this.#inflight.size === 0) return
const drained = Promise.allSettled([...this.#inflight]).then(() => undefined)
let timer!: ReturnType<typeof setTimeout>
const capped = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs)
})
try {
await Promise.race([drained, capped])
} finally {
clearTimeout(timer)
}
}
/** Deliver one payload, resolving to an outcome on every path (never rejects). */
async #deliver(payload: TelemetryPayload, consent: ConsentDecision): Promise<DeliveryOutcome> {
if (!consent.allowed) return { status: 'skipped', reason: consent.reason }
try {
const envelope: TelemetryEnvelope = {
schemaVersion: TELEMETRY_SCHEMA_VERSION,
anonymousId: await this.#anonymousId(),
sentAt: new Date(this.#now()).toISOString(),
...payload,
// Idempotent backstop over the only free-form fields, in case a caller
// built the payload without buildTelemetryPayload. Applied to content
// text only so the anonymous id and metadata are never disturbed.
...payload.cordisYmlContent !== undefined
? { cordisYmlContent: this.#redactor.redactText(payload.cordisYmlContent) }
: {},
...payload.packageJsonContent !== undefined
? { packageJsonContent: this.#redactor.redactText(payload.packageJsonContent) }
: {},
}
const response = await this.#fetch(this.#endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(envelope),
signal: AbortSignal.timeout(this.#timeoutMs),
})
if (!response.ok) return { status: 'failed', error: `HTTP ${response.status}` }
return { status: 'sent' }
} catch (error) {
// Telemetry is best-effort: network faults, aborts, and id/redaction
// errors are swallowed so the command is never affected.
return { status: 'failed', error: error instanceof Error ? error.message : String(error) }
}
}
}

View File

@@ -0,0 +1,208 @@
/**
* Conservative secret redactor: the safety backstop that scrubs credential-like
* values from telemetry content before it leaves the machine.
*
* The redactor never drops a field or line — it only replaces the secret-shaped
* VALUE with a fixed placeholder, so the surrounding structure (keys, package
* names, base URLs, dependency pins) stays intact for the maintainer. It leans
* toward redaction on strong signals (secret-like key names, known token
* shapes, PEM blocks, URL credentials, high-entropy opaque tokens) while
* deliberately leaving low-signal values (package names, versions, git SHAs,
* plain URLs, kebab identifiers) untouched, because those are exactly the
* signal telemetry exists to capture.
*
* @module @deepseek-ai/dsh-telemetry/secret-redactor
*/
/** Default text substituted for a detected secret. */
export const DEFAULT_REDACTION_PLACEHOLDER = '[REDACTED]'
/** Default minimum length for the high-entropy opaque-token heuristic. */
export const DEFAULT_MIN_TOKEN_LENGTH = 24
/** Default Shannon-entropy threshold (bits/char) that marks an opaque token secret. */
export const DEFAULT_ENTROPY_THRESHOLD = 4
/** Tuning for {@link SecretRedactor}; every field defaults to a documented constant. */
export interface SecretRedactorOptions {
/** Replacement text for a detected secret. */
placeholder?: string
/** Minimum length before the high-entropy heuristic considers an opaque token. */
minTokenLength?: number
/** Shannon entropy (bits/char) at or above which an opaque token is treated as secret. */
entropyThreshold?: number
}
/**
* Regexes for well-known credential shapes. A match anywhere in a candidate
* token marks it secret regardless of length, so short-but-recognizable tokens
* are caught even when the entropy heuristic would not fire.
*/
const KNOWN_SECRET_PATTERNS: readonly RegExp[] = [
/sk-(?:ant-)?[A-Za-z0-9_-]{10,}/, // OpenAI / DeepSeek / Anthropic style
/gh[pousr]_[A-Za-z0-9]{16,}/, // GitHub personal/oauth/server/refresh tokens
/github_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT
/xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens
/AKIA[0-9A-Z]{16}/, // AWS access key id
/AIza[0-9A-Za-z_-]{35}/, // Google API key
/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JWT
]
/**
* Key names (normalized to lowercase, separators stripped) whose value is a
* secret. Split by match strategy so short/ambiguous words do not over-match:
* `author` must not trip the `auth` rule.
*/
const KEY_SUBSTRING_INDICATORS: readonly string[] = [
'password', 'passwd', 'passphrase', 'secret', 'apikey', 'apisecret',
'clientsecret', 'privatekey', 'secretkey', 'accesskey', 'credential',
'connectionstring', 'sastoken', 'xapikey', 'authtoken', 'accesstoken',
'refreshtoken', 'idtoken', 'sessiontoken', 'bearertoken',
]
const KEY_SUFFIX_INDICATORS: readonly string[] = ['token']
const KEY_EXACT_INDICATORS: readonly string[] = [
'auth', 'authorization', 'cookie', 'bearer', 'dsn', 'signature',
]
/**
* Whether a key name marks its value as a secret.
* @param key - raw object key or assignment name.
* @returns whether the value under this key must be redacted.
*/
export function keyLooksSecret(key: string): boolean {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '')
if (normalized.length === 0) return false
if (KEY_SUBSTRING_INDICATORS.some(indicator => normalized.includes(indicator))) return true
if (KEY_SUFFIX_INDICATORS.some(indicator => normalized.endsWith(indicator))) return true
return KEY_EXACT_INDICATORS.includes(normalized)
}
/** Shannon entropy in bits per character. */
function shannonEntropy(value: string): number {
const counts = new Map<string, number>()
for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1)
let entropy = 0
for (const count of counts.values()) {
const probability = count / value.length
entropy -= probability * Math.log2(probability)
}
return entropy
}
/** Opaque-token character set (base64/base64url plus common token punctuation). */
const OPAQUE_TOKEN = /^[A-Za-z0-9+/=_.-]+$/
/** Version-like leader kept visible (dependency pins, semver). */
const VERSION_LIKE = /^v?\d+(?:\.\d+)+/
/**
* Conservative secret detector and redactor for telemetry content.
* Detection is a pure function of the input; construction only fixes tunables.
*/
export class SecretRedactor {
readonly #placeholder: string
readonly #minTokenLength: number
readonly #entropyThreshold: number
/** @param options - placeholder text and heuristic thresholds. */
constructor(options: SecretRedactorOptions = {}) {
this.#placeholder = options.placeholder ?? DEFAULT_REDACTION_PLACEHOLDER
this.#minTokenLength = options.minTokenLength ?? DEFAULT_MIN_TOKEN_LENGTH
this.#entropyThreshold = options.entropyThreshold ?? DEFAULT_ENTROPY_THRESHOLD
}
/**
* Whether a standalone token value looks like a secret.
* @param value - candidate token, already trimmed of surrounding quotes.
* @returns whether the value should be redacted on its own merits.
*/
isSecretValue(value: string): boolean {
if (KNOWN_SECRET_PATTERNS.some(pattern => pattern.test(value))) return true
if (value.length < this.#minTokenLength) return false
if (!OPAQUE_TOKEN.test(value)) return false
// Git SHAs and integrity digests are hex and public — never a secret we hide.
if (/^[0-9a-fA-F]+$/.test(value)) return false
if (VERSION_LIKE.test(value)) return false
const classes = (/[a-z]/.test(value) ? 1 : 0) + (/[A-Z]/.test(value) ? 1 : 0) + (/[0-9]/.test(value) ? 1 : 0)
return classes >= 3 || shannonEntropy(value) >= this.#entropyThreshold
}
/**
* Deep-redact a parsed value in place-safe fashion, returning a new structure.
* A secret-named key redacts its string value outright; every other string is
* judged on its own shape. Non-string leaves pass through untouched.
* @param value - parsed JSON-like value (object, array, or primitive).
* @returns a structurally identical value with secret strings replaced.
*/
redactValue<T>(value: T): T {
return this.#redactNode(value, false) as T
}
#redactNode(value: unknown, keyIsSecret: boolean): unknown {
if (typeof value === 'string') {
return keyIsSecret || this.isSecretValue(value) ? this.#placeholder : value
}
if (Array.isArray(value)) return value.map(item => this.#redactNode(item, false))
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [key, this.#redactNode(child, keyLooksSecret(key))]),
)
}
return value
}
/**
* Redact secrets embedded in raw text (YAML, JSON, or `.env`-style content),
* preserving every line and key while replacing only secret-shaped values.
* @param text - raw file or message text.
* @returns text with detected secrets replaced by the placeholder.
*/
redactText(text: string): string {
let output = this.#redactPemBlocks(text)
output = this.#redactAssignments(output)
output = this.#redactUrlCredentials(output)
output = this.#redactBearerTokens(output)
return this.#redactStandaloneTokens(output)
}
#redactPemBlocks(text: string): string {
return text.replace(
/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/g,
this.#placeholder,
)
}
#redactAssignments(text: string): string {
// `key: value`, `key = value`, or `"key": "value"` across YAML/JSON/.env.
return text.replace(
/("?)([A-Za-z0-9_.-]+)\1(\s*[:=]\s*)(["']?)([^\n\r"']+)\4/g,
(match, keyQuote: string, key: string, separator: string, valueQuote: string, value: string) =>
keyLooksSecret(key) && value.trim().length > 0
? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${this.#placeholder}${valueQuote}`
: match,
)
}
#redactUrlCredentials(text: string): string {
// Redact only the password in `scheme://user:password@host`, keeping host visible.
return text.replace(
/([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi,
(_match, prefix: string, _password: string, at: string) => `${prefix}${this.#placeholder}${at}`,
)
}
#redactBearerTokens(text: string): string {
// The candidate must contain a digit: real bearer credentials are never
// letters-only, while prose like "bearer authentication" is.
return text.replace(
/(bearer\s+)((?=[a-z._-]*[0-9])[a-z0-9._-]{8,})/gi,
(_match, prefix: string) => `${prefix}${this.#placeholder}`,
)
}
#redactStandaloneTokens(text: string): string {
// `/` is excluded so package names, file paths, and URLs are never split or
// redacted; a secret containing `/` is still scrubbed piecewise.
return text.replace(/[A-Za-z0-9][A-Za-z0-9+=_.-]{7,}/g, token =>
this.isSecretValue(token) ? this.#placeholder : token)
}
}

View File

@@ -0,0 +1,100 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
ANONYMOUS_ID_FILE_NAME,
getOrCreateAnonymousId,
globalConfigDir,
} from '@deepseek-ai/dsh-telemetry'
const dirs: string[] = []
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-anon-'))
dirs.push(dir)
return dir
}
afterEach(async () => {
await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
describe('globalConfigDir', () => {
it('prefers an explicit DSH_CONFIG_HOME override', () => {
expect(globalConfigDir({ env: { DSH_CONFIG_HOME: '/custom/dsh' } })).toBe('/custom/dsh')
})
it('falls back to XDG_CONFIG_HOME under the harness namespace', () => {
expect(globalConfigDir({ env: { XDG_CONFIG_HOME: '/xdg' } })).toBe(join('/xdg', 'deepseek-harness'))
})
it('uses %APPDATA% on Windows', () => {
expect(globalConfigDir({ env: { APPDATA: 'C:/Users/x/AppData/Roaming' }, platform: 'win32' }))
.toBe(join('C:/Users/x/AppData/Roaming', 'deepseek-harness'))
})
it('falls back to ~/.config on Windows without APPDATA and on posix', () => {
const home = () => '/home/dev'
expect(globalConfigDir({ env: {}, platform: 'win32', homeDir: home }))
.toBe(join('/home/dev', '.config', 'deepseek-harness'))
expect(globalConfigDir({ env: {}, platform: 'linux', homeDir: home }))
.toBe(join('/home/dev', '.config', 'deepseek-harness'))
})
it('reads process.env by default', () => {
// No override supplied: the call must not throw and must return an absolute path.
expect(globalConfigDir()).toContain('deepseek-harness')
})
})
describe('getOrCreateAnonymousId', () => {
it('creates, persists, and returns a UUID on first use', async () => {
const dir = await tempDir()
const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
expect(id).toMatch(UUID)
const stored: unknown = JSON.parse(await readFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'utf8'))
expect(stored).toEqual({ anonymousId: id })
})
it('returns the same persisted id on subsequent calls', async () => {
const dir = await tempDir()
const first = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
const second = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
expect(second).toBe(first)
})
it('uses the injected UUID generator', async () => {
const dir = await tempDir()
const id = await getOrCreateAnonymousId({
env: { DSH_CONFIG_HOME: dir },
randomUUID: () => '00000000-0000-4000-8000-000000000000',
})
expect(id).toBe('00000000-0000-4000-8000-000000000000')
})
it('regenerates when the stored file is corrupt JSON', async () => {
const dir = await tempDir()
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'not json', 'utf8')
const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
expect(id).toMatch(UUID)
})
it('regenerates when the stored value is not a valid UUID or object', async () => {
const dir = await tempDir()
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), JSON.stringify({ anonymousId: 'nope' }), 'utf8')
expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID)
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), '123', 'utf8')
expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID)
})
it('returns a usable id even when persistence fails', async () => {
const dir = await tempDir()
// A regular file where a directory is expected makes mkdir/writeFile fail.
await writeFile(join(dir, 'blocker'), 'x', 'utf8')
const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: join(dir, 'blocker') } })
expect(id).toMatch(UUID)
})
})

View File

@@ -0,0 +1,131 @@
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { ConsentResolver, DEFAULT_TELEMETRY_PLUGIN_NAME, type ConsentDecision } from '@deepseek-ai/dsh-telemetry'
const dirs: string[] = []
async function projectDir(cordisYml?: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-consent-'))
dirs.push(dir)
if (cordisYml !== undefined) await writeFile(join(dir, 'cordis.yml'), cordisYml, 'utf8')
return dir
}
afterEach(async () => {
await Promise.all(dirs.splice(0).map(dir => import('node:fs/promises').then(fs => fs.rm(dir, { recursive: true, force: true }))))
})
const enabledYml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n`
describe('ConsentResolver environment opt-out', () => {
it('denies when DO_NOT_TRACK is set', async () => {
const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' } }).resolve(await projectDir(enabledYml))
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'do-not-track' })
})
it('denies when CI is set', async () => {
const decision = await new ConsentResolver({ env: { CI: 'true' } }).resolve(await projectDir(enabledYml))
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'ci' })
})
it('ignores falsy env values and continues to the file', async () => {
const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '0', CI: 'false' } })
.resolve(await projectDir(enabledYml))
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
})
it('can be told to ignore env opt-out signals', async () => {
const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' }, honorEnvOptOut: false })
.resolve(await projectDir(enabledYml))
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
})
it('reads process.env by default', async () => {
const saved = { CI: process.env.CI, DO_NOT_TRACK: process.env.DO_NOT_TRACK }
delete process.env.CI
delete process.env.DO_NOT_TRACK
try {
const decision = await new ConsentResolver().resolve(await projectDir(enabledYml))
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
} finally {
if (saved.CI !== undefined) process.env.CI = saved.CI
if (saved.DO_NOT_TRACK !== undefined) process.env.DO_NOT_TRACK = saved.DO_NOT_TRACK
}
})
})
describe('ConsentResolver cordis.yml state', () => {
const resolver = new ConsentResolver({ env: {} })
it('allows when the telemetry entry is enabled', async () => {
expect(await resolver.resolve(await projectDir(enabledYml)))
.toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
})
it('denies when the telemetry entry is disabled', async () => {
const yml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n disabled: true\n`
expect(await resolver.resolve(await projectDir(yml)))
.toEqual<ConsentDecision>({ allowed: false, reason: 'disabled' })
})
it('tolerates !!js expression tags while reading plain scalars', async () => {
const yml = [
'- id: telemetry',
` name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'`,
'- id: llm',
' name: \'@deepseek-ai/dsh-llm-deepseek\'',
' config:',
' apiKey: !!js process.env.DEEPSEEK_API_KEY',
'',
].join('\n')
expect(await resolver.resolve(await projectDir(yml)))
.toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
})
it('reports (allows) when cordis.yml has no telemetry entry', async () => {
const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n'
expect(await resolver.resolve(await projectDir(yml)))
.toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
})
it('can be told to deny when the entry is absent', async () => {
const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n'
const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: false }).resolve(await projectDir(yml))
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'absent' })
})
it('skips non-object sequence items and a non-sequence root, still reporting absent', async () => {
expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n name: y\n')))
.toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
expect(await resolver.resolve(await projectDir('root: not-a-sequence\n')))
.toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
})
it('honors a custom telemetry plugin name', async () => {
const yml = '- id: t\n name: \'my-consent-marker\'\n'
const decision = await new ConsentResolver({ env: {}, telemetryPluginName: 'my-consent-marker' })
.resolve(await projectDir(yml))
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
})
})
describe('ConsentResolver missing or unreadable cordis.yml', () => {
it('reports no-config and allows by default on first init', async () => {
expect(await new ConsentResolver({ env: {} }).resolve(await projectDir()))
.toEqual<ConsentDecision>({ allowed: true, reason: 'no-config' })
})
it('can deny on first init', async () => {
const decision = await new ConsentResolver({ env: {}, allowWhenNoConfig: false }).resolve(await projectDir())
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'no-config' })
})
it('denies with an unreadable reason when cordis.yml is not a regular file', async () => {
const dir = await projectDir()
await mkdir(join(dir, 'cordis.yml')) // a directory where the resolver expects a file
expect(await new ConsentResolver({ env: {} }).resolve(dir))
.toEqual<ConsentDecision>({ allowed: false, reason: 'unreadable' })
})
})

View File

@@ -0,0 +1,69 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { SecretRedactor, buildTelemetryPayload } from '@deepseek-ai/dsh-telemetry'
const dirs: string[] = []
async function projectDir(files: Record<string, string>): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-payload-'))
dirs.push(dir)
await Promise.all(Object.entries(files).map(([name, content]) => writeFile(join(dir, name), content, 'utf8')))
return dir
}
afterEach(async () => {
await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
describe('buildTelemetryPayload', () => {
it('carries lifecycle facts and redacted file content', async () => {
const dir = await projectDir({
'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n config:\n apiKey: sk-abcdefghij1234567890\n',
'package.json': '{ "name": "my-app", "config": { "token": "sk-abcdefghij1234567890" } }',
})
const payload = await buildTelemetryPayload({ command: 'build', durationMs: 42, success: true, projectDir: dir })
expect(payload.command).toBe('build')
expect(payload.durationMs).toBe(42)
expect(payload.success).toBe(true)
expect(payload.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek') // package name preserved
expect(payload.cordisYmlContent).not.toContain('sk-abcdefghij1234567890') // secret scrubbed
expect(payload.packageJsonContent).toContain('my-app')
expect(payload.packageJsonContent).not.toContain('sk-abcdefghij1234567890')
})
it('omits fields whose files do not exist', async () => {
const dir = await projectDir({ 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' })
const payload = await buildTelemetryPayload({ command: 'create', durationMs: 1, success: false, projectDir: dir })
expect(payload.cordisYmlContent).toBeDefined()
expect('packageJsonContent' in payload).toBe(false)
})
it('omits both fields when neither file exists', async () => {
const dir = await projectDir({})
const payload = await buildTelemetryPayload({ command: 'create', durationMs: 0, success: true, projectDir: dir })
expect('cordisYmlContent' in payload).toBe(false)
expect('packageJsonContent' in payload).toBe(false)
})
it('withholds package.json when cordis.yml is absent (not an SDK project)', async () => {
const dir = await projectDir({ 'package.json': '{ "name": "unrelated-repo" }' })
const payload = await buildTelemetryPayload({ command: 'build', durationMs: 3, success: false, projectDir: dir })
expect('cordisYmlContent' in payload).toBe(false)
expect('packageJsonContent' in payload).toBe(false)
})
it('uses a supplied redactor', async () => {
const dir = await projectDir({
'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n',
'package.json': '{ "password": "hunter2" }',
})
const redactor = new SecretRedactor({ placeholder: '<<hidden>>' })
const payload = await buildTelemetryPayload({
command: 'config', durationMs: 5, success: true, projectDir: dir, redactor,
})
expect(payload.packageJsonContent).toContain('<<hidden>>')
expect(payload.packageJsonContent).not.toContain('hunter2')
})
})

View File

@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest'
import {
DSH_TELEMETRY_ENDPOINT,
SecretRedactor,
TELEMETRY_SCHEMA_VERSION,
TelemetryReporter,
type AnonymousId,
type ConsentDecision,
type TelemetryPayload,
} from '@deepseek-ai/dsh-telemetry'
const ALLOW: ConsentDecision = { allowed: true, reason: 'enabled' }
const DENY: ConsentDecision = { allowed: false, reason: 'disabled' }
const anon = (value = 'anon-123'): (() => Promise<AnonymousId>) => async () => value as AnonymousId
function okResponse(): Response {
return { ok: true } as Response
}
describe('TelemetryReporter.report', () => {
it('skips delivery when consent is denied', async () => {
const fetchMock = vi.fn(async () => okResponse())
const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon() })
reporter.report({ command: 'build', durationMs: 1, success: true }, DENY)
await reporter.flush(50)
expect(fetchMock).not.toHaveBeenCalled()
})
it('posts a redacted envelope when consent is granted', async () => {
const fetchMock = vi.fn<typeof globalThis.fetch>(() => Promise.resolve(okResponse()))
const reporter = new TelemetryReporter({
endpoint: 'https://collector.test/telemetry',
fetch: fetchMock,
anonymousId: anon('anon-xyz'),
redactor: new SecretRedactor(),
now: () => 0,
timeoutMs: 100,
})
const payload: TelemetryPayload = {
command: 'config',
durationMs: 7,
success: true,
cordisYmlContent: 'apiKey: sk-abcdefghij1234567890\nname: \'@deepseek-ai/dsh-llm-deepseek\'\n',
packageJsonContent: '{ "name": "app" }',
}
reporter.report(payload, ALLOW)
await reporter.flush(50)
expect(fetchMock).toHaveBeenCalledTimes(1)
const call = fetchMock.mock.calls[0]!
expect(call[0]).toBe('https://collector.test/telemetry')
const init = call[1]!
expect(init.method).toBe('POST')
const body = JSON.parse(init.body as string) as Record<string, unknown>
expect(body.schemaVersion).toBe(TELEMETRY_SCHEMA_VERSION)
expect(body.anonymousId).toBe('anon-xyz')
expect(body.sentAt).toBe('1970-01-01T00:00:00.000Z')
expect(body.command).toBe('config')
expect(body.cordisYmlContent).not.toContain('sk-abcdefghij1234567890')
expect(body.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek')
expect(body.packageJsonContent).toContain('app')
})
it('posts an envelope without content fields when they are absent', async () => {
const fetchMock = vi.fn<typeof globalThis.fetch>(() => Promise.resolve(okResponse()))
const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), now: () => 0, timeoutMs: 100 })
reporter.report({ command: 'start', durationMs: 2, success: true }, ALLOW)
await reporter.flush(50)
const body = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string) as Record<string, unknown>
expect('cordisYmlContent' in body).toBe(false)
expect('packageJsonContent' in body).toBe(false)
})
it('swallows a non-OK HTTP status', async () => {
const fetchMock = vi.fn(async () => ({ ok: false, status: 503 } as Response))
const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
reporter.report({ command: 'dev', durationMs: 3, success: true }, ALLOW)
await expect(reporter.flush(50)).resolves.toBeUndefined()
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('swallows a transport failure', async () => {
const fetchMock = vi.fn(async () => { throw new Error('network down') })
const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW)
await expect(reporter.flush(50)).resolves.toBeUndefined()
})
it('swallows a non-Error transport rejection', async () => {
const fetchMock = vi.fn(async () => { throw 'boom' })
const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW)
await expect(reporter.flush(50)).resolves.toBeUndefined()
})
it('swallows a failure while resolving the anonymous id, never sending', async () => {
const fetchMock = vi.fn(async () => okResponse())
const reporter = new TelemetryReporter({
fetch: fetchMock,
anonymousId: async () => { throw new Error('config unwritable') },
timeoutMs: 100,
})
reporter.report({ command: 'build', durationMs: 1, success: true }, ALLOW)
await reporter.flush(50)
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('TelemetryReporter.flush', () => {
it('returns immediately when nothing is in flight', async () => {
const reporter = new TelemetryReporter({ fetch: vi.fn(async () => okResponse()), anonymousId: anon() })
await expect(reporter.flush()).resolves.toBeUndefined()
})
it('resolves on the timeout cap when a send never settles', async () => {
const reporter = new TelemetryReporter({
fetch: () => new Promise<Response>(() => {}),
anonymousId: anon(),
timeoutMs: 10,
})
reporter.report({ command: 'start', durationMs: 1, success: true }, ALLOW)
const started = Date.now()
await reporter.flush(15)
expect(Date.now() - started).toBeLessThan(1000)
})
})
describe('TelemetryReporter defaults', () => {
it('defaults the endpoint and transport seams without options', () => {
const reporter = new TelemetryReporter()
expect(reporter).toBeInstanceOf(TelemetryReporter)
expect(DSH_TELEMETRY_ENDPOINT).toContain('.invalid')
})
})

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_ENTROPY_THRESHOLD,
DEFAULT_MIN_TOKEN_LENGTH,
DEFAULT_REDACTION_PLACEHOLDER,
SecretRedactor,
keyLooksSecret,
} from '@deepseek-ai/dsh-telemetry'
const REDACTED = DEFAULT_REDACTION_PLACEHOLDER
describe('exported defaults', () => {
it('expose the documented tunable defaults', () => {
expect(DEFAULT_REDACTION_PLACEHOLDER).toBe('[REDACTED]')
expect(DEFAULT_MIN_TOKEN_LENGTH).toBe(24)
expect(DEFAULT_ENTROPY_THRESHOLD).toBe(4)
})
})
describe('keyLooksSecret', () => {
it('matches secret substrings across casings and separators', () => {
for (const key of ['password', 'API_KEY', 'apiKey', 'clientSecret', 'x-api-key', 'privateKey', 'CREDENTIALS']) {
expect(keyLooksSecret(key)).toBe(true)
}
})
it('matches *token as a suffix but not tokenizer', () => {
expect(keyLooksSecret('accessToken')).toBe(true)
expect(keyLooksSecret('token')).toBe(true)
expect(keyLooksSecret('tokenizer')).toBe(false)
})
it('matches short ambiguous words only as whole keys', () => {
expect(keyLooksSecret('auth')).toBe(true)
expect(keyLooksSecret('authorization')).toBe(true)
expect(keyLooksSecret('cookie')).toBe(true)
expect(keyLooksSecret('author')).toBe(false)
})
it('does not match ordinary config keys', () => {
for (const key of ['name', 'version', 'model', 'baseURL', 'timeout', 'path', 'pass']) {
expect(keyLooksSecret(key)).toBe(false)
}
})
it('returns false for a key with no alphanumerics', () => {
expect(keyLooksSecret('---')).toBe(false)
})
})
describe('SecretRedactor.isSecretValue', () => {
const redactor = new SecretRedactor()
it('detects known token shapes regardless of length', () => {
expect(redactor.isSecretValue('sk-abcdefghij1234567890')).toBe(true)
expect(redactor.isSecretValue('sk-ant-abcdefghij1234567890')).toBe(true)
expect(redactor.isSecretValue('ghp_abcdefghijklmnop1234')).toBe(true)
expect(redactor.isSecretValue('github_pat_abcdefghijklmnopqrst')).toBe(true)
expect(redactor.isSecretValue('xoxb-abcdefghij-klmno')).toBe(true)
expect(redactor.isSecretValue('AKIA1234567890ABCDEF')).toBe(true)
expect(redactor.isSecretValue(`AIza${'a'.repeat(35)}`)).toBe(true)
expect(redactor.isSecretValue('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop')).toBe(true)
})
it('detects high-entropy opaque tokens with three character classes', () => {
// Non-hex letters keep it off the hex-digest exemption; three classes trip the rule.
expect(redactor.isSecretValue('zX9zX9zX9zX9zX9zX9zX9zX9')).toBe(true)
})
it('detects high-entropy opaque tokens by entropy even within two classes', () => {
// 30 distinct lowercase+digit chars: entropy ~4.9, only two classes.
const token = 'abcdefghijklmnopqrstuvwxyz0123'
expect(token.length).toBeGreaterThanOrEqual(DEFAULT_MIN_TOKEN_LENGTH)
expect(redactor.isSecretValue(token)).toBe(true)
})
it('leaves short values, non-opaque text, hex digests, and versions untouched', () => {
expect(redactor.isSecretValue('deepseek-chat')).toBe(false) // short
expect(redactor.isSecretValue('a token with spaces here!!')).toBe(false) // not opaque
expect(redactor.isSecretValue('a'.repeat(40))).toBe(false) // low entropy, one class
expect(redactor.isSecretValue('abcdef0123456789abcdef0123456789abcdef01')).toBe(false) // 40-hex git SHA
expect(redactor.isSecretValue('1.2.3.4.5.6.7.8.9.10.11.12')).toBe(false) // version-like
expect(redactor.isSecretValue('ZXQPZXQPZXQPZXQPZXQPZXQP')).toBe(false) // uppercase only, low entropy
})
it('honors a custom entropy threshold', () => {
const strict = new SecretRedactor({ entropyThreshold: 100 })
// Two-class token can no longer trip the entropy branch under an impossible threshold.
expect(strict.isSecretValue('abcdefghijklmnopqrstuvwxyz0123')).toBe(false)
})
})
describe('SecretRedactor.redactValue', () => {
const redactor = new SecretRedactor()
it('redacts secret-keyed strings and secret-shaped strings, keeping structure', () => {
const result = redactor.redactValue({
apiKey: 'short-not-shaped',
name: 'my-package',
token: 'sk-abcdefghij1234567890',
count: 3,
enabled: true,
missing: null,
nested: { password: 'p', note: 'plain text value' },
list: ['harmless', 'sk-abcdefghij1234567890'],
})
expect(result).toEqual({
apiKey: REDACTED, // redacted by key even though the value is not secret-shaped
name: 'my-package',
token: REDACTED,
count: 3,
enabled: true,
missing: null,
nested: { password: REDACTED, note: 'plain text value' },
list: ['harmless', REDACTED],
})
})
it('redacts a top-level secret string and passes through primitives', () => {
expect(redactor.redactValue('sk-abcdefghij1234567890')).toBe(REDACTED)
expect(redactor.redactValue('plain')).toBe('plain')
expect(redactor.redactValue(42)).toBe(42)
expect(redactor.redactValue(null)).toBeNull()
})
})
describe('SecretRedactor.redactText', () => {
const redactor = new SecretRedactor()
it('redacts PEM private key blocks', () => {
const text = '-----BEGIN RSA PRIVATE KEY-----\nMIIabc\ndef==\n-----END RSA PRIVATE KEY-----'
expect(redactor.redactText(text)).toBe(REDACTED)
})
it('redacts secret-keyed assignments across YAML, JSON, and .env', () => {
expect(redactor.redactText('password: hunter2')).toBe(`password: ${REDACTED}`)
expect(redactor.redactText('apiKey: "sk-abcdefghij1234567890"')).toBe(`apiKey: "${REDACTED}"`)
expect(redactor.redactText('"token": "abcdefgh"')).toBe(`"token": "${REDACTED}"`)
expect(redactor.redactText('API_KEY=sk-abcdefghij1234567890')).toBe(`API_KEY=${REDACTED}`)
})
it('keeps non-secret assignments and whitespace-only secret values intact', () => {
expect(redactor.redactText('model: deepseek-chat')).toBe('model: deepseek-chat')
expect(redactor.redactText('password: \n')).toBe('password: \n')
})
it('redacts only the password in URL credentials, keeping the host', () => {
expect(redactor.redactText('url: https://user:s3cretPass@api.deepseek.com/v1'))
.toBe(`url: https://user:${REDACTED}@api.deepseek.com/v1`)
})
it('redacts bearer tokens embedded in free text', () => {
expect(redactor.redactText('sending Bearer abcdefgh12345678 now'))
.toBe(`sending Bearer ${REDACTED} now`)
})
it('keeps letters-only prose after the word bearer intact', () => {
expect(redactor.redactText('uses bearer authentication for requests'))
.toBe('uses bearer authentication for requests')
expect(redactor.redactText('"description": "bearer token-helper middleware"'))
.toBe('"description": "bearer token-helper middleware"')
})
it('redacts standalone secret-shaped tokens while keeping package names and paths', () => {
expect(redactor.redactText('key sk-abcdefghij1234567890 end'))
.toBe(`key ${REDACTED} end`)
expect(redactor.redactText('name: @deepseek-ai/dsh-telemetry')).toBe('name: @deepseek-ai/dsh-telemetry')
expect(redactor.redactText('path: ./plugins/local-plugin/src/index.ts'))
.toBe('path: ./plugins/local-plugin/src/index.ts')
})
it('is idempotent on already-redacted text', () => {
const once = redactor.redactText('password: hunter2')
expect(redactor.redactText(once)).toBe(once)
})
})

View File

@@ -0,0 +1,13 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{ "path": "../../util/brand" }
]
}

View File

@@ -5,9 +5,9 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie
Four layers, importable separately:
- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). Startup failures preserve captured agent stderr in the rejected diagnostic.
- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the expected-output and purity checks, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). Startup failures preserve captured agent stderr in the rejected diagnostic.
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.golden.md` plus `tool-schemas.golden.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
@@ -36,9 +36,9 @@ defineAcpSnapshotSuite({
})
```
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.golden.md` and the corresponding full tool-schema sequence in generated `tool-schemas.golden.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences.
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences.
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log goldens, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript).

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-acp-snapshot",
"description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, golden normalizers, and suite factory",
"description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, expected-output normalizers, and suite factory",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -6,7 +6,7 @@
* It boots the REAL agent bin subprocess via the cordis Loader (so the
* export-shape bug class stays guarded — see docs/postmortem/0001), drives it
* over real ACP JSON-RPC stdio with a deterministic input script, tees raw
* stdout (for the golden + a purity check) into an SDK `ClientSideConnection`,
* stdout (for the expected-output and purity checks) into an SDK `ClientSideConnection`,
* and — in record mode — harvests the persisted session JSONL after a graceful
* shutdown flush. The pure normalizers in ./normalize.ts turn the captured
* stdout frames and the session-log events into stable, snapshot-able text.
@@ -162,7 +162,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
const cwd = await mkdtemp(join(tmpdir(), 'acp-snap-cwd-'))
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-'))
// Fixed path length: spill-policy budgets the preview against the REAL path
// before stdout normalization, so tmpdir() length differences churn goldens.
// before stdout normalization, so tmpdir() length differences churn expected outputs.
const spillRoot = '/tmp/dsh-acp-snapshot-spill'
// Everything past the temp-dir creation is followed by failure-safe cleanup,
// so a failure in workspace seeding, spawn, or any step never leaks resources.
@@ -171,7 +171,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
let sessionLogs: HarvestedLog[] = []
const outcome = await (async (): Promise<RunResult> => {
// Seed the workspace if the scenario ships one (a file the agent reads/edits).
// Copied into the temp cwd so the agent's bash tools see it; the goldens
// Copied into the temp cwd so the agent's bash tools see it; the expected outputs
// normalize the cwd, so the seeded paths stay stable across runs.
if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) {
await cp(opts.workspaceDir, cwd, { recursive: true })

View File

@@ -2,7 +2,7 @@
* ACP snapshot suite kit — the shared machinery behind the keyless snapshot
* tier (`pnpm run test:snapshot`). Four layers, composable per example: the
* shared subprocess/client launcher ({@link launchAcpTestAgent}), the scripted
* scenario harness ({@link runScenario}), the pure golden normalizers
* scenario harness ({@link runScenario}), the pure expected-output normalizers
* ({@link normalizeStdout} / {@link normalizeSessionLog} /
* {@link scrubRequestHeaders} / {@link scrubSystemPrompts}), and the suite
* factory ({@link defineAcpSnapshotSuite}) that registers a scenario table as a

View File

@@ -60,7 +60,7 @@ function scrubValue(value: unknown, ctx: NormalizeContext): unknown {
}
/**
* Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a stable golden
* Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a stable expected output
* in the same shape as the wire: one compact JSON frame per line (NDJSON), with the JSON-RPC
* `id` rewritten to a per-transcript sequence (1, 2, 3, …) and all volatile strings scrubbed.
* Invalid JSON throws, doubling as a protocol-stdout purity check.
@@ -72,7 +72,7 @@ function scrubValue(value: unknown, ctx: NormalizeContext): unknown {
export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): string {
const lines = rawStdout.split('\n').filter(line => line.trim().length > 0)
// Map each distinct JSON-RPC id (request/response correlate by id) to a stable
// sequence number, in first-seen order, so id churn doesn't perturb the golden.
// sequence number, in first-seen order, so id churn doesn't perturb the expected output.
const idSeq = new Map<string, number>()
const stableId = (id: unknown): number => {
const key = JSON.stringify(id)
@@ -91,7 +91,7 @@ export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): strin
}
/**
* Normalize a session JSONL log into a stable golden: the header line's
* Normalize a session JSONL log into a stable expected output: the header line's
* volatile fields (`createdAt`, `id`, `cwd`) and every event's `time` are
* zeroed/scrubbed, all volatile strings scrubbed, and `seq` is LEFT INTACT
* (deterministic by contract). Output is JSONL in the same shape as the input —
@@ -112,7 +112,7 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri
// Event line: zero the epoch-ms timestamp; keep seq (deterministic).
record.time = 0
// A hook/result carries the hook's wall-clock runtime (`data.durationMs`),
// which is run-to-run noise like `time` — zero it so the golden reflects
// which is run-to-run noise like `time` — zero it so the expected output reflects
// the hook's decision/exit, not how long the shell took.
if (record.type === 'hook/result' && record.data !== null && typeof record.data === 'object') {
const data = record.data as Record<string, unknown>

View File

@@ -30,10 +30,10 @@ import {
} from './normalize.ts'
/** The readable system-prompt snapshot beside each header-pinning fixture. */
const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.golden.md'
const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md'
/** The structured tool-schema snapshot beside each header-pinning fixture. */
const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.golden.json'
const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.expected.json'
/** Stable session-log token standing in for the sidecar's initial schemas. */
const TOOLS_TOKEN = '{{tools}}'
@@ -41,7 +41,7 @@ const TOOLS_TOKEN = '{{tools}}'
/** A snapshot scenario and how its fixtures are produced. */
export interface Scenario {
name: string
/** Whether the scenario drives at least one model turn (so a JSONL golden applies). */
/** Whether the scenario drives at least one model turn (so a JSONL expected output applies). */
hasModelTurn: boolean
/**
* Whether the run persists a comparable session log to diff against the
@@ -112,8 +112,8 @@ export interface SnapshotSuiteOptions {
scenarios: Scenario[]
/**
* `replay` (keyless, the default tier), `record` (live API; re-records the
* `recorded` scenarios' fixtures and refreshes the Vitest goldens under
* `--update`), or `refresh` (keyless replay that rewrites stdout goldens and
* `recorded` scenarios' fixtures and refreshes the Vitest expected outputs under
* `--update`), or `refresh` (keyless replay that rewrites stdout expected outputs and
* comparable session fixtures from the replay run). The caller derives this
* from `$DSH_SNAPSHOT` — env reading stays outside this library.
*/
@@ -427,7 +427,7 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement
}
/**
* Register the suite: one test per scenario (the golden/log compares and
* Register the suite: one test per scenario (the expected-output and log comparisons and
* the header-uniformity guard) plus the fixture guard block (no orphan
* scenario dirs, required files present, exactly one pin per header class,
* pinning fixtures well-formed, every JSONL prompt-scrubbed, non-pinning
@@ -467,7 +467,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
for (const scenario of scenarios) {
// In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones
// (sidecar-driven errors/cancel) are never re-recorded.
it.skipIf(RECORDING && !scenario.recorded)(`snapshot: ${scenario.name} matches the goldens`, async ({ expect }) => {
it.skipIf(RECORDING && !scenario.recorded)(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => {
const dir = join(snapshotsDir, scenario.name)
const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript
const overrideFile = join(dir, 'replay.override.json')
@@ -573,9 +573,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
const stdout = normalizeStdout(result.rawStdout, ctx)
if (REFRESHING) {
await writeFile(join(dir, 'stdout.golden.jsonl'), stdout)
await writeFile(join(dir, 'stdout.expected.jsonl'), stdout)
}
await expect(stdout).toMatchFileSnapshot(join(dir, 'stdout.golden.jsonl'))
await expect(stdout).toMatchFileSnapshot(join(dir, 'stdout.expected.jsonl'))
// A model turn always produces a log worth comparing; a hook scenario can
// produce one without a model turn (a `rejected` turn carrying `hook/*`).
@@ -651,7 +651,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
describe('snapshot fixtures', () => {
it('every scenario directory is registered (no orphans)', async () => {
// toMatchFileSnapshot does not prune orphaned golden/fixture files, so a
// toMatchFileSnapshot does not prune orphaned expected-output or fixture files, so a
// renamed/removed scenario could leave a stale dir that nothing exercises.
// Fail loud on any snapshots/<dir> not present in the scenario table.
const entries = await readdir(snapshotsDir, { withFileTypes: true })
@@ -665,7 +665,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
for (const { name, overridden, pinsHeader } of scenarios) {
const dir = join(snapshotsDir, name)
expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true)
expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true)
expect(existsSync(join(dir, 'stdout.expected.jsonl')), `${name}/stdout.expected.jsonl`).toBe(true)
expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true)
expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json presence must match \`overridden\``)
.toBe(overridden === true)

View File

@@ -24,7 +24,7 @@ import {
/**
* Unit tests for the suite factory, by running it: two synthetic suites over the scripted fake
* ACP bin (./fixtures/fake-acp-agent.ts) register real describe/it trees at collection time,
* so every factory path — golden and log compares, the per-suite header pin and its uniformity
* so every factory path — expected-output and log comparisons, the per-suite header pin and its uniformity
* guard, record-mode fixture write-back, skip semantics, and the fixture guard block —
* executes as an ordinary green test.
*
@@ -61,7 +61,7 @@ const RECORD_SCENARIOS: Scenario[] = [
// Record/refresh modes mutate their snapshots dir, so run them on throwaway
// copies — except record's documented bootstrap knob, which regenerates the
// committed record fixtures/goldens in place.
// committed record fixtures and expected outputs in place.
const BOOTSTRAP = process.env.ACP_SNAPSHOT_SPEC_BOOTSTRAP === '1'
const recordDir = BOOTSTRAP ? RECORD_SRC : mkdtempSync(join(tmpdir(), 'acp-snap-record-suite-'))
if (!BOOTSTRAP) {
@@ -80,9 +80,9 @@ afterAll(async () => {
})
function staleRefreshFixtures(dir: string): void {
writeFileSync(join(dir, 'plain-turn', 'stdout.golden.jsonl'), 'stale stdout\n')
writeFileSync(join(dir, 'pin-turn', 'system-prompt.golden.md'), 'STALE PROMPT\n')
writeFileSync(join(dir, 'pin-turn', 'tool-schemas.golden.json'), '{"initial":[{"name":"stale"}],"changes":[]}\n')
writeFileSync(join(dir, 'plain-turn', 'stdout.expected.jsonl'), 'stale stdout\n')
writeFileSync(join(dir, 'pin-turn', 'system-prompt.expected.md'), 'STALE PROMPT\n')
writeFileSync(join(dir, 'pin-turn', 'tool-schemas.expected.json'), '{"initial":[{"name":"stale"}],"changes":[]}\n')
const plainBehaviorFile = join(dir, 'plain-turn', 'behavior.json')
const plainBehavior = JSON.parse(readFileSync(plainBehaviorFile, 'utf8')) as Record<string, unknown>
@@ -117,7 +117,7 @@ describe('defineAcpSnapshotSuite: refresh mode', () => {
describe('defineAcpSnapshotSuite: refresh write-back', () => {
it('rewrites stdout and comparable logs from a replay-mode child run', () => {
const stdout = readFileSync(join(refreshDir, 'plain-turn', 'stdout.golden.jsonl'), 'utf8')
const stdout = readFileSync(join(refreshDir, 'plain-turn', 'stdout.expected.jsonl'), 'utf8')
expect(stdout).not.toContain('stale stdout')
expect(stdout).toContain('env:{\\"mode\\":\\"replay\\"')
expect(stdout).not.toContain('\\"mode\\":\\"refresh\\"')
@@ -130,7 +130,7 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => {
expect(authored).toContain('"error":"model exploded"')
expect(authored).not.toContain('"error":"stale"')
expect(readFileSync(join(refreshDir, 'pin-turn', 'system-prompt.golden.md'), 'utf8')).toBe([
expect(readFileSync(join(refreshDir, 'pin-turn', 'system-prompt.expected.md'), 'utf8')).toBe([
'SYS PROMPT',
'',
'<!-- request/header change 1 -->',
@@ -140,7 +140,7 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => {
'NEW PROMPT LINE',
'',
].join('\n'))
const schemas = readFileSync(join(refreshDir, 'pin-turn', 'tool-schemas.golden.json'), 'utf8')
const schemas = readFileSync(join(refreshDir, 'pin-turn', 'tool-schemas.expected.json'), 'utf8')
expect(schemas).toContain('"description": "D1"')
expect(schemas).not.toContain('"name":"stale"')
})
@@ -195,7 +195,7 @@ describe('defineAcpSnapshotSuite: registration contract', () => {
describe('sessionFixtureNames', () => {
it('orders the primary and contiguous child fixtures while ignoring other files', () => {
expect(sessionFixtureNames([
'stdout.golden.jsonl',
'stdout.expected.jsonl',
'session.2.jsonl',
'session.jsonl',
'session.1.jsonl',

View File

@@ -12,14 +12,14 @@ sequenceDiagram
participant Workspace
participant Replay as llm-replay adapter
participant ACP as acp-agent subprocess
participant Golden as stdout golden
participant Expected as stdout expected output
Recorder->>Fixture: session.jsonl + workspace inputs
Fixture->>Workspace: seed files and hook configs
Fixture->>Replay: recorded StreamChunk script
Replay->>ACP: deterministic <code>llm/stream</code> chunks
ACP->>Workspace: bash, fs, and hook side effects
ACP->>Golden: normalized sessionUpdate stream
Golden-->>ACP: diff must be empty
ACP->>Expected: normalized sessionUpdate stream
Expected-->>ACP: diff must be empty
```
The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.

View File

@@ -8,7 +8,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc
## Config
There are no `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`.
`maxTokensAsSuccess` defaults to `false`. Set it to `true` for evaluation hosts that distinguish an accepted, token-limited agent result from an infrastructure failure. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`.
## stdout is the protocol

View File

@@ -22,8 +22,10 @@ export const name = 'jsonrpc'
// Only the agent factory is required; initialize reads the optional LLM seam with ctx.get().
export const inject = ['agents']
/** Runtime-only test seams; no field is configurable from `cordis.yml`. */
/** JSON-RPC deployment config plus runtime-only test seams. */
export interface JsonRpcConfig {
/** Report max-token turn/subagent termination as a successful SDK result. */
maxTokensAsSuccess?: boolean
/** Transport input override; production uses `process.stdin`. */
input?: Readable
/** Transport output override; production uses `process.stdout`. */
@@ -32,7 +34,9 @@ export interface JsonRpcConfig {
exit?: (code: number) => void
}
export const Config: Schema<JsonRpcConfig> = Schema.object({})
export const Config: Schema<JsonRpcConfig> = Schema.object({
maxTokensAsSuccess: Schema.boolean().default(false),
})
/**
* Serve SDK requests over the configured streams. Effect disposal shuts down
@@ -41,6 +45,8 @@ export const Config: Schema<JsonRpcConfig> = Schema.object({})
* owns root-context disposal for EOF and signals.
*/
export function apply(ctx: Context, config: JsonRpcConfig): void {
// Cordis applies the schema default before invoking the plugin.
const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean }
// The later transport callback must dispose this plugin's fiber, not its ambient context.
const fiber = ctx.fiber
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
@@ -51,7 +57,9 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
const exit = config.exit ?? ((code: number): void => { process.exit(code) })
const transport = new JsonRpcLineTransport(input, output)
const server = new HarnessSdkServer(ctx, transport)
const server = new HarnessSdkServer(ctx, transport, {
maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
})
// Share one exit task and attempt flush and disposal independently before exiting.
let exitTask: Promise<void> | undefined

View File

@@ -57,7 +57,22 @@ function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
return carrierKeyOf(carrier) as Agent
}
/** SDK server whose subscriptions and created agents live until {@link shutdown}. */
/** Deployment-specific status mapping for SDK turn and subagent outcomes. */
export interface HarnessSdkServerOptions {
/** Report max-token termination as an accepted result instead of an infrastructure error. */
maxTokensAsSuccess?: boolean
}
function successStatus(reason: string, options: HarnessSdkServerOptions): 'ok' | 'error' {
if (reason === 'completed') return 'ok'
return reason === 'max-tokens' && options.maxTokensAsSuccess === true ? 'ok' : 'error'
}
/**
* SDK server over one booted harness context and transport peer. Construction
* subscribes to session, agent, and subagent lifecycle events until shutdown;
* reinitialization is unsupported.
*/
export class HarnessSdkServer {
private cwd = process.cwd()
private provider = 'deepseek'
@@ -72,7 +87,9 @@ export class HarnessSdkServer {
constructor(
private readonly ctx: Context,
private readonly transport: JsonRpcTransportPeer,
private readonly options: HarnessSdkServerOptions = {},
) {
const serverOptions = this.options
this.disposers.push(ctx.on('session/event', (session, event) => {
if (event.type === 'turn/end') {
const rec = this.sessions.get(String(session.id))
@@ -99,7 +116,7 @@ export class HarnessSdkServer {
agentId: String(info.id),
parentSessionId: String(parent.session.id),
childSessionId: String(info.id),
status: info.stopReason === 'completed' ? 'ok' : 'error',
status: successStatus(info.stopReason, serverOptions),
stopReason: info.stopReason,
...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
})
@@ -233,7 +250,7 @@ export class HarnessSdkServer {
private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' {
if (!reason) return 'error'
return reason.kind === 'completed' ? 'ok' : 'error'
return successStatus(reason.kind, this.options)
}
private hasAdapterFor(provider: string): boolean {

View File

@@ -656,7 +656,7 @@ describe('HarnessSdkServer', () => {
signal: new AbortController().signal,
})
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const server = new HarnessSdkServer(ctx, transport, { maxTokensAsSuccess: true })
missedStartResult.resolve({ output: [], stopReason: 'max-tokens' })
await missedStartRun.result
@@ -692,7 +692,7 @@ describe('HarnessSdkServer', () => {
agentId: 'fallback-child-session',
parentSessionId: 'fallback-parent',
childSessionId: 'fallback-child-session',
status: 'error',
status: 'ok',
stopReason: 'max-tokens',
lastAssistantMessage: [],
},
@@ -782,6 +782,24 @@ describe('HarnessSdkServer', () => {
}
})
it('can report max-token turn termination as an accepted evaluation result', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-max-tokens-success-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport(), { maxTokensAsSuccess: true }) as unknown as {
finishedStatus(reason: unknown): 'ok' | 'error'
shutdown(): Promise<Record<string, never>>
}
expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('ok')
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 {

View File

@@ -286,7 +286,7 @@ export class HeadlessTerminal implements Terminal {
return violations
}
/** Serialize terminal cells and metadata into a stable, reviewable golden. */
/** Serialize terminal cells and metadata into a stable, reviewable expected output. */
async snapshot(options: TerminalSnapshotOptions = {}): Promise<string> {
await this.flush()
const buffer = this.emulator.buffer.active

View File

@@ -52,7 +52,7 @@ async function checkpoint(
observedCheckpoints.add(name)
expect(terminal.themeViolations(), `${name} must remain theme-agnostic`).toEqual([])
const snapshot = await terminal.snapshot(options)
const path = join(SNAPSHOTS_DIR, `${name}.golden.txt`)
const path = join(SNAPSHOTS_DIR, `${name}.expected.txt`)
if (REFRESHING) {
await mkdir(SNAPSHOTS_DIR, { recursive: true })
await writeFile(path, snapshot)
@@ -493,7 +493,7 @@ describe('TUI terminal-state snapshots', () => {
afterAll(async () => {
expect([...observedCheckpoints].sort()).toEqual([...CHECKPOINTS].sort())
const files = (await readdir(SNAPSHOTS_DIR))
.filter(file => file.endsWith('.golden.txt'))
.filter(file => file.endsWith('.expected.txt'))
.sort()
expect(files).toEqual(CHECKPOINTS.map(name => `${name}.golden.txt`).sort())
expect(files).toEqual(CHECKPOINTS.map(name => `${name}.expected.txt`).sort())
})