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:
@@ -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 () => {
|
||||
|
||||
@@ -85,11 +85,11 @@ describe('acp bridge — session/load replay', () => {
|
||||
expect(loader.ctx.agents.get(sessionId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects load when the persisted session cwd differs from the launch dir', async () => {
|
||||
it('loads a session whose persisted cwd differs from the launch dir (honors per-session cwd)', async () => {
|
||||
// Seed a session on disk whose header.cwd is a DIFFERENT absolute path than
|
||||
// the server's launch dir, then load it requesting the launch cwd (so the
|
||||
// request-cwd check passes). The bridge must still reject on the persisted
|
||||
// header cwd — else it would replay that session while tools run here.
|
||||
// the server's launch dir. The bridge must LOAD it (per-session cwd is
|
||||
// honored — the resumed session keeps header.cwd, and bash routes there), no
|
||||
// longer reject on a mismatch.
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
const otherCwd = '/some/other/workspace'
|
||||
await loader.ctx.sessionPersistence.create({
|
||||
@@ -101,23 +101,41 @@ describe('acp bridge — session/load replay', () => {
|
||||
])
|
||||
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/created in \/some\/other\/workspace/)
|
||||
// The rejected load must NOT have constructed/registered a live agent (the
|
||||
// cwd is validated from persisted metadata BEFORE resume) — no leak.
|
||||
expect(loader.ctx.agents.get('elsewhere')).toBeUndefined()
|
||||
// And a fresh newSession still works (the connection is not wedged).
|
||||
const ok = await loader.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(ok.sessionId).toBeTruthy()
|
||||
// Load succeeds even though the requested cwd is the launch dir, not otherCwd.
|
||||
const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res).toBeDefined()
|
||||
// The resumed session retains its ORIGINAL workspace cwd (so bash runs there).
|
||||
expect(loader.ctx.agents.get('elsewhere')!.session.header.cwd).toBe(otherCwd)
|
||||
})
|
||||
|
||||
it('rejects load for a non-absolute or mismatched cwd', async () => {
|
||||
it('rejects load for a non-absolute cwd (still required to be absolute)', async () => {
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(loader.client.loadSession({ sessionId: 's', cwd: 'rel', mcpServers: [] }))
|
||||
.rejects.toThrow(/absolute/)
|
||||
await expect(loader.client.loadSession({ sessionId: 's', cwd: '/other', mcpServers: [] }))
|
||||
.rejects.toThrow(/launch directory/)
|
||||
})
|
||||
|
||||
it('rejects loading a persisted session that has NO cwd (would silently run in the launch dir)', async () => {
|
||||
// A legacy / externally-created session log with no header.cwd. The bridge
|
||||
// must reject the load rather than accept it and let bash silently fall back
|
||||
// to the server's launch dir (the request cwd does not override the header).
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.ctx.sessionPersistence.create({
|
||||
version: 1, id: SessionId('legacy'), createdAt: 1, updatedAt: 1, // no cwd
|
||||
})
|
||||
await loader.ctx.sessionPersistence.append(SessionId('legacy'), [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
])
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/no absolute persisted cwd/)
|
||||
// Rejected BEFORE resume (metadata-only check) — no agent was registered, so
|
||||
// the id is not wedged: a later attempt hits the same clean rejection, not a
|
||||
// duplicate-registration error.
|
||||
expect(loader.ctx.agents.get('legacy')).toBeUndefined()
|
||||
await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/no absolute persisted cwd/)
|
||||
})
|
||||
|
||||
it('allows loading alongside an existing session but rejects re-loading the SAME id', async () => {
|
||||
|
||||
Reference in New Issue
Block a user