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
+9 -3
View File
@@ -65,13 +65,19 @@ describe('acp bridge', () => {
expect(harness.ctx.agents.get(b.sessionId)).toBeDefined()
})
it('rejects a non-absolute cwd and a cwd that differs from the launch dir', async () => {
it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
// Relative cwd is still rejected (it becomes the session header / bash workdir).
await expect(harness.client.newSession({ cwd: 'relative/path', mcpServers: [] }))
.rejects.toThrow(/absolute/)
await expect(harness.client.newSession({ cwd: '/some/other/dir', mcpServers: [] }))
.rejects.toThrow(/launch directory/)
// An absolute cwd that differs from the server launch dir is now ACCEPTED —
// the per-session cwd is honored (routed to the bash workdir), so the server
// no longer has to launch in the workspace.
const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] })
expect(res.sessionId).toBeTruthy()
// The session header records that cwd, so its bash tools run there.
expect(harness.ctx.agents.get(res.sessionId)!.session.header.cwd).toBe('/tmp')
})
it('rejects non-empty additionalDirectories', async () => {