refactor(bash): centralize managed env prefix

This commit is contained in:
Yichen Jiang
2026-07-12 16:14:13 +08:00
parent df9617aaff
commit 48e7bde361
8 files changed
+51 -34

No files matched your search

+2 -2
View File
@@ -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)
@@ -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.
+3 -2
View File
@@ -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`)
}
}
+1 -1
View File
@@ -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).
+2 -1
View File
@@ -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' {
+7 -1
View File
@@ -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<Record<`DSH_${string}`, string>>
export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
/**
* Brand a string as a {@link BashTaskId}.
+28 -23
View File
@@ -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<Record<`DSH_${string}`, BashEnvVariable>>
variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>
/**
* 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<Partial<Record<`DSH_${string}`, string>>>
resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>
}
/** 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<DshEnvironmentKey>([
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<string, BashEnvContributor>()
private readonly keyOwners = new Map<`DSH_${string}`, string>()
private readonly keyOwners = new Map<DshEnvironmentKey, string>()
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<DshEnvironmentKey, string> = {
[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> 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 } : {}
},
})
@@ -535,7 +535,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'BashEnvContributor',
declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly<Record<`DSH_${string}`, BashEnvVariable>>;\n resolve(execution: ToolExecution): Readonly<Partial<Record<`DSH_${string}`, string>>>;\n}',
declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>;\n resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>;\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<Record<`DSH_${string}`, string>>;',
declaration: 'export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>;',
},
{
name: 'DshEnvironmentKey',
declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;',
},
{
name: 'FileDiff',