From 48e7bde3617dd5c84fb7f1c7692066e9bd530e3c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 12 Jul 2026 16:14:13 +0800 Subject: [PATCH] refactor(bash): centralize managed env prefix --- docs/cordis-catalog/services.md | 4 +- ...agent-session-identity-and-log-location.md | 2 +- packages/bash/bash-local/src/run.ts | 5 +- packages/bash/bash/README.md | 2 +- packages/bash/bash/src/index.ts | 3 +- packages/bash/bash/src/types.ts | 8 ++- packages/bash/tool-bash/src/index.ts | 51 ++++++++++--------- .../cordis/tool-cordis/src/api-catalog.ts | 10 ++-- 8 files changed, 51 insertions(+), 34 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 87ecaa328a..d25379adad 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -79,7 +79,7 @@ onTaskDone(listener: BashTaskListener): () => void Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:63`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:64`](../../packages/bash/bash/src/index.ts) ## `ctx.bashEnv` — `BashEnvRegistry` @@ -93,7 +93,7 @@ list(): BashEnvVariableInfo[] Types: [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/bash/tool-bash/src/index.ts:143`](../../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/tool-bash/src/index.ts:147`](../../packages/bash/tool-bash/src/index.ts) ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) 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 index 35968a3896..9dcd609b50 100644 --- 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 @@ -38,7 +38,7 @@ The registry rebuilds a trusted overlay for every foreground and background bash 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 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 `DSH_*`; the local executor rejects that wrong channel, removes every inherited ambient `DSH_*`, 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 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 ordinary-env rejection. 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; the local executor rejects that wrong channel, 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. diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 47361b4e5e..9cd9ac2553 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -27,6 +27,7 @@ import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash' /** @@ -69,10 +70,10 @@ export function childEnv( ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value + if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value } for (const key of Object.keys(extra ?? {})) { - if (key.startsWith('DSH_')) { + if (key.startsWith(DSH_ENV_PREFIX)) { throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`) } } diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index 11a50c954a..97c2d00faf 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -34,4 +34,4 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal The seam also owns the per-session mode override vocabulary (the sandbox RFC § Per-session mode switching): the log-only `'bash/sandbox-mode'` session event, the pure fold `effectiveSandboxMode(events)` (last event wins; `undefined` means "apply the executor default"), and THE write path `setSandboxMode(session, mode)` — the session log is the store, so an override survives restart by replay and two sessions can never see each other's mode. Writers must respect turn-enclosure: the ACP bridge anchors an idle switch at the next turn rather than appending between turns. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts. -`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 `DSH_*` keys; model bash uses it for the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited `DSH_*`, 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). +`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). diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 6c9acedf20..207f475dcb 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -18,7 +18,7 @@ import { Context, Service } from 'cordis' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts' -export { BashTaskId, OwnerToken } from './types.ts' +export { BashTaskId, DSH_ENV_PREFIX, OwnerToken } from './types.ts' export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' export type { BashExecRequest, @@ -31,6 +31,7 @@ export type { BashTaskStatus, 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 ebc0d36898..479b968cfc 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -12,8 +12,14 @@ import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox' /** Identifies one background task within an executor (generated `bash-N`). */ export type BashTaskId = Branded<'BashTaskId'> +/** 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> +export type DshEnvironment = Readonly> /** * Brand a string as a {@link BashTaskId}. diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 7ebf1621f3..08493d79c0 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -70,8 +70,8 @@ import type {} from '@deepseek-ai/dsh-system-prompt' // stays optional at runtime, same pattern as dsh-tools' ask routing). import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash' -import type { BashRunResult, BashTask, CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash' +import { BashTaskId, DSH_ENV_PREFIX, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash' +import type { BashRunResult, BashTask, CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' declare module 'cordis' { interface Context { @@ -108,13 +108,13 @@ 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> + 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>> + resolve(execution: ToolExecution): Readonly>> } /** An enumerable declaration returned by {@link BashEnvRegistry.list}. */ @@ -122,15 +122,19 @@ export interface BashEnvVariableInfo extends BashEnvVariable { /** Contributor that owns the variable. */ contributor: string /** Declared `DSH_*` environment variable name. */ - key: `DSH_${string}` + key: DshEnvironmentKey } -const RESERVED_BASH_ENV_KEYS = new Set<`DSH_${string}`>([ - 'DSH_HOME', - 'DSH_SHELL', - 'DSH_SESSION_ID', +const DSH_HOME_KEY = `${DSH_ENV_PREFIX}HOME` as const +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_KEY, + DSH_SHELL_KEY, + DSH_SESSION_ID_KEY, ]) -const BASH_ENV_KEY = /^DSH_[A-Z][A-Z0-9_]*$/ +const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ /** * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. @@ -142,7 +146,7 @@ const BASH_ENV_KEY = /^DSH_[A-Z][A-Z0-9_]*$/ */ export class BashEnvRegistry extends Service { private readonly contributors = new Map() - private readonly keyOwners = new Map<`DSH_${string}`, string>() + private readonly keyOwners = new Map() private readonly dshHome: string /** @@ -152,7 +156,7 @@ export class BashEnvRegistry extends Service { */ constructor(ctx: Context, config: Config = {}) { super(ctx, 'bashEnv') - this.dshHome = resolvePath(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')) + this.dshHome = resolvePath(config.dshHome ?? process.env[DSH_HOME_KEY] ?? join(homedir(), '.dsh')) } /** @@ -170,9 +174,10 @@ export class BashEnvRegistry extends Service { throw new Error(`bash env contributor "${contributor.name}" is already registered`) } - const variables = Object.entries(contributor.variables) as [`DSH_${string}`, BashEnvVariable][] + const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][] for (const [key, variable] of variables) { - if (!BASH_ENV_KEY.test(key)) { + 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)) { @@ -203,18 +208,18 @@ export class BashEnvRegistry extends Service { * @returns an immutable environment overlay containing built-ins and current contributions. */ collect(execution: ToolExecution): DshEnvironment { - const values: Record<`DSH_${string}`, string> = { - DSH_HOME: this.dshHome, - DSH_SHELL: '1', + const values: Record = { + [DSH_HOME_KEY]: this.dshHome, + [DSH_SHELL_KEY]: '1', } if (execution.agent !== undefined) { - values.DSH_SESSION_ID = execution.agent.session.header.id + 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 `DSH_${string}` + const key = rawKey as DshEnvironmentKey if (!Object.hasOwn(contributor.variables, key)) { throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) } @@ -237,7 +242,7 @@ export class BashEnvRegistry extends Service { .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({ contributor: contributor.name, description: variable.description, - key: key as `DSH_${string}`, + key: key as DshEnvironmentKey, }))) .sort((left, right) => left.key.localeCompare(right.key)) } @@ -337,7 +342,7 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string { 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_*` variables; inspect them when needed. ' + + `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 (a background task reports the same marker via bash_output once it has finished). ' + '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; ' @@ -579,7 +584,7 @@ export function apply(ctx: Context, config: Config = {}): void { bashEnv.register({ name: 'session-persistence', variables: { - DSH_SESSION_JSONL: { + [DSH_SESSION_JSONL_KEY]: { description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', }, }, @@ -587,7 +592,7 @@ export function apply(ctx: Context, config: Config = {}): void { const agent = execution.agent if (agent === undefined) return {} const location = ctx.get('sessionPersistence')?.locate(agent.session.header) - return location?.kind === 'jsonl' ? { DSH_SESSION_JSONL: location.path } : {} + return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} }, }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 8323d13248..dbdfb880da 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -535,7 +535,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BashEnvContributor', - declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly>;\n resolve(execution: ToolExecution): Readonly>>;\n}', + declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly>;\n resolve(execution: ToolExecution): Readonly>>;\n}', }, { name: 'BashEnvVariable', @@ -543,7 +543,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BashEnvVariableInfo', - declaration: 'export interface BashEnvVariableInfo extends BashEnvVariable {\n contributor: string;\n key: `DSH_${string}`;\n}', + declaration: 'export interface BashEnvVariableInfo extends BashEnvVariable {\n contributor: string;\n key: DshEnvironmentKey;\n}', }, { name: 'BashExecRequest', @@ -659,7 +659,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'DshEnvironment', - declaration: 'export type DshEnvironment = Readonly>;', + declaration: 'export type DshEnvironment = Readonly>;', + }, + { + name: 'DshEnvironmentKey', + declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;', }, { name: 'FileDiff',