Merge remote-tracking branch 'origin/master' into cross-family-fs-sandbox

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md
#	.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md
#	docs/capability-seams.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/persistence-catalog.md
#	docs/rfc/INDEX.md
#	examples/acp-agent/README.md
#	examples/acp-agent/fs.cordis.snapshot.yml
#	examples/acp-agent/fs.cordis.yml
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md
#	examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json
#	examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md
#	examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json
#	packages/bash/bash/src/index.ts
#	packages/bash/tool-bash/package.json
#	packages/bash/tool-bash/src/index.ts
#	packages/bash/tool-bash/tests/tools.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/fs/README.md
#	packages/fs/tool-fs/src/edit.ts
#	packages/fs/tool-fs/src/write.ts
#	packages/sandbox/README.md
#	pnpm-lock.yaml
This commit is contained in:
kingwl
2026-07-20 11:40:29 +08:00
1293 changed files with 74019 additions and 16297 deletions

View File

@@ -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')
})
})

View File

@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
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 type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
@@ -19,23 +20,26 @@ 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 ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
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
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
const dirs: string[] = []
afterEach(() => {
vi.unstubAllEnvs()
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -46,7 +50,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function events(agent: ReactLoopAgent): SessionEvent[] {
function events(agent: Agent): SessionEvent[] {
return [...agent.session.events]
}
@@ -82,13 +86,45 @@ async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise<v
}
describe('bash tool through the agent loop', () => {
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({
sessionId: SessionId('session-env-id'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent
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.'),
textResponse('The command printed integration-ok.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-fg'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run echo integration-ok' }])
await waitForIdle(ctx, agent)
@@ -120,7 +156,7 @@ describe('bash tool through the agent loop', () => {
textResponse('It failed with code 9.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-exit'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run exit 9' }])
await waitForIdle(ctx, agent)
@@ -140,7 +176,7 @@ describe('bash tool through the agent loop', () => {
textResponse('Background task finished.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
await waitForIdle(ctx, agent)

View File

@@ -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, { SessionId } 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'
@@ -48,18 +50,17 @@ async function setupWithTasks() {
}
/**
* Build a fake {@link Agent} whose session token is `sessionId`, give it a
* Build a fake {@link Agent} with the shared agent/session identity, give it a
* dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
* The agent id is deliberately different from the session token so a
* wrong-field ownership match fails the test.
*/
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
const scopeFiber = ctx.plugin(() => {})
const id = SessionId(sessionId)
const agent = {
id: `agent-${sessionId}`,
id,
ctx: scopeFiber.ctx,
inject,
session: { header: { version: 0, id: sessionId, createdAt: 0 } },
session: { id, header: { version: 0, id, createdAt: 0 } },
} as unknown as Agent
ctx.agents.register(agent)
return agent
@@ -101,6 +102,7 @@ class RecordingSandboxExecutor extends BashExecutor {
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
timeoutMs: request.timeoutMs ?? 1000,
...request.signal ? { signal: request.signal } : {},
sandboxMode: request.sandboxMode ?? 'read-only',
@@ -140,7 +142,13 @@ class CountingStartExecutor extends BashExecutor {
starts = 0
resolve(request: BashExecRequest): BashExecSpec {
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0, sandboxMode: request.sandboxMode }
return {
command: request.command,
workdir: request.workdir ?? '/x',
timeoutMs: request.timeoutMs ?? 0,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
sandboxMode: request.sandboxMode,
}
}
run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
@@ -174,11 +182,13 @@ async function setupSandboxed(withApproval = false) {
function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', ctx?: Context): Agent {
const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
const id = SessionId('sandbox-session')
return {
id: 'sandbox-agent',
id,
...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx },
session: {
header: { version: 0, id: 'sandbox-session', createdAt: 0 },
id,
header: { version: 0, id, createdAt: 0 },
events,
append: (type: string, data: Record<string, unknown>) => {
const event = { type, data }
@@ -277,7 +287,7 @@ describe('bash tool', () => {
})
// Type and required-key violations are rejected by the harness
// (defineTool validates against the SchemaSpec — the arg-validation RFC) before execute.
// (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute.
it.each([
[{}, /missing required property "command"/],
[{ command: 42, description: 'd' }, /"command" must be a string/],
@@ -924,16 +934,19 @@ 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`
* tool does not expose `stdin`/`env` as parameters (bash syntax already gives a
* model that power), so it must build its request from named args only and
* tool does not expose trusted-plugin fields (`stdoutMaxBytes`, `stdin`, or
* `env`) as parameters, so it must build its request from named args only and
* never spread unknown tool-call keys into it. This guard's job is to catch a
* future refactor that blindly forwards `...args` — which would silently thread
* model input into the post-scrub `env` merge — NOT to defend a trust boundary
* model input into the post-scrub `env` merge or per-run capture budget — NOT
* to defend a trust boundary
* (the credential scrub in dsh-bash-local is the security control; see the
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()`
* bash-stdin-env Agent Note). Foreground `run()` returns a canned result; `start()`
* hands back an already-settled fake handle so the task registration completes.
*/
class RecordingBashExecutor extends BashExecutor {
@@ -944,9 +957,11 @@ describe('the model-facing bash tool builds its request from named args only (no
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 0,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...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,
}
}
@@ -968,19 +983,127 @@ 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('does not forward env/stdin even when the model includes them as extra arguments', async () => {
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.
// This preserves the request shape; it is not a security boundary because shell syntax can
@@ -993,6 +1116,7 @@ describe('the model-facing bash tool builds its request from named args only (no
description: 'echo',
env: { SNEAKY_API_KEY: 'leak' },
stdin: 'malicious payload',
stdoutMaxBytes: 999_999,
},
})
expect(bash.requests).toHaveLength(1)
@@ -1000,9 +1124,10 @@ describe('the model-facing bash tool builds its request from named args only (no
expect(request.command).toBe('echo hi')
expect('env' in request).toBe(false)
expect('stdin' in request).toBe(false)
expect('stdoutMaxBytes' in request).toBe(false)
})
it('a background bash call likewise carries no env/stdin', async () => {
it('a background bash call likewise carries no trusted-only fields', async () => {
const { ctx, bash } = await setupRecording()
const result = await ctx.tools.execute({
callId: CallId('no-forward-2'),
@@ -1013,6 +1138,7 @@ describe('the model-facing bash tool builds its request from named args only (no
run_in_background: true,
env: { TOKEN: 'leak' },
stdin: 'x',
stdoutMaxBytes: 999_999,
},
})
// The call really went down the background path (the recorder sees the real
@@ -1024,5 +1150,6 @@ describe('the model-facing bash tool builds its request from named args only (no
expect(request.command).toBe('sleep 1')
expect('env' in request).toBe(false)
expect('stdin' in request).toBe(false)
expect('stdoutMaxBytes' in request).toBe(false)
})
})