Merge remote-tracking branch 'origin/rfc/loop' into codex/goal-domain

This commit is contained in:
Tianyi Cui
2026-07-19 22:49:25 +08:00
82 changed files with 3637 additions and 163 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

@@ -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

@@ -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 {