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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user