diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2780299d0e..9c650c9df1 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -24,6 +24,9 @@ flowchart LR svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] + pkg_tool_bash["tool-bash"] + pkg_hooks_claude["hooks-claude"] + pkg_hooks_codex["hooks-codex"] pkg_acp["acp"] svc_sessionQuery["ctx.sessionQuery
Exact session-history reads and traces"] pkg_system_prompt["system-prompt"] @@ -33,7 +36,6 @@ flowchart LR pkg_tool_web["tool-web"] svc_tools["ctx.tools
Tool registry and guarded execution pipeline"] pkg_tool_ask_user["tool-ask-user"] - pkg_tool_bash["tool-bash"] pkg_tool_cordis["tool-cordis"] pkg_tool_skill["tool-skill"] pkg_tool_subagent["tool-subagent"] @@ -51,8 +53,7 @@ flowchart LR svc_bash["ctx.bash
Bash executor seam"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] - pkg_hooks_claude["hooks-claude"] - pkg_hooks_codex["hooks-codex"] + svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] pkg_sandbox["sandbox"] svc_sandbox["ctx.sandbox
Process-sandbox seam"] pkg_sandbox_local["sandbox-local"] @@ -130,6 +131,7 @@ flowchart LR pkg_subagent_spawn --> svc_subagents pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks + pkg_tool_bash --> svc_bashEnv pkg_tools --> svc_tools pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web @@ -159,7 +161,10 @@ flowchart LR svc_sandbox --> pkg_bash_sandbox svc_sessionPersistence --> pkg_acp svc_sessionPersistence --> pkg_agent_loop + svc_sessionPersistence --> pkg_hooks_claude + svc_sessionPersistence --> pkg_hooks_codex svc_sessionPersistence --> pkg_session_query + svc_sessionPersistence --> pkg_tool_bash svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants @@ -198,7 +203,7 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | @@ -207,6 +212,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | +| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | | `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index af01b7de9e..933ca9127d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -48,6 +48,8 @@ export interface Config { toolOrder?: string[] /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ @@ -97,13 +99,14 @@ Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loo * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt * plugin (the deployment's persona section and the explicit model-facing tool * order), the `tools` object to the tool registry (its presentation `mode`), - * `skills` to the skill registry/local provider/tool consumer, - * `workspaceContext` to the workspace-context loader, and - * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. - * Owner schemas supply defaults for optional input; workspace context instead - * requires an explicit byte budget or `false` because it changes model-visible - * input. Producer opt-in stays producer-local: `toolBash` configures bash only; - * independently composed producers keep their own config. + * `dshHome` to bash environment and local skill discovery, `skills` to the + * skill registry/local provider/tool consumer, `workspaceContext` to the + * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool + * plugins this bundle owns. Owner schemas supply defaults for optional input; + * workspace context instead requires an explicit byte budget or `false` because + * it changes model-visible input. Producer opt-in stays producer-local: + * `toolBash` configures bash only; independently composed producers keep their + * own config. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ @@ -114,6 +117,8 @@ export interface Config { toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ tools?: ToolsConfig + /** DeepSeek Harness home directory shared by shell context and local skill discovery. */ + dshHome?: string /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ @@ -137,7 +142,7 @@ export interface SkillConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:55`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -309,7 +314,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:43`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:44`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -334,7 +339,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:41`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-jsonrpc` @@ -595,7 +600,7 @@ export interface Config { } ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:23`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:24`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -630,7 +635,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:38`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:39`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` @@ -674,7 +679,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:40`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` @@ -748,6 +753,8 @@ export interface Config { toolOrder?: string[] /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ @@ -925,14 +932,16 @@ Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/ti Requires: `tools` · `bash` · `systemPrompt` ```ts config-catalog -/** Configures whether the model may background commands. */ +/** Configuration for the bash tool and its managed child environment. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string } ``` -Source: [`packages/bash/tool-bash/src/index.ts:30`](../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/tool-bash/src/index.ts:39`](../packages/bash/tool-bash/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1365,6 +1374,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) +- `@deepseek-ai/dsh-home` ([`packages/util/home/src/index.ts`](../packages/util/home/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 460e998b74..4e66d723cd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -71,7 +71,21 @@ abstract start(spec: BashExecSpec): BashProcess Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:46`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:49`](../../packages/bash/bash/src/index.ts) + +## `ctx.bashEnv` — `BashEnvRegistry` + +Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. + +```ts cordis-catalog +register(contributor: BashEnvContributor): () => void +collect(execution: ToolExecution): DshEnvironment +list(): BashEnvVariableInfo[] +``` + +Types: [ToolExecution](../core-data-structures/tools.md) + +Source: [`packages/bash/tool-bash/src/index.ts:102`](../../packages/bash/tool-bash/src/index.ts) ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) @@ -163,6 +177,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts:111`](../../packages/sandbox/san Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events. ```ts cordis-catalog +abstract locate(meta: SessionHeader): SessionLocation | undefined abstract create(meta: SessionHeader): Promise abstract append(id: SessionId, events: readonly SessionEvent[]): Promise abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> @@ -171,7 +186,7 @@ abstract list(): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:30`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:42`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index a8096c3b5c..c61c24add6 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -4,6 +4,18 @@ The bash execution seam is split across interface ([dsh-bash](../../packages/bas Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) +## Managed shell environment namespace + +`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot. + +```ts type-equiv +type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` +``` + +```ts type-equiv +type DshEnvironment = Readonly> +``` + ## Request vs. spec: the `resolve()` split The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`/`stdoutMaxBytes`, filled from config or request policy) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory or output budget came from. @@ -33,15 +45,20 @@ interface BashExecRequest { */ stdin?: string | undefined /** - * Extra environment entries for the command, merged AFTER the - * implementation's credential scrub (so an explicit entry here is honored even - * when its name matches the scrub pattern — the caller named a value it holds, - * not the harness's ambient secret). Set by in-process plugins (the hooks - * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing - * bash tool does not expose it as a parameter (a model that needs an env var - * uses shell syntax like `FOO=bar cmd`). + * Ordinary environment entries for the command, merged after the credential + * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it + * here. Set by in-process plugins (the hooks bridges set + * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool + * does not expose it as a parameter. */ env?: Record | undefined + /** + * Harness-owned `DSH_*` variables for this execution. Executors discard + * ambient `DSH_*` entries before merging this snapshot, so an unavailable + * current fact cannot inherit a stale value from the harness process, and + * reject non-`DSH_*` names supplied through this managed channel. + */ + dshEnv?: DshEnvironment | undefined /** * Explicit per-call sandbox-policy input, overriding the executor's * configured default mode for THIS call. Never a silent default: a @@ -85,6 +102,8 @@ interface BashExecSpec { * config default, absent means "no extra env". */ env?: Record | undefined + /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + dshEnv?: DshEnvironment | undefined /** * The sandbox mode this call executes under, required-but-nullable so every * resolved spec states its policy. A sandboxing executor's `resolve()` stamps diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 7bf102924b..c1e02ae743 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -2,7 +2,7 @@ The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md). -The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -12,6 +12,19 @@ The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06 A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). +## `SessionLocation` — optional per-session artifact target + +`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. + +```ts type-equiv +interface SessionLocation { + /** Backend-specific artifact kind, for example `jsonl`. */ + readonly kind: string + /** Absolute path to this session's backend-owned artifact. */ + readonly path: string +} +``` + ## `SessionHeader` — metadata beside the log Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. @@ -80,7 +93,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi ## The backends -Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: +Both implement the same abstract `SessionPersistence` (locate/create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. diff --git a/docs/module-graph.md b/docs/module-graph.md index ba67ef3f87..74973ff873 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -9,6 +9,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri flowchart TD subgraph group_util["packages/util"] pkg_brand["brand"] + pkg_home["home"] pkg_paths["paths"] pkg_retention["retention"] pkg_timeout["timeout"] @@ -172,6 +173,7 @@ flowchart TD pkg_fs_local --> pkg_fs pkg_fs_policy --> pkg_fs pkg_skill_local --> pkg_fs + pkg_skill_local --> pkg_home pkg_skill_local --> pkg_skill pkg_compact --> pkg_llm pkg_compact --> pkg_session @@ -252,8 +254,10 @@ flowchart TD pkg_agent_loop --> pkg_tools pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_bash + pkg_tool_bash --> pkg_home pkg_tool_bash --> pkg_llm pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_session_persistence pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tasks pkg_tool_bash --> pkg_tools @@ -299,6 +303,7 @@ flowchart TD pkg_hooks_codex --> pkg_hook_protocol pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session + pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools pkg_agent_loop_testkit --> pkg_agent pkg_agent_loop_testkit --> pkg_llm @@ -356,6 +361,7 @@ flowchart TD pkg_hooks_claude --> pkg_hook_protocol pkg_hooks_claude --> pkg_llm pkg_hooks_claude --> pkg_session + pkg_hooks_claude --> pkg_session_persistence pkg_hooks_claude --> pkg_subagent pkg_hooks_claude --> pkg_tools pkg_subagent_mock --> pkg_agent @@ -368,6 +374,7 @@ flowchart TD pkg_jsonrpc --> pkg_subagent pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_home pkg_agent_spine_demo --> pkg_invariants pkg_agent_spine_demo --> pkg_llm pkg_agent_spine_demo --> pkg_session @@ -416,6 +423,7 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | | [`brand`](../packages/util/brand) | `util` | — | +| [`home`](../packages/util/home) | `util` | — | | [`paths`](../packages/util/paths) | `util` | — | | [`retention`](../packages/util/retention) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | @@ -442,7 +450,7 @@ flowchart TD | [`bash`](../packages/bash/bash) | `bash` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | -| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`skill`](../packages/skill/skill) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) | @@ -470,7 +478,7 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | @@ -480,7 +488,7 @@ flowchart TD | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | @@ -492,10 +500,10 @@ flowchart TD | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 25d4a7ca73..386f2aaeeb 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -80,6 +80,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | | [Bash-backed grep and glob discovery tools](implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md) | 2026-07-09 | +| [Expose agent session identity and JSONL location to tools and hooks](implemented/feature/2026-07-10-agent-session-identity-and-log-location.md) | 2026-07-10 | | [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | | [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 | | [Session query relationship tracing](implemented/feature/2026-07-13-session-query-tracing.md) | 2026-07-13 | diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index de6428fb64..bc91d425b6 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -14,9 +14,9 @@ Add `stdin?: string` and `env?: Record` to **both** `BashExecReq Three deliberate choices: -1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. +1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them. -2. **`env` merges AFTER the credential scrub, so an explicit caller entry always wins** — even a credential-shaped name. This is correct because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into a spawned command. A caller that explicitly sets a var has named a value it already holds (not the ambient secret), so the scrub is not a constraint on it. `childEnv(extra?)` layers `scrub(process.env)` → `ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins. +2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md index e5df9c9548..af5c7e928d 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -12,8 +12,8 @@ The framing that shapes the whole design: **a bridge is a compatibility adapter, Two independent plugins in the `packages/hooks/` group, each a function/namespace plugin (`name`/`inject`/`Config`/`apply`, NO default export — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)) injecting only `bash`: -- **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. A CC hook's stdin carries a **trailing newline**. -- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. A tool call's payload carries the real `tool_name` in the bridge's reduced `tool_input: { command }` shape. +- **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`transcript_path`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. `transcript_path` is the persistence locator result or `''`; stdin carries a **trailing newline**. +- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape. ### Outcome → Decision mapping diff --git a/docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md new file mode 100644 index 0000000000..118009dedb --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -0,0 +1,85 @@ +# RFC: Expose agent session identity and JSONL location to tools and hooks + +Status: implemented + +## Problem + +An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands. + +The boundary must preserve two properties: the owner of a fact decides how to resolve it, and every child receives a per-execution snapshot rather than process-global mutable state. In particular, a nested harness must not leak its ambient `DSH_*` values into a child whose current agent, persistence backend, or configuration differs. + +## Decision + +Extend the [`SessionPersistence`](../../implemented/architecture/2026-06-14-session-persistence.md) seam with a synchronous, side-effect-free location query: + +```ts +import type { SessionHeader } from '@deepseek-ai/dsh-session' + +interface SessionLocation { + readonly kind: string + readonly path: string +} + +interface SessionPersistence { + locate(meta: SessionHeader): SessionLocation | undefined +} +``` + +`path` is an absolute local path to the backend's dedicated log for `meta`; `kind` identifies the representation. JSONL returns `{ kind: 'jsonl', path }` using its resolved root and path helpers. SQLite and any backend without an honest local per-session artifact return `undefined`. The query creates and flushes nothing, so it can report a lazy target path before that file exists. + +The model-facing bash package owns a `ctx.bashEnv` registry. A contributor declares its stable name, every `DSH_*` key it may return, a description for each key, and `resolve(execution: ToolExecution)`. Duplicate contributor names, duplicate key ownership, reserved keys, malformed declarations, undeclared runtime output, and non-string output fail loudly. Registration is a Cordis effect and is removed with the contributing plugin fiber. `list()` exposes declarations without running resolvers, keeping the environment surface enumerable for diagnostics and future prompt/UI consumers. + +The registry rebuilds a trusted overlay for every foreground and background bash `ToolExecution`: + +- `DSH_HOME` is always the absolute configured Harness home. The standalone [`@deepseek-ai/dsh-home`](../../../../packages/util/home/README.md) utility owns its precedence: explicit `dshHome`, then ambient `$DSH_HOME`, then `~/.dsh`. +- `DSH_SHELL=1` is always present and identifies a model bash child managed by DeepSeek Harness. +- `DSH_SESSION_ID` is present when the execution has an agent and equals `agent.session.header.id`. +- The built-in persistence translator contributes `DSH_SESSION_JSONL` only when `ctx.sessionPersistence.locate(header)` returns `kind: 'jsonl'`. + +Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`. + +The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for filtering and channel validation. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`. Ordinary `env` remains the general in-process plugin surface used by hooks, but cannot contain managed keys; symmetrically, `dshEnv` cannot contain ordinary keys. The local executor rejects either wrong channel before spawn, removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. + +The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required. + +The [Claude Code and Codex hook bridges](../../implemented/feature/2026-06-30-hook-bridges.md) resolve transcript location from the same persistence seam when constructing payloads. Codex uses `transcript_path: string | null`; Claude Code preserves its string field and falls back to `''`. Hook lookup neither materializes nor flushes a session. + +## Peer product findings + +Peer products separate stable identity from physical storage. Codex injects stable `CODEX_THREAD_ID` into spawned shells while recorder and hook surfaces own transcript paths. Claude Code supplies `session_id` and `transcript_path` as structured hook/status input. OpenCode carries identity in structured tool context; Kimi Code expands a session placeholder; Reasonix keeps the active session path on its controller. The portable rule is to inject identity at the invocation boundary, let storage resolve location, and never use a process-global current-session variable in a concurrent harness. + +## Lifecycle and persistence semantics + +A fresh session receives its id before the first turn, so its first bash call can read `DSH_SESSION_ID` and a JSONL target. The JSONL file may still be absent until the first successful turn-end checkpoint, and during an open turn it contains only the last flushed prefix. `DSH_SESSION_JSONL` is a location hint, not an authorization credential or freshness guarantee. + +Resume reuses the loaded header and therefore the same id and location. Fork and spawn create new session ids and locations. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. A persistence service replacement affects later collections because the translator queries `ctx.get('sessionPersistence')` at execution time; the registry itself is effect-scoped and HMR-safe. + +`dshHome` is session-independent deployment context. Agent-core resolves one value through `@deepseek-ai/dsh-home` and routes it to both tool-bash and local skill discovery; standalone consumers call the same resolver. If top-level `dshHome` and `skills.local.dshHome` are both supplied and resolve differently, composition fails instead of exposing contradictory homes. Persistence may change independently without freezing its facts into the session prefix. + +## Testing + +Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, absent/JSONL persistence, ignored model `env`, and parent/child isolation. JSONL/SQLite locator contract tests and both hook bridge suites pin available and unavailable transcript dialects. + +A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, JSONL target, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, pre-flush file absence, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice. + +## Alternatives considered + +**Only an id plus `find`.** Search cannot know a custom root or backend layout and races under multiple sessions. + +**Only an absolute path.** A path can be unavailable, lazy, or representation-specific and is not stable session identity. + +**Global `process.env`.** Concurrent agents would overwrite one another and nested harnesses would inherit stale current-session values. + +**Put persistence instructions in the session prefix.** A session prefix is frozen while the active service can change across HMR or future backend switching; persistence-specific guidance would become stale. + +**A typed waterfall event.** Listeners cannot declare ownership without running, and later listeners can silently overwrite keys. A registry detects key conflicts at registration and remains enumerable. + +**Have each persistence backend register bash env directly.** That reverses the dependency from storage into one consumer and forces bash into deployments that do not use it. `locate()` is also still required by hooks. + +**A model-facing `session_info` tool.** It adds schema and another call while bash already supplies the query surface; the registry generalizes to future environment facts without one tool per fact. + +## Consequences + +Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The complete `DSH_*` namespace inside these children is managed by the harness: ambient values are removed, current trusted values are re-added, and ordinary callers cannot use `env` to bypass ownership checks. + +The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 8c436f4cd6..048302db56 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -127,7 +127,7 @@ Owned by the tool registry as a reserved transport outside filterable capability ### `bash` -Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. +Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. ```json { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md index 33bfa25590..9d57691ab4 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -27,7 +27,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json index daf4e93ee8..e6700c6611 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json @@ -2,7 +2,7 @@ "initial": [ { "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md index 36aa94c53c..1d6918d142 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md @@ -27,7 +27,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json index 52e7974409..8cc9f35ea2 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json @@ -2,7 +2,7 @@ "initial": [ { "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md index 36aa94c53c..1d6918d142 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md @@ -27,7 +27,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md index 65b354efd1..bfedf3ed80 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md @@ -33,7 +33,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 070f269bcd..feb791e71e 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -131,8 +131,8 @@ {"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"b76bc320-c954-4f8a-b7d6-30821793dae8","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"b76bc320-c954-4f8a-b7d6-30821793dae8","outcome":"allowed-once"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"f2837399-691e-4913-abf4-9cd40aa31ac2","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"f2837399-691e-4913-abf4-9cd40aa31ac2","outcome":"allowed-once"}} {"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index c0315ac54a..c04cfdb6c6 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -155,8 +155,8 @@ {"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"66efb593-279a-472b-b647-c50d34045bc0","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"66efb593-279a-472b-b647-c50d34045bc0","outcome":"rejected"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"e5a72cf8-322c-4ec1-a082-55903876dc53","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"e5a72cf8-322c-4ec1-a082-55903876dc53","outcome":"rejected"}} {"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index f67bf420ab..7659b738d2 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,8 +55,8 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"af257151-c371-4be2-a9ab-fbf4b6d18eb1","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"af257151-c371-4be2-a9ab-fbf4b6d18eb1","outcome":"rejected"}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"6f7aaf62-f3cf-435f-8338-e8de72dbfbbf","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"6f7aaf62-f3cf-435f-8338-e8de72dbfbbf","outcome":"rejected"}} {"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json index 4b07d8edef..c1da2056fa 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json @@ -2,7 +2,7 @@ "initial": [ { "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json index 4b07d8edef..c1da2056fa 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json @@ -2,7 +2,7 @@ "initial": [ { "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json index 4b07d8edef..c1da2056fa 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json @@ -2,7 +2,7 @@ "initial": [ { "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json index da5e23216c..49b1b21197 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json @@ -2,7 +2,7 @@ "initial": [ { "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json index da5e23216c..49b1b21197 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json @@ -2,7 +2,7 @@ "initial": [ { "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", "parameters": { "type": "object", "properties": { diff --git a/knip.json b/knip.json index 1caef3861b..58278f286b 100644 --- a/knip.json +++ b/knip.json @@ -35,6 +35,11 @@ "project": ["src/**/*.ts"], "ignoreDependencies": ["cordis"] }, + "packages/util/home": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["cordis"] + }, "packages/util/timeout": { "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"], diff --git a/packages/README.md b/packages/README.md index 93a8a849d7..6c83abfc91 100644 --- a/packages/README.md +++ b/packages/README.md @@ -33,7 +33,7 @@ Packages live at `packages///`; groups are containers, while names r | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) the leaves load | Support — example infra | | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes, subagent mock) | Support — lower compatibility expectations | -| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, path helpers, timeout, retention) | Support — small, stable, harness-dep-free | +| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table. diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 4ff043457c..afa5000767 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -24,7 +24,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; - **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. -- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +- **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. ## Model Experience diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index b988631b0b..587c33b9a2 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -100,10 +100,14 @@ export class LocalBashExecutor extends BashExecutor { timeoutMs, stdoutMaxBytes, ...request.signal ? { signal: request.signal } : {}, - // Explicit environment values are merged after credential scrubbing in run.ts. + // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional, + // no config default. run.ts owns the scrub and merge order. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, - // Local execution carries this override for sandboxing subclasses. + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + // Carry a sandbox-mode override through verbatim: this executor never + // confines, so the field is inert here (the seam contract) — a + // sandboxing subclass overrides resolve() to stamp its default instead. sandboxMode: request.sandboxMode, } } @@ -120,6 +124,7 @@ export class LocalBashExecutor extends BashExecutor { signal: d.signal, stdin: spec.stdin, env: spec.env, + dshEnv: spec.dshEnv, }, this.internals).done // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined @@ -138,6 +143,7 @@ export class LocalBashExecutor extends BashExecutor { signal: spec.signal, stdin: spec.stdin, env: spec.env, + dshEnv: spec.dshEnv, }, this.internals) let stdoutOffset = 0 diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 5b9c49daf3..fa4dae73d6 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -11,7 +11,8 @@ import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { CollectedOutput } from '@deepseek-ai/dsh-bash' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' +import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash' /** * Model-friendly environment overrides: disable colors, pagers, and @@ -34,18 +35,33 @@ export const ENV_OVERRIDES = { export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** - * Build a child environment by scrubbing credential-shaped ambient variables, - * applying model-friendly overrides, then merging trusted caller entries last. - * - * @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides. + * Build a child environment from scrubbed ambient values, terminal overrides, + * ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed + * names are removed; ordinary and managed entries reject the other channel's + * namespace before `dshEnv` merges last. + * @param extra - caller entries; `DSH_*` names are rejected. + * @param dshEnv - managed entries; non-`DSH_*` names are rejected. * @returns the environment to hand to `spawn` for the child process. */ -export function childEnv(extra?: Record): NodeJS.ProcessEnv { +export function childEnv( + extra?: Readonly>, + dshEnv?: DshEnvironment, +): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value + if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value } - return { ...env, ...ENV_OVERRIDES, ...extra } + for (const key of Object.keys(extra ?? {})) { + if (key.startsWith(DSH_ENV_PREFIX)) { + throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`) + } + } + for (const key of Object.keys(dshEnv ?? {})) { + if (!key.startsWith(DSH_ENV_PREFIX)) { + throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`) + } + } + return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv } } /** What to run and under which limits (resolved — no defaults in here). */ @@ -73,12 +89,12 @@ export interface SpawnSpec { */ stdin?: string | undefined /** - * Extra environment entries, merged onto the scrubbed env AFTER the - * credential scrub and the model-friendly overrides (so an explicit entry - * wins). Set by in-process plugins; the model-facing tool does not forward - * model input here. + * Ordinary environment entries merged after the credential scrub and + * terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`. */ env?: Record | undefined + /** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */ + dshEnv?: DshEnvironment | undefined } /** @@ -280,7 +296,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB } // Keep absent stdin as /dev/null; literal tuples preserve non-null output types. - const env = childEnv(spec.env) + const env = childEnv(spec.env, spec.dshEnv) const child: ChildProcessByStdio = spec.stdin !== undefined ? 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 }) diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 9aca00490c..9db0c2eebd 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -127,21 +127,28 @@ describe('LocalBashExecutor.run', () => { await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/) }) - it('resolve() carries stdin/env onto the spec, and run() threads them to the command', async () => { + it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => { const { bash } = await setup() - const spec = bash.resolve({ command: 'cat; echo "[$DSH_SEAM_VAR]"', stdin: 'piped\n', env: { DSH_SEAM_VAR: 'env-ok' } }) - // resolve() keeps the stdin/env fields verbatim (optional, no default). + const spec = bash.resolve({ + command: 'cat; echo "[$SEAM_VAR][$DSH_SEAM_VAR]"', + stdin: 'piped\n', + env: { SEAM_VAR: 'env-ok' }, + dshEnv: { DSH_SEAM_VAR: 'dsh-ok' }, + }) + // resolve() keeps the optional input/environment fields verbatim. expect(spec.stdin).toBe('piped\n') - expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' }) + expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' }) + expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' }) const result = await bash.run(spec) - expect(result.stdout.text).toBe('piped\n[env-ok]\n') + expect(result.stdout.text).toBe('piped\n[env-ok][dsh-ok]\n') }) - it('resolve() omits stdin/env when the request supplies neither', async () => { + it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => { const { bash } = await setup() const spec = bash.resolve({ command: 'true' }) expect('stdin' in spec).toBe(false) expect('env' in spec).toBe(false) + expect('dshEnv' in spec).toBe(false) }) }) @@ -160,11 +167,12 @@ describe('LocalBashExecutor.start (background process handles)', () => { it('threads stdin and extra env into a background process', async () => { const { bash } = await setup() const proc = bash.start(bash.resolve({ - command: 'cat; echo "[$DSH_BG_VAR]"', + command: 'cat; echo "[$BG_VAR][$DSH_BG_VAR]"', stdin: 'bg-stdin\n', - env: { DSH_BG_VAR: 'bg-env' }, + env: { BG_VAR: 'bg-env' }, + dshEnv: { DSH_BG_VAR: 'bg-dsh-env' }, })) - const output = await readUntil(proc, '[bg-env]') + const output = await readUntil(proc, '[bg-env][bg-dsh-env]') expect(output).toContain('bg-stdin') await proc.done expect(proc.exitCode).toBe(0) diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index 02668ada3e..e65500a4b5 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' +import type { DshEnvironment } from '@deepseek-ai/dsh-bash' import { killGroup, OutputCollector, runBash } from '../src/run.ts' import type { RunningBash } from '../src/run.ts' @@ -198,19 +199,19 @@ describe('stdin and extra env (set by in-process plugins)', () => { expect(piped.stdout.text).toBe('socket\n') }) - it('merges extra env entries onto the scrubbed environment', async () => { - const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', { - env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' }, + it('merges ordinary extra env entries onto the scrubbed environment', async () => { + const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { + env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' }, })).done expect(result.stdout.text).toBe('alpha/beta\n') }) it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => { // TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins. - // DSH_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit + // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit // entry is still honored — the scrub only drops AMBIENT process.env creds. - const result = await runBash(spec('echo "$TERM/$DSH_OVERRIDE_KEY"', { - env: { TERM: 'xterm-256color', DSH_OVERRIDE_KEY: 'explicit-wins' }, + const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', { + env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, })).done expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n') }) @@ -363,13 +364,13 @@ describe('abort edge cases', () => { }) describe('environment and spill-file hardening', () => { - it('scrubs credential-shaped env vars from child processes', async () => { + it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => { process.env.DSH_TEST_API_KEY = 'super-secret' process.env.DSH_TEST_TOKEN = 'also-secret' process.env.DSH_TEST_PLAIN = 'visible' try { const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done - expect(result.stdout.text.trim()).toBe('[absent|absent|visible]') + expect(result.stdout.text.trim()).toBe('[absent|absent|absent]') } finally { delete process.env.DSH_TEST_API_KEY delete process.env.DSH_TEST_TOKEN @@ -377,6 +378,29 @@ describe('environment and spill-file hardening', () => { } }) + it('injects only the current trusted DSH environment after scrubbing ambient values', async () => { + process.env.DSH_STALE = 'old-value' + try { + const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { + dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, + })).done + expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') + } finally { + delete process.env.DSH_STALE + } + }) + + it('rejects DSH variables on the ordinary env channel', () => { + expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) + .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/) + }) + + it('rejects ordinary variables on the managed env channel', () => { + const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment + expect(() => runBash(spec('true', { dshEnv: invalid }))) + .toThrow(/managed bash env.*PATH.*use env/) + }) + it('creates spill files with owner-only permissions and random names', async () => { const result = await runBash( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index ff2dfe0129..e4b5bf1952 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -27,11 +27,11 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp ## Vocabulary -`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing. +`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing. The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). -`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec; a missing value means "none". See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). ## Model Experience diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 7563224000..75a5f7f230 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -9,6 +9,7 @@ import { Context, Service } from 'cordis' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts' +export { DSH_ENV_PREFIX } from './types.ts' export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' export type { BashExecRequest, @@ -19,6 +20,8 @@ export type { BashRunResult, BashSandboxInfo, CollectedOutput, + DshEnvironment, + DshEnvironmentKey, } from './types.ts' declare module 'cordis' { diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 81b051ceb3..6e0ddca91e 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -6,6 +6,15 @@ import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox' +/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ +export const DSH_ENV_PREFIX = 'DSH_' as const + +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` + +/** Trusted DeepSeek Harness variables for one bash execution. */ +export type DshEnvironment = Readonly> + /** * Sandbox facts for one run, present iff a sandboxing executor handled it. * Facts are reported independently of process exit status so callers can @@ -52,15 +61,20 @@ export interface BashExecRequest { */ stdin?: string | undefined /** - * Extra environment entries for the command, merged AFTER the - * implementation's credential scrub (so an explicit entry here is honored even - * when its name matches the scrub pattern — the caller named a value it holds, - * not the harness's ambient secret). Set by in-process plugins (the hooks - * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing - * bash tool does not expose it as a parameter (a model that needs an env var - * uses shell syntax like `FOO=bar cmd`). + * Ordinary environment entries for the command, merged after the credential + * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it + * here. Set by in-process plugins (the hooks bridges set + * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool + * does not expose it as a parameter. */ env?: Record | undefined + /** + * Harness-owned `DSH_*` variables for this execution. Executors discard + * ambient `DSH_*` entries before merging this snapshot, so an unavailable + * current fact cannot inherit a stale value from the harness process, and + * reject non-`DSH_*` names supplied through this managed channel. + */ + dshEnv?: DshEnvironment | undefined /** Explicit per-call sandbox mode override. */ sandboxMode?: SandboxMode | undefined } @@ -84,10 +98,14 @@ export interface BashExecSpec { /** Bytes to write to stdin before closing it; absent means no stdin. */ stdin?: string | undefined /** - * Extra environment entries, merged after credential scrubbing so explicit - * values win; absent means no extra entries. + * Ordinary environment entries carried through from + * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * OPTIONAL on the spec for the same reason as `stdin`: absent means no + * ordinary extra environment. */ env?: Record | undefined + /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + dshEnv?: DshEnvironment | undefined /** Resolved sandbox mode; ignored by executors that do not confine. */ sandboxMode: SandboxMode | undefined } diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index c20a30c98e..d5e65f1a39 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -24,6 +24,29 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th `command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. +### Managed shell environment + +Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. + +`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tool-bash' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. + Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`. When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. @@ -34,7 +57,7 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call ## The tool builds its request from named args only -The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, and `env`, used by trusted in-process plugins. This tool does **not** expose or forward them: it builds requests from named command/workdir/timeout/signal/sandbox fields only. This is not a trust boundary; the local executor's ambient credential scrub is the security control. +The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). ## Permissions and escalation diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index c8d082f46a..816be1ef88 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -25,7 +25,9 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", @@ -42,9 +44,12 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 476cd1a176..cad92c9276 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -8,34 +8,203 @@ * @module @deepseek-ai/dsh-tool-bash */ -import type { Context } from 'cordis' +import { Service, type Context } from 'cordis' import z from 'schemastery' import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-persistence' import { assertNever } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { effectiveSandboxMode } from '@deepseek-ai/dsh-bash' +import { DSH_ENV_PREFIX, effectiveSandboxMode } from '@deepseek-ai/dsh-bash' +import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' +import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home' import { processOutcome } from './background.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' +declare module 'cordis' { + interface Context { + bashEnv: BashEnvRegistry + } +} + export const name = 'tool-bash' export const inject = ['tools', 'bash', 'systemPrompt'] -/** Configures whether the model may background commands. */ +/** Configuration for the bash tool and its managed child environment. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string } +/** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), + dshHome: z.string(), }) +/** Model-visible metadata for one managed `DSH_*` environment variable. */ +export interface BashEnvVariable { + /** Concise description of the environment fact represented by the variable. */ + description: string +} + +/** + * A plugin contribution to the managed environment of each model bash call. + * Declared keys make ownership conflicts detectable before the first command; + * `resolve` computes only the values available for the current execution. + */ +export interface BashEnvContributor { + /** Stable contributor name used in diagnostics and duplicate detection. */ + name: string + /** Complete set of `DSH_*` keys this contributor may return. */ + variables: Readonly> + /** + * Resolve this contributor's available values for one tool execution. + * @param execution - the bash tool execution and its optional calling agent. + * @returns a partial map containing only keys declared in {@link variables}. + */ + resolve(execution: ToolExecution): Readonly>> +} + +/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */ +export interface BashEnvVariableInfo extends BashEnvVariable { + /** Contributor that owns the variable. */ + contributor: string + /** Declared `DSH_*` environment variable name. */ + key: DshEnvironmentKey +} + +const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const +const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const +const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const +const RESERVED_BASH_ENV_KEYS = new Set([ + DSH_HOME_ENV, + DSH_SHELL_KEY, + DSH_SESSION_ID_KEY, +]) +const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ + +/** + * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. + * The namespace is rebuilt for every model bash call: ambient `DSH_*` values + * are discarded by the executor, then the registry's current snapshot is + * injected. Built-in shell facts remain owned by the registry itself while + * plugins can register additional, enumerable facts with effect-scoped + * disposal. + */ +export class BashEnvRegistry extends Service { + private readonly contributors = new Map() + private readonly keyOwners = new Map() + private readonly dshHome: string + + /** + * Create and install the `ctx.bashEnv` service. + * @param ctx - Cordis context that owns the service and registrations. + * @param config - home-directory configuration for the built-in variables. + */ + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'bashEnv') + this.dshHome = resolveDshHome(config.dshHome) + } + + /** + * Register one environment contributor. Names and keys are unique; built-in + * keys are reserved. Registration is disposed with the calling plugin fiber. + * @param contributor - declared key ownership and per-execution resolver. + * @returns the disposer that unregisters the contribution. + */ + register(contributor: BashEnvContributor): () => void { + const dispose = this.ctx.effect(function* (this: BashEnvRegistry) { + if (contributor.name.trim().length === 0) { + throw new Error('bash env contributor name must be non-empty') + } + if (this.contributors.has(contributor.name)) { + throw new Error(`bash env contributor "${contributor.name}" is already registered`) + } + + const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][] + for (const [key, variable] of variables) { + if (!key.startsWith(DSH_ENV_PREFIX) + || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) { + throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`) + } + if (RESERVED_BASH_ENV_KEYS.has(key)) { + throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`) + } + if (variable.description.trim().length === 0) { + throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`) + } + const owner = this.keyOwners.get(key) + if (owner !== undefined) { + throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`) + } + } + + this.contributors.set(contributor.name, contributor) + for (const [key] of variables) this.keyOwners.set(key, contributor.name) + yield () => { + this.contributors.delete(contributor.name) + for (const [key] of variables) this.keyOwners.delete(key) + } + }.bind(this), 'bashEnv.register()') + return () => void dispose() + } + + /** + * Build the trusted `DSH_*` snapshot for one bash tool execution. + * @param execution - the current tool execution. + * @returns an immutable environment overlay containing built-ins and current contributions. + */ + collect(execution: ToolExecution): DshEnvironment { + const values: Record = { + [DSH_HOME_ENV]: this.dshHome, + [DSH_SHELL_KEY]: '1', + } + if (execution.agent !== undefined) { + values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id + } + + for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { + const resolved = contributor.resolve(execution) + for (const [rawKey, value] of Object.entries(resolved)) { + const key = rawKey as DshEnvironmentKey + if (!Object.hasOwn(contributor.variables, key)) { + throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) + } + if (typeof value !== 'string') { + throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) + } + values[key] = value + } + } + + return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right)))) + } + + // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics, + // prompt, or UI code treats list() as an exhaustive environment catalog. + /** + * Enumerate plugin-contributed variables without executing their resolvers. + * @returns declarations sorted by environment variable name. + */ + list(): BashEnvVariableInfo[] { + return [...this.contributors.values()] + .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({ + contributor: contributor.name, + description: variable.description, + key: key as DshEnvironmentKey, + }))) + .sort((left, right) => left.key.localeCompare(right.key)) + } +} + /** Parsed tool args; execute validates value constraints absent from SchemaSpec. */ interface BashToolArgs { command: string @@ -82,6 +251,7 @@ function bashDescription(backgroundEnabled: boolean, escalationModes: readonly S const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. ' + 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — ' + 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. ' + + `Current harness environment facts are exposed through managed \`$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. ` + 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. ' + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. ' + background @@ -153,7 +323,22 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent return modelWorkdir } -export function apply(ctx: Context, config: Config): void { +export function apply(ctx: Context, config: Config = {}): void { + const bashEnv = new BashEnvRegistry(ctx, config) + bashEnv.register({ + name: 'session-persistence', + variables: { + [DSH_SESSION_JSONL_KEY]: { + description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', + }, + }, + resolve(execution) { + const agent = execution.agent + if (agent === undefined) return {} + const location = ctx.get('sessionPersistence')?.locate(agent.session.header) + return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} + }, + }) const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS @@ -235,10 +420,12 @@ export function apply(ctx: Context, config: Config): void { ? await approveEscalation(args.sandbox_permissions, args.justification, exec) : sessionOverride(exec) const workdir = resolveWorkdir(args.workdir, exec) + const dshEnv = bashEnv.collect(exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, + dshEnv, ...sandboxMode !== undefined ? { sandboxMode } : {}, } if (args.run_in_background === true) { diff --git a/packages/bash/tool-bash/tests/bash-env.spec.ts b/packages/bash/tool-bash/tests/bash-env.spec.ts new file mode 100644 index 0000000000..03d29b572b --- /dev/null +++ b/packages/bash/tool-bash/tests/bash-env.spec.ts @@ -0,0 +1,190 @@ +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash' + +afterEach(() => vi.unstubAllEnvs()) + +function execution(sessionId?: string): ToolExecution { + return { + token: Symbol('bash-env-test') as ToolExecution['token'], + callId: CallId('bash-env-call'), + name: 'bash', + arguments: { command: 'true' }, + ...(sessionId === undefined + ? {} + : { agent: { session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as Agent }), + } +} + +describe('BashEnvRegistry', () => { + it('collects unconditional shell facts and the current agent session id', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + + expect(registry.collect(execution())).toEqual({ + DSH_HOME: resolve('./test-dsh-home'), + DSH_SHELL: '1', + }) + expect(registry.collect(execution('session-a'))).toEqual({ + DSH_HOME: resolve('./test-dsh-home'), + DSH_SESSION_ID: 'session-a', + DSH_SHELL: '1', + }) + }) + + it('resolves DSH_HOME from the ambient override or the user-home default', () => { + vi.stubEnv('DSH_HOME', './ambient-dsh-home') + const fromEnvironment = new BashEnvRegistry(new Context()) + expect(fromEnvironment.collect(execution()).DSH_HOME).toBe(resolve('./ambient-dsh-home')) + + vi.stubEnv('DSH_HOME', undefined) + const fromDefault = new BashEnvRegistry(new Context()) + expect(fromDefault.collect(execution()).DSH_HOME).toBe(join(homedir(), '.dsh')) + }) + + it('collects declared contributor variables and omits unavailable values', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + registry.register({ + name: 'optional-session-fact', + variables: { + DSH_SESSION_OPTIONAL: { description: 'Optional session-scoped test fact.' }, + }, + resolve: exec => exec.agent === undefined ? {} : { DSH_SESSION_OPTIONAL: exec.agent.session.header.id }, + }) + registry.register({ + name: 'always-available-fact', + variables: { + DSH_ALWAYS_AVAILABLE: { description: 'Always-available test fact.' }, + }, + resolve: () => ({ DSH_ALWAYS_AVAILABLE: 'yes' }), + }) + + expect(registry.collect(execution())).not.toHaveProperty('DSH_SESSION_OPTIONAL') + expect(registry.collect(execution()).DSH_ALWAYS_AVAILABLE).toBe('yes') + expect(registry.collect(execution('session-b')).DSH_SESSION_OPTIONAL).toBe('session-b') + expect(registry.list()).toEqual([ + { + contributor: 'always-available-fact', + description: 'Always-available test fact.', + key: 'DSH_ALWAYS_AVAILABLE', + }, + { + contributor: 'optional-session-fact', + description: 'Optional session-scoped test fact.', + key: 'DSH_SESSION_OPTIONAL', + }, + ]) + }) + + it('rejects duplicate variable ownership at registration time', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + registry.register({ + name: 'first', + variables: { DSH_SHARED: { description: 'First owner.' } }, + resolve: () => ({ DSH_SHARED: 'first' }), + }) + + expect(() => registry.register({ + name: 'second', + variables: { DSH_SHARED: { description: 'Second owner.' } }, + resolve: () => ({ DSH_SHARED: 'second' }), + })).toThrow(/DSH_SHARED.*first.*second|DSH_SHARED.*second.*first/) + }) + + it('rejects duplicate contributor names and malformed declarations', () => { + const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) + registry.register({ + name: 'declared', + variables: { DSH_DECLARED: { description: 'Declared fact.' } }, + resolve: () => ({}), + }) + + expect(() => registry.register({ + name: 'declared', + variables: { DSH_ANOTHER: { description: 'Another fact.' } }, + resolve: () => ({}), + })).toThrow(/already registered/) + expect(() => registry.register({ + name: ' ', + variables: { DSH_BLANK_NAME: { description: 'Blank owner.' } }, + resolve: () => ({}), + })).toThrow(/name must be non-empty/) + expect(() => registry.register({ + name: 'invalid-key', + variables: { dsh_invalid: { description: 'Invalid key.' } } as unknown as Record<'DSH_INVALID', { description: string }>, + resolve: () => ({}), + })).toThrow(/invalid key/) + expect(() => registry.register({ + name: 'reserved-key', + variables: { DSH_HOME: { description: 'Reserved key.' } }, + resolve: () => ({}), + })).toThrow(/reserved key/) + expect(() => registry.register({ + name: 'blank-description', + variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } }, + resolve: () => ({}), + })).toThrow(/must describe/) + }) + + it('rejects undeclared variables returned by a contributor', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + registry.register({ + name: 'drifted-provider', + variables: { DSH_DECLARED: { description: 'Declared fact.' } }, + resolve: () => ({ DSH_UNDECLARED: 'bad' }), + }) + + expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/) + }) + + it('rejects non-string values returned by a contributor', () => { + const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) + registry.register({ + name: 'wrong-value-type', + variables: { DSH_STRING: { description: 'String fact.' } }, + resolve: () => ({ DSH_STRING: 42 }) as unknown as Record<'DSH_STRING', string>, + }) + + expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/) + }) + + it('removes an effect-scoped contributor when its plugin is disposed', async () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + const fiber = await ctx.plugin({ + inject: ['bashEnv'], + apply(inner: Context) { + inner.bashEnv.register({ + name: 'temporary', + variables: { DSH_TEMPORARY: { description: 'Temporary fact.' } }, + resolve: () => ({ DSH_TEMPORARY: 'present' }), + }) + }, + }) + + expect(registry.collect(execution()).DSH_TEMPORARY).toBe('present') + await fiber.dispose() + expect(registry.collect(execution())).not.toHaveProperty('DSH_TEMPORARY') + }) + + it('returns an explicit contributor disposer', () => { + const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) + const dispose = registry.register({ + name: 'explicit-disposal', + variables: { DSH_EXPLICIT_DISPOSAL: { description: 'Explicitly disposed fact.' } }, + resolve: () => ({ DSH_EXPLICIT_DISPOSAL: 'present' }), + }) + + expect(registry.collect(execution()).DSH_EXPLICIT_DISPOSAL).toBe('present') + dispose() + expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL') + }) +}) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 0a2a1ba883..dbeac75e28 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -16,18 +20,25 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent * (tool/call + tool/result session events, the generic `ctx.tasks` runtime, * agent.inject completion notices). */ -async function harness(adapter: MockAdapter) { +async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + if (sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - await ctx.plugin(ToolBash) + await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) ctx.llm.registerAdapter(['mock'], adapter) return ctx } +const dirs: string[] = [] +afterEach(() => { + vi.unstubAllEnvs() + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { @@ -75,6 +86,39 @@ async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise { + it('first-turn bash receives session identity before the lazy JSONL file materializes', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-bash-session-env-')) + dirs.push(root) + const dshHome = join(root, 'dsh-home') + vi.stubEnv('DSH_STALE_PARENT', 'stale') + const adapter = new MockAdapter([ + toolCallResponse('call-1', 'bash', { + command: 'printf \'%s\\n%s\\n%s\\n%s\\n%s\\n\' "$DSH_HOME" "$DSH_SHELL" "$DSH_SESSION_ID" "$DSH_SESSION_JSONL" "${DSH_STALE_PARENT-unset}"; if [ -e "$DSH_SESSION_JSONL" ]; then printf \'present\\n\'; else printf \'absent\\n\'; fi', + description: 'inspect session environment', + }), + textResponse('Session environment inspected.'), + ]) + const ctx = await harness(adapter, root, dshHome) + const handle = await ctx.agents.create({ + agentId: AgentId('session-env'), + sessionId: SessionId('session-env-id'), + agentOptions: { model: 'mock' }, + }) + const agent = handle.agent as ReactLoopAgent + const location = ctx.sessionPersistence.locate(agent.session.header) + expect(location?.kind).toBe('jsonl') + + agent.send([{ type: 'text', text: 'inspect the current session' }]) + await waitForIdle(ctx, agent) + + const result = findEvent(events(agent), 'tool/result') + expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`) + expect(existsSync(location!.path)).toBe(true) + const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string } + expect(header).toMatchObject({ type: 'session', id: 'session-env-id' }) + await handle.dispose() + }) + it('foreground: model calls bash, sees the result, replies', async () => { const adapter = new MockAdapter([ toolCallResponse('call-1', 'bash', { command: 'echo integration-ok', description: 'test command' }, 'Running it.'), diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 5ef710779d..c19846ab13 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -10,6 +10,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import TaskService from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' @@ -931,6 +933,8 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { }) describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => { + const recordingDshHome = join(spillDir, 'dsh-home') + /** * Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a * test can assert what the model-facing tool DID and DID NOT forward. The `bash` @@ -956,6 +960,7 @@ describe('the model-facing bash tool builds its request from named args only (no ...request.signal ? { signal: request.signal } : {}, ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, sandboxMode: request.sandboxMode, } } @@ -977,18 +982,126 @@ describe('the model-facing bash tool builds its request from named args only (no } } - async function setupRecording() { + async function setupRecording(withJsonl = false) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + if (withJsonl) { + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') }) + } await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) await ctx.plugin(RecordingBashExecutor) - await ctx.plugin(ToolBash) + await ctx.plugin(ToolBash, { dshHome: recordingDshHome }) return { ctx, bash: ctx.bash as RecordingBashExecutor } } + it('describes the managed harness environment namespace to the model', async () => { + const { ctx } = await setupRecording() + const description = ctx.tools.get('bash')?.description ?? '' + expect(description).toContain('$DSH_*') + expect(description).not.toContain('DSH_SESSION_JSONL') + }) + + it('injects the session id and JSONL target path into a foreground request', async () => { + const { ctx, bash } = await setupRecording(true) + const agent = registerFakeAgent(ctx, 'request-fg', () => undefined) + const path = ctx.sessionPersistence.locate(agent.session.header)?.path + + await ctx.tools.execute({ + callId: CallId('session-env-fg'), + name: 'bash', + arguments: { command: 'true', description: 'run command' }, + agent, + }) + + expect(bash.requests[0]?.dshEnv).toEqual({ + DSH_HOME: recordingDshHome, + DSH_SESSION_ID: 'request-fg', + DSH_SESSION_JSONL: path, + DSH_SHELL: '1', + }) + }) + + it('injects the same trusted variables into a background request without forwarding model env', async () => { + const { ctx, bash } = await setupRecording(true) + const agent = registerFakeAgent(ctx, 'request-bg', () => undefined) + const path = ctx.sessionPersistence.locate(agent.session.header)?.path + + await ctx.tools.execute({ + callId: CallId('session-env-bg'), + name: 'bash', + arguments: { + command: 'sleep 1', + description: 'run command', + run_in_background: true, + env: { DSH_SESSION_ID: 'spoofed', DSH_SESSION_JSONL: '/tmp/spoofed' }, + }, + agent, + }) + + expect(bash.requests[0]?.env).toBeUndefined() + expect(bash.requests[0]?.dshEnv).toEqual({ + DSH_HOME: recordingDshHome, + DSH_SESSION_ID: 'request-bg', + DSH_SESSION_JSONL: path, + DSH_SHELL: '1', + }) + }) + + it('injects built-ins and the stable session id when no JSONL locator is available', async () => { + const { ctx, bash } = await setupRecording() + const agent = registerFakeAgent(ctx, 'request-id-only', () => undefined) + const ambient = process.env.DSH_SESSION_ID + + await ctx.tools.execute({ + callId: CallId('session-env-id-only'), + name: 'bash', + arguments: { command: 'true', description: 'run command' }, + agent, + }) + + expect(bash.requests[0]?.dshEnv).toEqual({ + DSH_HOME: recordingDshHome, + DSH_SESSION_ID: 'request-id-only', + DSH_SHELL: '1', + }) + expect(process.env.DSH_SESSION_ID).toBe(ambient) + }) + + it('keeps parent and child agent session environments isolated', async () => { + const { ctx, bash } = await setupRecording(true) + const parent = registerFakeAgent(ctx, 'request-parent', () => undefined) + const child = registerFakeAgent(ctx, 'request-child', () => undefined) + + for (const [callId, agent] of [['parent', parent], ['child', child]] as const) { + await ctx.tools.execute({ + callId: CallId(`session-env-${callId}`), + name: 'bash', + arguments: { command: 'true', description: 'run command' }, + agent, + }) + } + + expect(bash.requests.map(request => request.dshEnv)).toEqual([ + { + DSH_HOME: recordingDshHome, + DSH_SESSION_ID: 'request-parent', + DSH_SESSION_JSONL: ctx.sessionPersistence.locate(parent.session.header)?.path, + DSH_SHELL: '1', + }, + { + DSH_HOME: recordingDshHome, + DSH_SESSION_ID: 'request-child', + DSH_SESSION_JSONL: ctx.sessionPersistence.locate(child.session.header)?.path, + DSH_SHELL: '1', + }, + ]) + expect(bash.requests[0]?.dshEnv?.DSH_SESSION_JSONL).not.toBe(bash.requests[1]?.dshEnv?.DSH_SESSION_JSONL) + }) + it('does not forward trusted-only fields even when the model includes them as extra arguments', async () => { const { ctx, bash } = await setupRecording() // Unknown `env` and `stdin` keys are ignored by the schema and named request construction. diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json index 6957c10fdf..407e78ebd8 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -26,9 +26,15 @@ { "path": "../../core/agent" }, + { + "path": "../../session-persistence/session-persistence" + }, { "path": "../../bash/bash" }, + { + "path": "../../util/home" + }, { "path": "../../tasks/tasks" }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9fb6383459..ba9873f56f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -91,6 +91,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'abstract start(spec: BashExecSpec): BashProcess', ], }, + { + key: 'bashEnv', + summary: 'Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.', + methods: [ + 'register(contributor: BashEnvContributor): () => void', + 'collect(execution: ToolExecution): DshEnvironment', + 'list(): BashEnvVariableInfo[]', + ], + }, { key: 'codeRuntime', summary: 'Registers one `ctx.codeRuntime` implementation.', @@ -150,6 +159,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'sessionPersistence', summary: 'Durable append-only session storage.', methods: [ + 'abstract locate(meta: SessionHeader): SessionLocation | undefined', 'abstract create(meta: SessionHeader): Promise', 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', @@ -575,13 +585,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AssembledSection', declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}', }, + { + name: 'BashEnvContributor', + declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly>;\n resolve(execution: ToolExecution): Readonly>>;\n}', + }, + { + name: 'BashEnvVariable', + declaration: 'export interface BashEnvVariable {\n description: string;\n}', + }, + { + name: 'BashEnvVariableInfo', + declaration: 'export interface BashEnvVariableInfo extends BashEnvVariable {\n contributor: string;\n key: DshEnvironmentKey;\n}', + }, { name: 'BashExecRequest', - declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n sandboxMode?: SandboxMode | undefined;\n}', + declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode?: SandboxMode | undefined;\n}', }, { name: 'BashExecSpec', - declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n sandboxMode: SandboxMode | undefined;\n}', + declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode: SandboxMode | undefined;\n}', }, { name: 'BashProcess', @@ -679,6 +701,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DiffResultView', declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}', }, + { + name: 'DshEnvironment', + declaration: 'export type DshEnvironment = Readonly>;', + }, + { + name: 'DshEnvironmentKey', + declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;', + }, { name: 'FileDiff', declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}', @@ -875,6 +905,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionLineageTrace', declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});', }, + { + name: 'SessionLocation', + declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index c778ab6c2e..5809e9ab49 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -28,6 +28,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `model` | (required) | the per-session agent template the bridge creates agents from | | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery | | `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` | | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index e52b21b9f6..c6a58af2f0 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -38,6 +38,8 @@ export interface Config { toolOrder?: string[] /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ @@ -61,6 +63,7 @@ export const Config: z = z.object({ // schemastery's native [] default would read as an invalid configured list. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, + dshHome: z.string(), // TODO(single-default-literal): share this schema default and the defensive // apply() fallback through one named constant while retaining both boundaries. persistenceRoot: z.string().default('./.sessions'), diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 34dd0054cc..a86d1261e8 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -118,8 +118,9 @@ describe('dsh-acp-demo composition', () => { }) }) - it('forwards skill config into agent-spine-demo', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false }) + it('forwards skill config and dshHome into agent-spine-demo', async () => { + const skills = await isolatedSkillsConfig(6) + const ctx = await mount({ model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false }) ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' }) expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...') await ctx.fiber.dispose() diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 0d02d5f597..45797a58c8 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -42,11 +42,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, persona?, toolOrder?, tools?, skills?, workspaceContext, toolBash?, toolTasks? } +// { agents?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it 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. `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` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it 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. ## Why a code bundle, not a shared YAML include diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 505e93e46f..772d6c059b 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -26,6 +26,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -45,6 +46,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 5652078a0f..d8357c6c9a 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -25,6 +25,7 @@ import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' import * as toolTasks from '@deepseek-ai/dsh-tool-tasks' import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop' +import { resolveDshHome } from '@deepseek-ai/dsh-home' export const name = 'agent-spine-demo' @@ -44,13 +45,14 @@ export interface SkillConfig { * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt * plugin (the deployment's persona section and the explicit model-facing tool * order), the `tools` object to the tool registry (its presentation `mode`), - * `skills` to the skill registry/local provider/tool consumer, - * `workspaceContext` to the workspace-context loader, and - * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. - * Owner schemas supply defaults for optional input; workspace context instead - * requires an explicit byte budget or `false` because it changes model-visible - * input. Producer opt-in stays producer-local: `toolBash` configures bash only; - * independently composed producers keep their own config. + * `dshHome` to bash environment and local skill discovery, `skills` to the + * skill registry/local provider/tool consumer, `workspaceContext` to the + * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool + * plugins this bundle owns. Owner schemas supply defaults for optional input; + * workspace context instead requires an explicit byte budget or `false` because + * it changes model-visible input. Producer opt-in stays producer-local: + * `toolBash` configures bash only; independently composed producers keep their + * own config. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ @@ -61,6 +63,8 @@ export interface Config { toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ tools?: ToolsConfig + /** DeepSeek Harness home directory shared by shell context and local skill discovery. */ + dshHome?: string /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ @@ -90,11 +94,12 @@ export const Config = z.intersect([ SystemPrompt.Config, z.object({ tools: ToolRegistry.Config, + dshHome: z.string(), skills: SkillConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, toolTasks: ToolTasksConfigSchema, - }) as unknown as z>, + }) as unknown as z>, ]) as unknown as z /** @@ -107,6 +112,7 @@ export function pickSpineConfig(config: Omit): Omit): Omit { await ctx.fiber.dispose() }) + it('shares top-level dshHome between local skills and the managed bash environment', async () => { + const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-home-')) + const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-agents-')) + await mkdir(join(home, 'skills'), { recursive: true }) + await writeFile(join(home, 'skills', 'shared-skill.md'), '---\nname: shared-skill\ndescription: Shared home skill\n---\n\nShared body.\n') + + const ctx = await mount({ + dshHome: home, + workspaceContext: false, + skills: { local: { agentsHome } }, + }, true) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['shared-skill']) + const execution: ToolExecution = { + token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'], + callId: CallId('agent-core-dsh-home'), + name: 'bash', + arguments: { command: 'true' }, + } + expect(ctx.bashEnv.collect(execution)).toMatchObject({ DSH_HOME: home, DSH_SHELL: '1' }) + await ctx.fiber.dispose() + }) + + it('rejects conflicting global and nested DSH home directories', () => { + expect(() => { + agentCore.apply(new Context(), { + dshHome: '/global-dsh-home', + workspaceContext: false, + skills: { local: { dshHome: '/nested-dsh-home' } }, + }) + }).toThrow(/must resolve to the same directory/) + }) + it('places workspace instructions before the skill catalog in the session prefix', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-prefix-order-')) try { @@ -307,6 +341,7 @@ describe('dsh-agent-spine-demo bundle', () => { persona: 'You are merged.', toolOrder: ['zulu'], tools: { mode: 'native' as const }, + dshHome: '/tmp/dsh-home', workspaceContext: false as const, skills: {}, toolBash: { enableRunInBackground: false }, @@ -317,6 +352,7 @@ describe('dsh-agent-spine-demo bundle', () => { persona: appConfig.persona, toolOrder: appConfig.toolOrder, tools: appConfig.tools, + dshHome: appConfig.dshHome, workspaceContext: false, skills: {}, toolBash: appConfig.toolBash, diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 9a51ffa8c8..89cb2accd8 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -50,6 +50,9 @@ { "path": "../../support/invariants" }, + { + "path": "../../util/home" + }, { "path": "../../bash/tool-bash" }, diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index d469acf6c4..5719eaff9f 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -28,6 +28,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `model` | (required) | the pre-created `main` agent's model | | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery | | `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` | | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index d7ae866b53..04c06ad5db 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -43,6 +43,8 @@ export interface Config { toolOrder?: string[] /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ @@ -71,6 +73,7 @@ export const Config: z = z.object({ // schemastery's native [] default would read as an invalid configured list. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, + dshHome: z.string(), // TODO(single-default-literal): share these schema defaults and defensive // apply() fallbacks through named constants while retaining both boundaries. persistenceRoot: z.string().default('./.sessions'), diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index 1dd9fda575..c0557ca512 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -132,8 +132,9 @@ describe('dsh-stdio-demo app', () => { await ctx.fiber.dispose() }) - it('forwards skill config into agent-spine-demo', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false }) + it('forwards skill config and dshHome into agent-spine-demo', async () => { + const skills = await isolatedSkillsConfig(6) + const ctx = await mount({ model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false }) ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' }) expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...') await ctx.fiber.dispose() diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 700acadda8..4430f5511a 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -46,6 +46,8 @@ The three emit points run detached — no seam awaits a `SessionStart`/`Subagent The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or a constant `agent_type` of `general-purpose` (`SubagentStart`/`SubagentStop` — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/`*`/empty `agent_type` matcher fires, a specific-kind matcher does not); `UserPromptSubmit`/`Stop` ignore matchers. Multiple file-configured hooks on one point run **serially, in config order**, and fold most-restrictively (`deny > ask > allow`, see `dsh-hook-protocol`); serial keeps each hook's `hook/invoked`/`hook/result` pair adjacent in the log, and the fold is order-independent for the decision (see the RFC's "run serially, not concurrently" note). +Every agent-scoped stdin payload carries `session_id` and string-shaped `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `''`. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn. + ## Context source Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself. diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index abb68a061e..c6870a6471 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -29,6 +29,7 @@ "@deepseek-ai/dsh-hook-protocol": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -42,6 +43,8 @@ "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 25f959260c..121e5cf75a 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -14,6 +14,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-session-persistence' import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { appendHookInvoked, @@ -197,7 +198,7 @@ export function apply(ctx: Context, config: Config): void { // may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. ctx.on('agent/session-start', (agent, source) => { - detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal }) + detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) if (context) agent.inject(context.content, { source: context.source }) @@ -211,7 +212,7 @@ export function apply(ctx: Context, config: Config): void { // matcher subject (CC ignores matchers for this event). --- ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise => { const turn = lastTurn(agent) - const merged = await runPoint('UserPromptSubmit', '', promptPayload(agent, content), { agent, turn }) + const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn }) if (merged.decision === 'deny') { return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } } @@ -230,7 +231,7 @@ export function apply(ctx: Context, config: Config): void { // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. --- ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} } return next() @@ -239,7 +240,7 @@ export function apply(ctx: Context, config: Config): void { // --- PostToolUse → PostToolDecision. Matcher subject is the tool name. --- ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } @@ -261,7 +262,7 @@ export function apply(ctx: Context, config: Config): void { // A blocking Stop hook forces continuation with its reason. // TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile. ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => { - const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn }) + const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation. const text = merged.reason ?? 'continue: blocked by Stop hook' @@ -274,7 +275,7 @@ export function apply(ctx: Context, config: Config): void { // use the live child's workspace and the generic agent-type matcher subject. ctx.on('subagent/start', (info) => { const child = ctx.get('agents')?.get(info.id) - detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }) + detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) if (context && child) child.inject(context.content, { source: context.source }) @@ -286,7 +287,7 @@ export function apply(ctx: Context, config: Config): void { // `.then` before the tool caller's `await run.result` disposes it) so the hook runs in the // child's cwd, not the server default. const child = ctx.get('agents')?.get(info.id) - detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })) + detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })) }) } @@ -316,28 +317,31 @@ function blocksToText(content: ContentBlock[]): string { return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('') } -function base(agent: Agent | undefined, event: string): Record { +function base(ctx: Context, agent: Agent | undefined, event: string): Record { return { session_id: agent?.session.header.id ?? '', + transcript_path: agent === undefined + ? '' + : ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? '', cwd: agent?.session.header.cwd ?? process.cwd(), hook_event_name: event, } } -function sessionStartPayload(agent: Agent, source: string): Record { - return { ...base(agent, 'SessionStart'), source } +function sessionStartPayload(ctx: Context, agent: Agent, source: string): Record { + return { ...base(ctx, agent, 'SessionStart'), source } } -function promptPayload(agent: Agent, content: ContentBlock[]): Record { - return { ...base(agent, 'UserPromptSubmit'), prompt: blocksToText(content) } +function promptPayload(ctx: Context, agent: Agent, content: ContentBlock[]): Record { + return { ...base(ctx, agent, 'UserPromptSubmit'), prompt: blocksToText(content) } } -function preToolPayload(exec: ToolExecution): Record { - return { ...base(exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId } +function preToolPayload(ctx: Context, exec: ToolExecution): Record { + return { ...base(ctx, exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId } } -function postToolPayload(exec: ToolExecution, result: ToolExecutionResult): Record { - return { ...base(exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } +function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): Record { + return { ...base(ctx, exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } } -function stopPayload(agent: Agent): Record { - return { ...base(agent, 'Stop'), stop_hook_active: false } +function stopPayload(ctx: Context, agent: Agent): Record { + return { ...base(ctx, agent, 'Stop'), stop_hook_active: false } } /** * Build a SubagentStart/SubagentStop payload from the CC base (the child's @@ -345,9 +349,9 @@ function stopPayload(agent: Agent): Record { * fields. `agent_type` is the CC-default {@link SUBAGENT_TYPE}; `stop_hook_active` * is present on SubagentStop only (the loop-guard flag, always false this cut). */ -function subagentPayload(event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record { +function subagentPayload(ctx: Context, event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record { return { - ...base(child, event), + ...base(ctx, child, event), agent_id: info.id, agent_type: SUBAGENT_TYPE, ...event === 'SubagentStop' ? { stop_hook_active: false } : {}, diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 2a0444c422..4af01686ac 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineTool } from '@deepseek-ai/dsh-tools' import { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' @@ -26,10 +27,11 @@ function hooks(d: string, h: unknown): string { writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') } -type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number } +type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number; sessionRoot?: string } async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath, ...opts }) @@ -51,6 +53,28 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): } describe('hooks-claude coverage — config option arms + substitution + skip warning', () => { + it('uses the persistence locator for transcript_path and an empty string without one', async () => { + async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string }; expected: string | undefined }> { + const d = dir() + const cap = join(d, 'payload') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('transcript'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + return { + payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string }, + expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path, + } + } + + const located = await capture(dir()) + expect(located.payload.transcript_path).toBe(located.expected) + expect((await capture()).payload.transcript_path).toBe('') + }, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom. + it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => { const d = dir() // ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker. diff --git a/packages/hooks/hooks-claude/tsconfig.json b/packages/hooks/hooks-claude/tsconfig.json index 909db9b5c3..07c88610f9 100644 --- a/packages/hooks/hooks-claude/tsconfig.json +++ b/packages/hooks/hooks-claude/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-persistence/session-persistence" + }, { "path": "../../subagent/subagent" }, diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 078bbc2e9d..0784857286 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -48,6 +48,8 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers. +Every agent-scoped stdin payload carries `session_id` and `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `null`, preserving the Codex `string | null` shape. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn. + `SessionStart` — the one emit point — runs detached; each run chain is tracked, and disposing the bridge aborts a still-running hook process, then drains the continuation before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`). ## Context source diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index a481162a11..8c8686c539 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -29,6 +29,7 @@ "@deepseek-ai/dsh-hook-protocol": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -41,6 +42,8 @@ "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index ad240e2e4d..8fa89a469d 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -17,6 +17,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-session-persistence' import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { appendHookInvoked, @@ -172,7 +173,7 @@ export function apply(ctx: Context, config: Config): void { // hook may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. ctx.on('agent/session-start', (agent, source) => { - detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) + detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) if (context) agent.inject(context.content, { source: context.source }) @@ -184,7 +185,7 @@ export function apply(ctx: Context, config: Config): void { // UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask. ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise => { const turn = lastTurn(agent) - const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true }) + const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(ctx, agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true }) /* jscpd:ignore-start */ if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } // Context alone is not a veto: DELEGATE so a later prompt-submit listener can @@ -202,7 +203,7 @@ export function apply(ctx: Context, config: Config): void { // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) /* jscpd:ignore-end */ if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } return next() @@ -212,7 +213,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(exec.agent) /* jscpd:ignore-start */ - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } @@ -236,7 +237,7 @@ export function apply(ctx: Context, config: Config): void { // avoid continuing the same turn indefinitely. It is always false here, so an // unconditionally blocking hook force-continues every step until it self-limits. ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => { - const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn }) + const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn }) /* jscpd:ignore-end */ if (merged.decision === 'deny') { // A blocking Stop hook forces continuation; a block with no reason (exit 2, @@ -270,10 +271,12 @@ function blocksToText(content: ContentBlock[]): string { /* jscpd:ignore-end */ /** Base fields on every Codex payload (no turn_id). */ -function base(agent: Agent | undefined, event: string, model: string): Record { +function base(ctx: Context, agent: Agent | undefined, event: string, model: string): Record { return { session_id: agent?.session.header.id ?? '', - transcript_path: null, + transcript_path: agent === undefined + ? null + : ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? null, cwd: agent?.session.header.cwd ?? process.cwd(), hook_event_name: event, model, @@ -282,8 +285,8 @@ function base(agent: Agent | undefined, event: string, model: string): Record { - return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) } +function turnBase(ctx: Context, agent: Agent | undefined, event: string, model: string): Record { + return { ...base(ctx, agent, event, model), turn_id: String(lastTurn(agent)) } } /** Extract a `command` string from a tool call's parsed arguments, else ''. */ @@ -295,14 +298,14 @@ function commandOf(args: unknown): string { return '' } -function preToolPayload(exec: ToolExecution, model: string): Record { +function preToolPayload(ctx: Context, exec: ToolExecution, model: string): Record { // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject); // a hardcoded constant would disagree with what the matcher tests and make a // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }` // shape (its shell payload), derived from the call's `command` arg when present. - return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } + return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } } -function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record { - return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } +function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult, model: string): Record { + return { ...turnBase(ctx, exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } } diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index d262b51dbb..e2d65967a7 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineTool } from '@deepseek-ai/dsh-tools' import { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' @@ -22,9 +23,11 @@ function hooks(d: string, h: unknown): string { writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') } -async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise { +type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string } +async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) @@ -46,6 +49,28 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): } describe('hooks-codex coverage — decision mapping paths', () => { + it('uses the persistence locator for transcript_path and null without one', async () => { + async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string | null }; expected: string | undefined }> { + const d = dir() + const cap = join(d, 'payload') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('transcript'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + return { + payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null }, + expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path, + } + } + + const located = await capture(dir()) + expect(located.payload.transcript_path).toBe(located.expected) + expect((await capture()).payload.transcript_path).toBeNull() + }, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom. + it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => { const d = dir() hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) diff --git a/packages/hooks/hooks-codex/tsconfig.json b/packages/hooks/hooks-codex/tsconfig.json index f936b500aa..ae3c91e9dd 100644 --- a/packages/hooks/hooks-codex/tsconfig.json +++ b/packages/hooks/hooks-codex/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-persistence/session-persistence" + }, { "path": "../../llm/llm" }, diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 343fb4a70a..43f4443107 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -19,6 +19,8 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence |---|---|---| | `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). | +`locate(meta)` returns `{ kind: 'jsonl', path }` using the resolved absolute root and the same cwd-bucket/id encoding as materialization. It performs no filesystem I/O: the target can be returned before the file exists, and an existing file contains only the last flushed prefix. + ## Durability and crash semantics - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory. A created-but-never-appended session leaves nothing on disk and is absent from `list`. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 1d13ff424e..0c8a2ee3ef 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -1,7 +1,8 @@ /** * JSONL durable session-persistence backend. It stores a header and contiguous * events in one append-only file per session, and delegates orchestration to - * {@link PersistenceCoordinator}. + * {@link PersistenceCoordinator}. Its side-effect-free locator returns the + * absolute per-session log target before materialization. * @module @deepseek-ai/dsh-session-persistence-jsonl */ @@ -12,7 +13,7 @@ import { dirname, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, PersistenceCoordinator, - type PersistenceBackend, type StoredPrefix, + type PersistenceBackend, type SessionLocation, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -68,6 +69,11 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /* jscpd:ignore-start */ // --- SessionPersistence service surface (delegated to the coordinator) --- + /** Resolve the absolute target path without touching the filesystem. */ + locate(meta: SessionHeader): SessionLocation { + return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id) } + } + create(meta: SessionHeader): Promise { return this.coordinator.create(meta) } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 75fcb48732..3c76616796 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { isAbsolute, join, relative, resolve } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -112,6 +112,19 @@ describe('SessionPersistenceJsonl: format helpers', () => { it('encodeSegment rejects an empty id', () => { expect(() => encodeSegment('')).toThrow(/empty/) }) + + it('resolves a relative custom root before locating a session', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: relative(process.cwd(), absoluteRoot) }) + const m = meta('relative-location', '/work') + expect(ctx.sessionPersistence.locate(m)).toEqual({ + kind: 'jsonl', + path: logPath(resolve(absoluteRoot), '/work', m.id), + }) + await fiber.dispose() + }) }) describe('SessionPersistenceJsonl: durability and crash semantics', () => { @@ -126,8 +139,13 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('lazy materialization: create() writes no file until the first append', async () => { const m = meta('lazy', '/work') + const location = ctx.sessionPersistence.locate(m) + expect(location).toEqual({ kind: 'jsonl', path: logPath(root, '/work', m.id) }) + expect(isAbsolute(location!.path)).toBe(true) + await ctx.sessionPersistence.create(m) - // nothing on disk yet + // locate() is a pure target-path calculation: neither it nor create() + // materializes a file before the first append. const dir = sessionDir(root, '/work') await expect(stat(logPath(root, '/work', m.id))).rejects.toThrow() expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id) @@ -139,6 +157,26 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { void dir }) + it('keeps the same location on resume and gives a fork its own location', async () => { + const parent = meta('location-parent', '/work') + const parentLocation = ctx.sessionPersistence.locate(parent) + await ctx.sessionPersistence.create(parent) + await ctx.sessionPersistence.append(parent.id, oneTurnLog()) + + const loaded = await ctx.sessionPersistence.load(parent.id) + expect(ctx.sessionPersistence.locate(loaded.meta)).toEqual(parentLocation) + + const child = { + ...loaded.meta, + id: SessionId('location-child'), + parentSession: parent.id, + seedLength: loaded.events.length, + } + const childLocation = ctx.sessionPersistence.locate(child) + expect(childLocation?.path).not.toBe(parentLocation?.path) + expect(childLocation).toEqual({ kind: 'jsonl', path: logPath(root, '/work', child.id) }) + }) + it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => { const m = meta('chunks') const log: SessionEvent[] = [ diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 1411e177be..241e7ceb38 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -2,6 +2,8 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes. +`locate(meta)` returns `undefined`: all sessions share one database, so there is no honest independent per-session transcript path. + > **TODO:** this backend talks to `node:sqlite` directly. If a cordis database service (`cordis/db` / a `@cordisjs` SQL driver plugin) is adopted, route through that instead of holding a raw `DatabaseSync` here — the contract surface (`SessionPersistence`) would not change, only the storage driver. ## Storage model diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 7d23292e9d..d886124961 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -1,7 +1,8 @@ /** * SQLite durable session-persistence backend. It maps each session header and * event to rows, and delegates write-path orchestration to - * {@link PersistenceCoordinator}. + * {@link PersistenceCoordinator}. It has no independent per-session artifact, + * so its locator returns `undefined`. * @module @deepseek-ai/dsh-session-persistence-sqlite */ @@ -12,7 +13,7 @@ import { mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, PersistenceCoordinator, - type PersistenceBackend, type StoredPrefix, + type PersistenceBackend, type SessionLocation, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -95,6 +96,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers // --- SessionPersistence service surface (delegated to the coordinator) --- + /** SQLite has one database, not an independent local artifact per session. */ + locate(_meta: SessionHeader): SessionLocation | undefined { + return undefined + } + create(meta: SessionHeader): Promise { return this.coordinator.create(meta) } diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index bae79b0a53..81316b9d2a 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -153,6 +153,12 @@ describe('scanRows', () => { }) describe('SessionPersistenceSqlite: durability and crash semantics', () => { + it('has no independent per-session log location', async () => { + const { ctx, dispose } = await backend() + expect(ctx.sessionPersistence.locate(meta('sqlite-location'))).toBeUndefined() + await dispose() + }) + it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => { const path = await freshDbPath() const m = meta('crash') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index f09a21251b..a294575fd9 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -8,6 +8,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | Method | Contract | |---|---| +| `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. | | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | @@ -24,6 +25,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l `PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +The side-effect-free `locate` query remains backend-owned because it describes storage topology rather than write orchestration. + The `PersistenceBackend` hooks (the only seam between the coordinator and storage): | Hook | Role | @@ -44,9 +47,9 @@ Import `runPersistenceContract` from `tests/contract.ts` (the public-API contrac Three backends run these suites: an in-memory reference (in `tests/`), `dsh-session-persistence-jsonl` (append-only file log) and `dsh-session-persistence-sqlite` (`node:sqlite`, each `SessionEvent` one row `(session_id, seq, type, time, data, source_event_seqs, surface_op)`). All passing the same contract + coordinator suite is the proof that the seam is genuinely backend-agnostic — lazy materialization, crash-tail-on-load, and contiguous-seq hold identically over file bytes and over a transactional store. -## Metadata types +## Metadata and location types -Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`). +Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`). `SessionLocation` is `{ readonly kind: string; readonly path: string }`; its path is an absolute backend target, not proof that the artifact exists or contains an unflushed turn. ## Model Experience diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 3e6c9c21ed..3c102e6ede 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -21,6 +21,18 @@ declare module 'cordis' { } } +/** + * A backend-resolved, per-session local artifact location. The path is an + * absolute target path and can name an artifact that has not materialized yet. + * Consumers must treat it as a location hint, never as an authorization token. + */ +export interface SessionLocation { + /** Backend-specific artifact kind, for example `jsonl`. */ + readonly kind: string + /** Absolute path to this session's backend-owned artifact. */ + readonly path: string +} + /** * Durable append-only session storage. Implementations preserve contiguous, * losslessly JSON-serializable events; {@link append} resolves only after @@ -32,6 +44,15 @@ export abstract class SessionPersistence extends Service { super(ctx, 'sessionPersistence') } + /** + * Resolve this backend's independent local artifact for a session without + * reading, creating, flushing, or otherwise materializing it. Backends such + * as SQLite that do not own one artifact per session return `undefined`. + * @param meta - the immutable session header whose artifact is requested. + * @returns the backend-specific absolute location, when one exists. + */ + abstract locate(meta: SessionHeader): SessionLocation | undefined + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index a937a39da7..5a0ec2d3c3 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -41,6 +41,10 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend // --- service surface (delegated to the coordinator) --- + locate(_meta: SessionHeader): undefined { + return undefined + } + create(m: SessionHeader): Promise { return this.coordinator.create(m) } diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index bb7d01ec73..733b746a6a 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -34,6 +34,10 @@ class TestPersistence extends SessionPersistence { this.afterList = undefined } + locate(_meta: SessionHeader): undefined { + return undefined + } + create(meta: SessionHeader): Promise { TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) return Promise.resolve() diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index eb22a9f95c..d897a5bf67 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -44,6 +44,10 @@ class TracePersistence extends SessionPersistence { this.afterList = undefined } + locate(_meta: SessionHeader): undefined { + return undefined + } + create(meta: SessionHeader): Promise { TracePersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) return Promise.resolve() diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 9449a40ec5..8034441272 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -12,7 +12,7 @@ Requires `ctx.skills` (`inject: ['skills']`). | Field | Default | Meaning | |---|---|---| -| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; scans `skills` under this directory. | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md); scans `skills` under this directory. | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. | | `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. | diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index d1ca775a26..d490438c51 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -23,6 +23,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -32,6 +33,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 19a15f1de8..ee109fbb16 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -17,6 +17,7 @@ import z from 'schemastery' import type Schema from 'schemastery' import { parse as parseYaml } from 'yaml' import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs' +import { resolveDshHome } from '@deepseek-ai/dsh-home' import { isSkillName, type SkillCandidate, @@ -92,7 +93,7 @@ export class LocalSkillProvider implements SkillProvider { private readonly customSkillDirs: string[] constructor(private readonly ctx: Context, config: Config = {}) { - this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')) + this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) } diff --git a/packages/skill/skill-local/tsconfig.json b/packages/skill/skill-local/tsconfig.json index 018f0a4a50..f51147abce 100644 --- a/packages/skill/skill-local/tsconfig.json +++ b/packages/skill/skill-local/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, + { "path": "../../util/home" }, { "path": "../../fs/fs" }, { "path": "../skill" } ] diff --git a/packages/util/README.md b/packages/util/README.md index fe077d2624..4b4f23ddf7 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -5,12 +5,15 @@ Zero-dependency primitives shared across the other groups. A package lands here | Package | Role | |---|---| | `brand/` | The type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) | +| `home/` | Canonical `DSH_HOME` resolution from explicit config, environment, or `~/.dsh` (no harness deps) | | `paths/` | Shared filesystem path constants and helpers for harness user data | | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | | `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. +`dsh-home` gives every package the same configurable Harness home without assigning that cross-cutting fact to bash, skills, or a composition bundle. It resolves an explicit value before `$DSH_HOME`, falls back to `~/.dsh`, and returns an absolute path without caching, creating, or mutating anything. + `dsh-timeout` follows the same shape for the timeout family: `dsh-bash` and `dsh-web-fetch-local` each fuse a caller's cancellation with a deadline and later classify "timed out" vs "cancelled" by depending on `dsh-timeout` alone. It deliberately owns only the timing/classification half — the *termination* (SIGKILL a process group, tear down a fetch socket) stays in each capability, because no shared layer can own every capability's kill (see [the timeout-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)). `dsh-retention` is the same split for bounded tool output: a tool (`glob`/`grep`/`bash`/`web_fetch`/`web_search`) feeds items or text into a retainer and gets back what it kept and exactly what it omitted — while grouping, exit codes, provider errors, and recovery prose stay tool-owned. It deliberately owns only the retention mechanic; `truncated` is a budget fact, never an "incomplete inspection" state (see [the retention-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-tool-result-retention-library.md)). diff --git a/packages/util/home/README.md b/packages/util/home/README.md new file mode 100644 index 0000000000..f9107a8706 --- /dev/null +++ b/packages/util/home/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-home + +`@deepseek-ai/dsh-home` is the single owner of DeepSeek Harness home-directory resolution. `resolveDshHome(configured?)` returns an absolute path using this precedence: + +1. The explicit `configured` path. +2. The `DSH_HOME` environment variable. +3. The `.dsh` directory under the current user's home directory. + +The resolver reads its inputs at call time. It does not cache a result, create the directory, or mutate `process.env`; consumers keep ownership of their own configuration fields and pass the configured value when resolving the shared home. + +## Model Experience + +Indirectly, through `dsh-tool-bash`, which exposes the resolved path to model bash as `DSH_HOME` without adding a prompt section. + +## Known Limitations and Deferred Work + +- **Resolution only** — the resolver makes a path absolute but does not create it, check access, or canonicalize symlinks; each consumer owns those filesystem decisions. diff --git a/packages/util/home/package.json b/packages/util/home/package.json new file mode 100644 index 0000000000..efeaf4832c --- /dev/null +++ b/packages/util/home/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-home", + "description": "Canonical DeepSeek Harness home-directory resolver", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/util/home/src/index.ts b/packages/util/home/src/index.ts new file mode 100644 index 0000000000..4e3d56b54b --- /dev/null +++ b/packages/util/home/src/index.ts @@ -0,0 +1,23 @@ +/** + * Canonical DeepSeek Harness home-directory resolution. + * + * @module @deepseek-ai/dsh-home + */ + +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +const DEFAULT_DSH_HOME_DIRNAME = '.dsh' + +/** Environment variable that overrides the default Harness home directory. */ +export const DSH_HOME_ENV = 'DSH_HOME' as const + +/** + * Resolve the DeepSeek Harness home directory without caching or mutating the environment. + * + * @param configured - Optional configured path, which takes precedence over the environment. + * @returns The absolute configured path, `$DSH_HOME`, or `~/.dsh`, in that order. + */ +export function resolveDshHome(configured?: string): string { + return resolve(configured ?? process.env[DSH_HOME_ENV] ?? join(homedir(), DEFAULT_DSH_HOME_DIRNAME)) +} diff --git a/packages/util/home/tests/home.spec.ts b/packages/util/home/tests/home.spec.ts new file mode 100644 index 0000000000..3ebde50bee --- /dev/null +++ b/packages/util/home/tests/home.spec.ts @@ -0,0 +1,26 @@ +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home' + +afterEach(() => vi.unstubAllEnvs()) + +describe('resolveDshHome', () => { + it('prefers an explicit configured path and resolves it absolutely', () => { + vi.stubEnv(DSH_HOME_ENV, './environment-home') + + expect(resolveDshHome('./configured-home')).toBe(resolve('./configured-home')) + }) + + it('uses DSH_HOME when no configured path is supplied', () => { + vi.stubEnv(DSH_HOME_ENV, './environment-home') + + expect(resolveDshHome()).toBe(resolve('./environment-home')) + }) + + it('defaults to the .dsh directory under the user home', () => { + vi.stubEnv(DSH_HOME_ENV, undefined) + + expect(resolveDshHome()).toBe(join(homedir(), '.dsh')) + }) +}) diff --git a/packages/util/home/tsconfig.json b/packages/util/home/tsconfig.json new file mode 100644 index 0000000000..9770ef25d6 --- /dev/null +++ b/packages/util/home/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f835f475ce..24b19fb52e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../bash-local + '@deepseek-ai/dsh-home': + specifier: workspace:^ + version: link:../../util/home '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -173,6 +176,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -556,6 +565,9 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local + '@deepseek-ai/dsh-home': + specifier: workspace:^ + version: link:../../util/home '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -853,6 +865,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -893,6 +911,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -1163,6 +1187,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs + '@deepseek-ai/dsh-home': + specifier: workspace:^ + version: link:../../util/home '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -1918,6 +1945,12 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/home: + devDependencies: + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/paths: devDependencies: cordis: @@ -2204,6 +2237,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-home': + specifier: workspace:^ + version: link:../../packages/util/home '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../../packages/hooks/hook-protocol diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a9c0055a02..c456fae848 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -23,6 +23,7 @@ "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-hooks-claude": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0a1b450b2e..1e182c37c0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -101,7 +101,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable session persistence seam', mode: 'seam', implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], - consumers: ['agent-loop', 'acp', 'session-query'], + consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, { @@ -170,6 +170,13 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'], note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.', }, + { + key: 'bashEnv', + pkg: 'tool-bash', + title: 'Managed bash environment registry', + mode: 'core', + note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.', + }, { key: 'sandbox', pkg: 'sandbox', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 7530d07cf7..78af0eabe8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -47,6 +47,7 @@ { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionLocation", "source": "packages/session-persistence/session-persistence/src/index.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, @@ -90,6 +91,8 @@ { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalPolicy", "source": "packages/ui/user-approval/src/index.ts" }, { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironmentKey", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironment", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index e5d1b140a8..ba3b2b2cc0 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -71,6 +71,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, 'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' }, 'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, + 'packages/util/home': { kind: 'indirect', reason: 'Only dsh-tool-bash exposes the resolved home to model commands.' }, 'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' }, 'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' }, 'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index f8ad4f99f1..678c8cd818 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -11,6 +11,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/home" }, { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" }, diff --git a/tsconfig.json b/tsconfig.json index 7ab3c01e64..2f433786bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/home" }, { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" },