Merge remote-tracking branch 'origin/master' into codex/agent-session-jsonl-location

# Conflicts:
#	docs/module-graph.md
#	docs/tool-catalog.md
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	packages/bash/tool-bash/README.md
#	packages/bash/tool-bash/package.json
#	packages/bash/tool-bash/src/index.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
This commit is contained in:
Yichen Jiang
2026-07-11 22:57:27 +08:00
190 changed files with 12183 additions and 456 deletions
+64
View File
@@ -0,0 +1,64 @@
# User Approval
The user-approval seam of [dsh-user-approval](../../packages/ui/user-approval) answers one question: may this specific action proceed? It owns the shared request/outcome vocabulary, the `ctx.approval` dispatch service, the `approval/request` answerer waterfall, the log-only audit pair, and the per-session `ask`/`never` policy. UI channels such as [dsh-acp](../../packages/ui/acp) provide answerers; callers such as [dsh-tools](../../packages/core/tools) and [dsh-tool-bash](../../packages/bash/tool-bash) consume the closed outcome and fail closed unless it is `allowed-once`.
Source: [`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approval/src/index.ts)
## Identity and outcome
Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call, session, or agent ids.
```ts type-equiv
type ApprovalRequestId = Branded<'ApprovalRequestId'>
```
`ApprovalOutcome` is closed and fail-closed. `allowed-once` grants only the asked-about action; callers deny on `rejected`, `cancelled`, and `unavailable`. A missing, non-owning, throwing, or non-conforming answerer becomes `unavailable` rather than opening the gate.
```ts type-equiv
type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
```
## Per-session policy
`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override.
```ts type-equiv
type ApprovalPolicy = 'ask' | 'never'
```
The prompt section states the deterministic `never` behavior and records either policy with a source-owned marker. The pre-step narrator reads that marker from the logged request header after restart; it does not infer state from deployment persona prose. An idle ACP switch is held in the bridge until the next `turn/start`, because approval audit and policy events must remain turn-enclosed for durable replay.
## Approval request
`ApprovalRequest` identifies the agent and tool action closely enough to route and audit the question. It deliberately omits tool arguments: an answerer attaches the prompt to the already-streamed tool call through `callId` instead of rendering a second copy that could drift.
```ts type-equiv
interface ApprovalRequest {
/**
* The agent on whose behalf the question is asked. Routes the question (a
* UI answerer only answers for agents it owns) and receives the audit
* events on its session log.
*/
agent: Agent
/** The tool the question is about (presentation and audit). */
toolName: string
/**
* The exact tool call being decided, when the asker has one — lets a UI
* attach the prompt to the tool call it already streamed.
*/
callId?: CallId
/** The asker's human-readable explanation of WHY it is asking. */
reason?: string
/**
* Aborting withdraws the question: the request settles `'cancelled'`
* immediately and a late answer from a still-pending answerer is discarded.
*/
signal?: AbortSignal
}
```
## Dispatch and audit
`ctx.approval.request(req)` requires the requesting session to be inside an open turn. It appends `approval/asked`, obtains one outcome, appends the matching `approval/decided`, and resolves with that outcome. The `never` policy is enforced inside the service before waterfall dispatch, so even an answerer registered later with `prepend` cannot bypass it. Answerers return an outcome when they own the request or call `next()` to delegate; the first answer occupies the single decision slot.
The audit events are log-only and do not enter the model transcript. Model-visible behavior is the caller's derived tool result, while the request header records the prompt policy that the model actually saw. Service disposal removes its prompt section and pre-step narrator together; answerer listeners are independently effect-bound to their owning plugins.
+84 -1
View File
@@ -44,6 +44,20 @@ interface BashExecRequest {
* ownerless background start (a non-agent caller).
*/
owner?: OwnerToken | undefined
/**
* Explicit per-call sandbox-policy input, overriding the executor's
* configured default mode for THIS call. Never a silent default: a
* consumer sets it only from an explicit policy source — an
* `'allowed-once'` grant a human just issued through `ctx.approval` (the
* escalation flow in the sandbox RFC § Escalation, which outranks), or the
* session's standing override folded from its own `bash/sandbox-mode`
* events (the sandbox RFC § Per-session mode switching — the user's recorded per-session
* choice). A sandboxing executor confines THIS call under the given mode;
* a non-sandboxing executor carries the field and confines nothing (the
* tool layer stamps neither escalation nor overrides without a sandboxing
* executor — see {@link BashExecutor.sandboxMode}).
*/
sandboxMode?: SandboxMode | undefined
}
```
@@ -79,6 +93,16 @@ interface BashExecSpec {
* task. `start()` stores it; `run()` (foreground) ignores it.
*/
owner: OwnerToken | undefined
/**
* The sandbox mode this call executes under, REQUIRED-but-nullable for the
* same visibility reason as `owner`. A sandboxing executor's `resolve()`
* stamps the effective mode (the request's explicit override, else its
* configured default) so `run()`/`start()` read the spec, never the config;
* a non-sandboxing executor carries the request value through verbatim and
* ignores it (`undefined` under such an executor means what its README says:
* unconfined execution).
*/
sandboxMode: SandboxMode | undefined
}
```
@@ -106,6 +130,12 @@ interface BashRunResult {
timeoutMs: number
stdout: CollectedOutput
stderr: CollectedOutput
/**
* Sandbox facts, present iff a sandboxing executor ran the command — an
* unsandboxed executor (e.g. `dsh-bash-local`) never sets it. See
* {@link BashSandboxInfo} for the `denied` classification semantics.
*/
sandbox?: BashSandboxInfo
}
```
@@ -122,9 +152,52 @@ interface CollectedOutput {
}
```
## File sandbox: `BashSandboxInfo`
A sandbox-consuming executor (`dsh-bash-sandbox`) exposes its configured fallback through `BashExecutor.sandboxMode`. The tool layer folds each agent session's durable `bash/sandbox-mode` override, stamps the effective mode onto the request, states it in the per-agent prompt, and may replace it for one user-approved strictly wider call. The mode/enforcement vocabulary is owned and cataloged by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md), whose provider wraps the executor's argv; modes govern FILE effects only, not network or process visibility.
A sandboxed run always reports the facts it executed under on `BashRunResult.sandbox`: `denied` is the executor's conservative classification of a failure as sandbox-caused (a failed exit whose stderr carries a filesystem-permission signature — never a clean exit or a signal kill), read from the collected stderr tail; `enforcement` reports how completely the selected backend governs the mode's file effects (`SandboxEnforcement = 'full' | 'partial'` — `partial` when an older Landlock ABI governs only a subset of the requested accesses; absent under `danger-full-access`, where nothing is confined); `runnerFailed` marks the opposite of a denial — the sandbox RUNNER itself failed and the command never ran (stamped only on settled background tasks; a foreground run surfaces the same condition as the thrown `SANDBOX_UNAVAILABLE` error):
```ts type-equiv
interface BashSandboxInfo {
/** The mode the command actually ran under. */
mode: SandboxMode
/**
* True when the executor classifies this run's failure as the sandbox
* denying a file operation. The classification is CONSERVATIVE (a failed
* exit whose stderr carries a filesystem-permission signature) and reads
* the COLLECTED stderr — the bounded in-memory tail per
* {@link CollectedOutput} semantics, so a signature that survives only in a
* spill file is missed toward `denied: false`. A plain command failure
* keeps `denied: false` even under a sandboxed mode.
*/
denied: boolean
/**
* How completely the runner enforced `mode`'s file effects — see
* {@link SandboxEnforcement}. Absent exactly when `mode` is
* `danger-full-access`: nothing is confined, so there is no enforcement to
* report.
*/
enforcement?: SandboxEnforcement
/**
* True when the executor classifies this failure as the SANDBOX RUNNER
* itself failing (missing binary, refused profile, fail-closed refusal
* before exec) — the command NEVER RAN; this is a sandbox failure, not a
* task failure, and it outranks `denied` (a runner's own error text can
* contain denial words). Only ever stamped on settled BACKGROUND tasks: a
* foreground run surfaces the same condition as the thrown
* `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error
* channel; a settled task's facts are its only channel).
*/
runnerFailed?: boolean
}
```
One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (owned by the [sandbox seam](sandbox.md)) is what the `ctx.sandbox` provider throws — and the executor propagates — when a confined mode has no usable backend. A selected runner refusing its profile reaches the same fail-closed foreground error; a settled background task records `runnerFailed`. The model sees the current effective mode in the prompt, receives denial/runner facts in results, and can request a one-shot strictly wider retry through `sandbox_permissions` plus `justification`; `ctx.approval` must grant that exact call before anything executes. The complete policy and switching design is the [sandbox RFC](../rfc/implemented/feature/2026-07-06-sandbox.md).
## Background tasks: `BashTask`
A long-running command started with `start()` is tracked as a `BashTask`. `BashTaskStatus` is `'running' | 'completed' | 'killed'`; `done` resolves when the underlying process closes and never rejects.
A long-running command started with `start()` is tracked as a `BashTask`. `BashTaskStatus` is `'running' | 'completed' | 'killed'`; `done` resolves when the underlying process closes and never rejects. A sandboxing executor stamps `sandbox` once the task settles — classification runs against the settled task's collected stderr — so the field is absent while running and under an unsandboxed executor.
```ts type-equiv
interface BashTask {
@@ -137,6 +210,16 @@ interface BashTask {
signal: NodeJS.Signals | null
/** Resolves when the underlying process closes (never rejects). */
readonly done: Promise<void>
/**
* Sandbox facts for this task's execution, stamped by a sandboxing executor
* once the task settles and BEFORE completion listeners are notified — an
* `onTaskDone` consumer and a `done` awaiter both see it. Denial
* classification runs against the settled task's collected stderr, so the
* field cannot exist earlier: absent while the task is running and under an
* executor that does not sandbox. See {@link BashSandboxInfo} for the
* `denied` semantics.
*/
sandbox?: BashSandboxInfo
}
```
+3
View File
@@ -20,9 +20,12 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline |
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts |
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
| [sandbox.md](sandbox.md) | the process-confinement seam: file-effect modes, `SandboxPolicy`, `ConfinedArgv`, enforcement and fail-closed errors |
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
| [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading |
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebError` |
+82
View File
@@ -0,0 +1,82 @@
# Process Sandbox
The process-sandbox seam of [dsh-sandbox](../../packages/sandbox/sandbox) wraps a same-world subprocess argv in a file-effect policy without coupling consumers to a platform runner. [dsh-sandbox-local](../../packages/sandbox/sandbox-local) supplies the Linux bwrap/Landlock and macOS Seatbelt backends; [dsh-bash-sandbox](../../packages/bash/bash-sandbox) is the first consumer. Containers, microVMs, and remote execution are sibling implementations of whole capability seams, not providers of `ctx.sandbox`.
Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts)
## Modes and enforcement
`SandboxMode` governs filesystem effects only. `read-only` denies writes except the required `/dev/null` sink; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary.
```ts type-equiv
type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
```
Only the first two modes can be sent to a provider. A `danger-full-access` consumer spawns its original argv and does not call `ctx.sandbox`.
```ts type-equiv
type ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'>
```
Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction.
```ts type-equiv
type SandboxEnforcement = 'full' | 'partial'
```
## Per-call policy
The policy is fully resolved and carried per call. This permits concurrent consumers and one-shot escalated retries to ask the same provider for different boundaries without mutating provider state.
```ts type-equiv
interface SandboxPolicy {
/** The file-effect mode this execution runs under. */
mode: ConfinedSandboxMode
/** Absolute root directory `workspace-write` may write under. */
workspaceRoot: string
}
```
## Wrapped argv and classification dialects
`ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr dialects. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureSignatures` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure.
```ts type-equiv
interface ConfinedArgv {
/** The wrapped argv (runner, profile, separator, then the caller's argv). */
argv: string[]
/** How completely the selected backend enforces the policy's file effects. */
enforcement: SandboxEnforcement
/**
* The selected backend's denial DIALECT: the case-insensitive stderr
* substrings a file effect denied by THIS backend produces (EROFS text
* under bwrap's read-only binds, EACCES under Landlock, EPERM under
* Seatbelt). A consumer that infers denials from a failed run's stderr
* matches against exactly these rather than a cross-backend union — the
* union claims denials a given backend never produces.
*/
denialSignatures: readonly string[]
/**
* How the RUNNER ITSELF failing identifies itself: case-insensitive stderr
* substrings produced when the sandbox binary is missing, refuses its
* profile, or fails closed before exec'ing the command (`bwrap: `,
* `landlock-run: `, `sandbox-exec: ` — each covers both the runner's own
* error prefix and the shell's runner-not-found message). ORTHOGONAL to
* {@link denialSignatures}: a denial is the confined COMMAND being blocked
* (the sandbox working as designed); a runner failure means the command
* NEVER RAN and must surface as a sandbox failure, not a task failure —
* consumers check these signatures FIRST (a runner's own error text may
* contain denial words, e.g. an unopenable grant root reporting
* `Permission denied`).
*/
runnerFailureSignatures: readonly string[]
}
```
An operator-configured local runner must supply at least one `runnerFailureSignatures` entry for its own pre-exec refusal dialect; the provider adds outer-shell missing and unexecutable forms automatically. This makes an executable custom runner rejecting its profile distinguishable from the wrapped command exiting with the same status.
## Provider and fail-closed errors
`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. A selected runner can also fail closed at execution time, in which case its failure signature carries the same infrastructure meaning. Silent unconfined passthrough is never legal for a confined policy.
Provider probing arbitrates between multiple candidates and is cached for the provider lifetime. A platform with one candidate may select it directly; execution-time refusal retains the safety property. The local provider reports bwrap and Seatbelt as full and preserves the Landlock launcher's full/partial kernel verdict.
+116
View File
@@ -0,0 +1,116 @@
# Skills
The [skill capability family](../../packages/skill) is split across three packages: the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`) merges provider catalogs; the local provider ([dsh-skill-local](../../packages/skill/skill-local)) scans project/custom/user directories; the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)) owns the session-prefix catalog and model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md).
Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts).
## Provider registry
`ctx.skills` is a multi-provider registry. Providers can represent local directories, embedded plugin data, HTTP catalogs, or another source. Provider plugins register synchronously during `apply()`; remote initialization, authentication, and discovery are awaited by `list()`. The registry validates candidates, resolves duplicate skill names first-wins by rank/provider order/local order, and sorts the final summaries by `name` for deterministic consumers. A provider `list()` rejection is logged and skipped without caching the degraded catalog; malformed candidates still fail fast because they violate the provider contract.
```ts type-equiv
interface SkillProvider {
name: string
list(options: SkillLookupOptions): Promise<SkillCandidate[]>
get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>
}
```
## Local discovery priority
The shipped local provider scans roots in rank order:
| Rank | Source | Root |
|---|---|---|
| 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
| 200 | `project-agents` | `<projectRoot>/.agents/skills` |
| 300 | `custom` | `Config.customSkillDirs` |
| 400 | `user-dsh` | `<dshHome>/skills` |
| 500 | `user-agents` | `<agentsHome>/skills` |
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child, and DeepSeek Harness no longer ships built-in system skills from the local provider. Additional built-ins can be supplied later by another provider.
## Skill identity
Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider accepts directory bundles (`<name>/SKILL.md`) and flat Markdown files (`<name>.md`). Nested recursive `**/SKILL.md` discovery is intentionally outside v1.
```ts type-equiv
type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {})
```
## Summaries, candidates, and complete definitions
`SkillSummary` is the registry's model-invocable summary shape. Consumers choose which fields to render; the session catalog uses only `name` and `description`, never the body or absolute file path. `disableModelInvocation` hides a skill from model listings while allowing trusted code to load it by name.
```ts type-equiv
interface SkillSummary {
name: string
description: string
whenToUse?: string
disableModelInvocation?: boolean
source: SkillSource
provider: string
resourceBase?: SkillResourceBase
}
```
`SkillCandidate` is the provider-to-registry shape. `locator` is opaque provider state; the registry only stores it and gives it back to the winning provider's `get()`.
```ts type-equiv
interface SkillCandidate extends SkillSummary {
rank: number
locator: unknown
path?: string
metadata?: Record<string, unknown>
}
```
`SkillDefinition` is the complete parsed result returned by `ctx.skills.get()` and used by the `skill` tool. `resourceBase` tells the tool how to render relative-resource guidance for local, URL, or provider-managed skills.
```ts type-equiv
type SkillResourceBase =
| { kind: 'directory'; path: string }
| { kind: 'url'; url: string }
| { kind: 'opaque'; description: string }
```
```ts type-equiv
interface SkillDefinition extends SkillSummary {
content: string
path?: string
metadata?: Record<string, unknown>
}
```
Runtime skills use the same complete shape and participate in the same first-wins collection order. The returned disposer removes the contribution and invalidates discovery caches.
```ts type-equiv
type SkillRegistration = Omit<SkillDefinition, 'provider'> & {
provider?: string
}
```
## Lookup and configuration
Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. If no git root is found, the local provider treats the supplied cwd itself as the project root.
```ts type-equiv
interface SkillLookupOptions {
cwd?: string | undefined
signal?: AbortSignal | undefined
}
```
The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`). The consumer owns its catalog description bound.
```ts type-equiv
interface Config {
collectCacheMaxEntries?: number
}
```
## Session catalog and tool contract
`dsh-tool-skill` contributes a user-role `<system-reminder>` through `agent/session-prefix`. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Prefix discovery forwards the caller's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. Its request-only, header-logged lifecycle is defined by the [session-prefix RFC](../rfc/implemented/feature/2026-07-07-session-prefix.md).
The model-facing `skill({ name })` tool validates the kebab-case name, loads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. The tool result is the model-visible path for complete instructions.