fix: use unified identity for bash ownership

This commit is contained in:
Tianyi Cui
2026-07-14 07:26:46 +08:00
parent c2979322c8
commit 99b5f04c9b
8 changed files with 40 additions and 47 deletions
+22 -24
View File
@@ -47,22 +47,16 @@ async function setup() {
}
/**
* Build a fake {@link Agent} whose session token is `sessionId`, REGISTER it in
* `ctx.agents` (the completion-notice path finds the owning agent by scanning
* the registry for a matching `session.header.id`), and return it. The returned
* Build a fake {@link Agent} with the shared registry/session `sessionId`,
* REGISTER it in `ctx.agents`, and return it. The returned
* agent is also passed to `execute` as `exec.agent` so it owns the spawned task.
* The registration disposer is tracked so {@link unregisterFakeAgents} can drop
* it (simulating the owning session disconnecting before a task completes).
*/
const fakeAgentDisposers = new Map<Context, (() => Promise<void> | void)[]>()
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent {
// The registry KEY (agent.id) is deliberately DIFFERENT from the session
// token (session.header.id), which is also the agent's durable id. The
// owner token IS the session id, so the notice path must find the agent by
// `session.header.id`, NOT the registry key. Using distinct values here makes
// the test fail if a regression matched on the wrong field (a same-value fake
// would pass either way — the "hits the line but not the scenario" trap).
const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
const id = SessionId(sessionId)
const agent = { id, inject, session: new Session(id) } as unknown as Agent
const dispose = ctx.agents.register(agent)
const list = fakeAgentDisposers.get(ctx) ?? []
list.push(dispose)
@@ -418,9 +412,9 @@ describe('background tools', () => {
it('injects a completion notice into the owning agent (found via the registry by session token)', async () => {
const ctx = await setup()
const inject = vi.fn()
// The notice path looks the agent up in ctx.agents by its session token, so
// The notice path looks the agent up in ctx.agents by its shared id, so
// the agent must be REGISTERED (not merely passed to execute). Mount a
// registry and register a fake whose session.header.id IS the owner token.
// registry and register a fake whose agent/session id IS the owner token.
const agent = registerFakeAgent(ctx, 'bg', inject)
const started = await ctx.tools.execute({
@@ -517,13 +511,14 @@ describe('background task ownership (cross-session isolation)', () => {
function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) {
return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
}
// Ownership is by TOKEN (session.header.id), NOT agent object identity — so
// each agent needs a DISTINCT session id, else every fake yields the same
// Ownership is by the shared agent/session TOKEN, NOT agent object identity —
// so each agent needs a DISTINCT id, else every fake yields the same
// token and the isolation tests pass for the wrong reason (all tasks owned by
// the same token). The impl reads `session.header.id`, so the fakes MUST carry
// it.
const fakeAgent = (sessionId: string) =>
({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
// the same token).
const fakeAgent = (sessionId: string) => {
const id = SessionId(sessionId)
return { id, inject: () => undefined, session: new Session(id) } as unknown as import('@deepseek-ai/dsh-agent').Agent
}
it('rejects bash_output/bash_kill for a task owned by a DIFFERENT session token', async () => {
const ctx = await setup()
@@ -548,7 +543,7 @@ describe('background task ownership (cross-session isolation)', () => {
})
it('a DIFFERENT Agent object with the SAME session token may access the task (ownership is by token, not object identity)', async () => {
// Ownership fences by session.header.id, NOT Agent object identity. Two
// Ownership fences by the shared id, NOT Agent object identity. Two
// distinct Agent objects sharing one session token (e.g. an agent re-created
// on the same session) are the SAME owner.
const ctx = await setup()
@@ -638,8 +633,10 @@ describe('session-cwd routing (per-session workdir)', () => {
return ctx.tools.execute({ callId: CallId(`cwd-${++callCounter}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
}
// An agent whose session header carries a cwd (what session/new records).
const agentInCwd = (cwd: string) =>
({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
const agentInCwd = (cwd: string) => {
const id = SessionId('c')
return { id, inject: () => undefined, session: { header: { version: 0, id, createdAt: 0, cwd } } } as unknown as import('@deepseek-ai/dsh-agent').Agent
}
it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => {
const ctx = await setup()
@@ -1188,10 +1185,11 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => {
* enforces the enclosure.
*/
function escalationAgent(events: Array<{ type: string; data: Record<string, unknown> }>): Agent {
const id = SessionId('sess-esc')
return {
id: 'agent-esc',
id,
session: {
header: { version: 0, id: 'sess-esc', createdAt: 0 },
header: { version: 0, id, createdAt: 0 },
events: [{ type: 'turn/start' }],
append: (type: string, data: Record<string, unknown>) => { events.push({ type, data }) },
},
@@ -1406,7 +1404,7 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const injected: string[] = []
const agent = {
id,
id: SessionId(id),
session,
inject: (content: { type: string; text: string }[]) => { injected.push(content[0]?.text ?? '') },
} as unknown as Agent