feat(acp): honor per-session cwd — run each ACP session in its own workspace

Lifts the RFC 010 § Deferred restriction that the server had to launch in the
workspace ("cwd must equal the launch directory"). An editor can now open any
project folder, and N concurrent sessions over one connection can each target a
different directory.

- packages/acp: drop the `cwd === process.cwd()` guard in validateWorkspaceParams
  (keep "must be absolute" — the cwd becomes the session header / bash workdir),
  and drop the persisted-cwd-vs-launch-dir check in session/load (a resumed
  session keeps its original header.cwd, so its bash tools run in its workspace).
- packages/tool-bash: the missing link — default the bash workdir to the calling
  agent's session cwd (`exec.agent.session.header.cwd`) via a new resolveWorkdir
  helper. An explicit model `workdir` still wins; a relative one resolves against
  the session cwd. This is the only correct spot for multi-session: N sessions
  share one ctx.bash executor, so the workdir must come per-call from exec.agent,
  not executor config. Falls back to the executor default when no session cwd is
  available (preserves non-ACP behavior).
- Trust: the cwd originates from the ACP client (the user's editor) at
  session/new — same trust level as the old launch dir; no new untrusted-input
  path. `additionalDirectories` (scope widening / sandbox) stays rejected.
- Tests: bridge accepts any absolute cwd + records it on the header; session/load
  honors the persisted cwd; bash defaults to / resolves relative against the
  session cwd; two sessions with different cwds each run bash in their own dir;
  non-absolute cwd still rejected. 100% per-file coverage maintained.
- Docs: RFC 010 status + § Deferred cwd bullet marked RESOLVED; acp README adds a
  Per-session cwd section; tool-bash + example READMEs and e2e comments updated.
This commit is contained in:
Tianyi Cui
2026-06-17 10:01:18 +08:00
parent 5e3df1b2f5
commit f3906af225
10 changed files with 165 additions and 66 deletions

View File

@@ -13,10 +13,10 @@ Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`);
| `command` | string (required) | Run via `bash -c`. No state persists between calls — use `workdir`, not `cd`. |
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
| `timeoutMs` | number | Default/max from executor config (120s/600s for bash-local). |
| `workdir` | string | Working directory for this call. |
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
`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.
`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()`.
Result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: <path>]` when the tail was kept. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.

View File

@@ -37,6 +37,7 @@
*/
import type { Context } from 'cordis'
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
@@ -122,6 +123,26 @@ export function renderResult(result: BashRunResult): string {
return body + markers.join('\n')
}
/**
* Resolve the working directory for a bash call. Precedence: an explicit model
* `workdir` wins; otherwise default to the calling agent's session cwd
* (`session.header.cwd`) so each ACP session's commands run in ITS workspace,
* not the server's launch dir. A RELATIVE model `workdir` is resolved against
* the session cwd (the tool tells the model to pass `workdir` instead of `cd`,
* so a relative one should be relative to the session's root, not `process.cwd()`).
* Returns `undefined` when neither is available (no agent / headerless session /
* no session cwd) — the executor then applies its own config/`process.cwd()`
* default, preserving today's non-ACP behavior.
*/
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
const sessionCwd = exec.agent?.session.header.cwd
if (modelWorkdir === undefined) return sessionCwd
if (sessionCwd !== undefined && !isAbsolute(modelWorkdir)) {
return resolvePath(sessionCwd, modelWorkdir)
}
return modelWorkdir
}
/** Status line for background task reads. */
function statusLine(task: BashTask): string {
switch (task.status) {
@@ -193,7 +214,7 @@ export function apply(ctx: Context): void {
+ '"git status" → "Show working tree status"; "npm install" → "Install package dependencies".',
},
timeoutMs: { type: 'number', description: 'Timeout in milliseconds (default 120000, max 600000). The command is killed on expiry.' },
workdir: { type: 'string', description: 'Working directory for this command.' },
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
run_in_background: { type: 'boolean', description: 'Run in the background and return a task id immediately. No timeout applies.' },
},
async execute(args, exec) {
@@ -201,9 +222,13 @@ export function apply(ctx: Context): void {
// `description` is display/logging metadata only (surfaced to UIs via
// the tool/call session event); it is intentionally NOT forwarded to
// ctx.bash and has no effect on execution.
// Default the workdir to the calling agent's session cwd so each ACP
// session runs in its own workspace (see resolveWorkdir); an explicit
// model workdir still wins.
const workdir = resolveWorkdir(args.workdir, exec)
const request = {
command: args.command,
...args.workdir !== undefined ? { workdir: args.workdir } : {},
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
...exec.signal ? { signal: exec.signal } : {},
}

View File

@@ -265,7 +265,7 @@ describe('background tools', () => {
it('injects a completion notice into the owning agent', async () => {
const ctx = await setup()
const inject = vi.fn()
const agent = { inject } as unknown as import('@deepseek-ai/dsh-agent').Agent
const agent = { inject, session: { header: { version: 1, id: 'bg', createdAt: 0 } } } as unknown as import('@deepseek-ai/dsh-agent').Agent
const started = await ctx.tools.execute({
callId: CallId('call-bg'),
@@ -290,6 +290,7 @@ describe('background tools', () => {
const ctx = await setup()
const agent = {
inject: () => { throw new Error('agent "x" is disposed') },
session: { header: { version: 1, id: 'bg', createdAt: 0 } },
} as unknown as import('@deepseek-ai/dsh-agent').Agent
const started = await ctx.tools.execute({
@@ -311,6 +312,7 @@ describe('background tools', () => {
try {
const agent = {
inject: () => { throw new Error('unexpected inject bug') },
session: { header: { version: 1, id: 'bg', createdAt: 0 } },
} as unknown as import('@deepseek-ai/dsh-agent').Agent
const started = await ctx.tools.execute({
@@ -344,7 +346,7 @@ describe('background task ownership (cross-session isolation)', () => {
return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
}
// Distinct identities — ownership is by agent object identity, not id.
const fakeAgent = () => ({ inject: () => undefined }) as unknown as import('@deepseek-ai/dsh-agent').Agent
const fakeAgent = () => ({ inject: () => undefined, session: { header: { version: 1, id: 'bg', createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
it('rejects bash_output/bash_kill for a task owned by a DIFFERENT agent', async () => {
const ctx = await setup()
@@ -438,6 +440,50 @@ describe('background task ownership (cross-session isolation)', () => {
})
})
describe('session-cwd routing (per-session workdir)', () => {
function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, args: unknown) {
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: 1, id: 'c', 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()
const result = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
expect(text(result).trim()).toMatch(/\/tmp$/)
})
it('an explicit absolute workdir overrides the session cwd', async () => {
const ctx = await setup()
const result = await callAs(ctx, agentInCwd('/'), { command: 'pwd', description: 'pwd', workdir: '/tmp' })
expect(text(result).trim()).toMatch(/\/tmp$/)
})
it('a relative workdir is resolved against the session cwd', async () => {
const ctx = await setup()
// session cwd /usr + relative 'bin' → /usr/bin
const result = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd', workdir: 'bin' })
expect(text(result).trim()).toMatch(/\/usr\/bin$/)
})
it('two sessions with different cwds each run bash in their own dir', async () => {
const ctx = await setup()
const inUsr = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd' })
const inTmp = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
expect(text(inUsr).trim()).toMatch(/\/usr$/)
expect(text(inTmp).trim()).toMatch(/\/tmp$/)
})
it('falls back to the executor default when the agent has no session cwd', async () => {
const ctx = await setup()
// No exec.agent at all → executor uses its config/process.cwd() default.
const result = await ctx.tools.execute({ callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } })
expect(result.isError).toBe(false)
expect(text(result).trim().length).toBeGreaterThan(0)
})
})
describe('renderResult', () => {
const base = {
exitCode: 0 as number | null,