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:
@@ -402,17 +402,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
loadingIds.add(params.sessionId)
|
||||
try {
|
||||
// Validate the PERSISTED cwd BEFORE resuming — `list()` is a
|
||||
// metadata-only read (no full-log parse) — so a mismatch rejects
|
||||
// without ever constructing/registering a live agent (which would
|
||||
// then leak in `ctx.agents`/`ctx.sessions` with no disposer here).
|
||||
// A session persisted in workspace A must not be loaded by a server
|
||||
// launched in workspace B: it would replay A's history while tools run
|
||||
// in B. (If the id is unknown to `list()`, fall through to resume,
|
||||
// which rejects with the backend's not-found error.)
|
||||
// metadata-only read (no full-log parse), so this rejects a session we
|
||||
// can't honor WITHOUT ever constructing/registering an agent (a
|
||||
// post-resume reject would leak the registered agent — abort() does not
|
||||
// unregister it — and wedge the id against re-load). The session's bash
|
||||
// workdir is derived from its persisted `header.cwd` and the request
|
||||
// `cwd` does NOT override it (resume takes no cwd), so a session with no
|
||||
// absolute persisted cwd would silently run bash in the SERVER's launch
|
||||
// dir, not the client's workspace. A session created by this bridge
|
||||
// always has a cwd (session/new requires it); reject the rest loudly.
|
||||
// (An id unknown to `list()` falls through to resume, which rejects with
|
||||
// the backend's not-found error.)
|
||||
const meta = (await ctx.sessionPersistence.list()).find(m => m.id === params.sessionId)
|
||||
if (meta?.cwd !== undefined && meta.cwd !== process.cwd()) {
|
||||
if (meta !== undefined && (meta.cwd === undefined || !isAbsolute(meta.cwd))) {
|
||||
throw invalidParams(
|
||||
`session was created in ${meta.cwd}, but the server's launch directory is ${process.cwd()}; honoring a different cwd is not yet supported — launch the server in the session's workspace`,
|
||||
`session ${params.sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`,
|
||||
)
|
||||
}
|
||||
const agent = await ctx.agents.resume({
|
||||
@@ -600,33 +604,27 @@ export function agentOptions(config: AcpConfig): { model?: string; systemPrompt?
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate `session/new` params per the MVP contract: `cwd` absolute AND equal
|
||||
* to the server's launch directory (there is no path from session cwd to the
|
||||
* bash workdir yet — RFC 010 § Deferred — so the server must be launched in the
|
||||
* workspace root, and we error loudly rather than silently run tools in the
|
||||
* wrong directory); `additionalDirectories` empty (we cannot widen filesystem
|
||||
* scope yet, and silently ignoring them would desync the client's scope UI).
|
||||
*/
|
||||
/**
|
||||
* Validate the MVP `cwd`/`additionalDirectories` contract shared by
|
||||
* `session/new` and `session/load`: `cwd` must be absolute AND equal the
|
||||
* server's launch directory (there is no path from session cwd to the bash
|
||||
* workdir yet — RFC 010 § Deferred — so the server must be launched in the
|
||||
* workspace root, and we error loudly rather than silently run tools in the
|
||||
* wrong directory); `additionalDirectories` must be empty (we cannot widen
|
||||
* filesystem scope yet, and silently ignoring it would desync the client's
|
||||
* scope UI). Both request shapes carry `cwd: string` and
|
||||
* Validate the `cwd`/`additionalDirectories` contract shared by `session/new`
|
||||
* and `session/load`: `cwd` must be absolute (a relative path would be ambiguous
|
||||
* as a workspace root). What the cwd is USED for differs by method, and this
|
||||
* validator only enforces shape:
|
||||
* - `session/new`: the validated `cwd` becomes the session's `SessionHeader.cwd`
|
||||
* (via `agents.create({meta:{cwd}})`) and thus the default bash workdir.
|
||||
* - `session/load`: the request `cwd` is shape-checked only; the RESUMED
|
||||
* session keeps its PERSISTED `header.cwd`, which stays authoritative for the
|
||||
* bash workdir — the request cwd does not override it.
|
||||
* Any absolute path is accepted (the per-session cwd flows to the bash executor
|
||||
* — see `dsh-tool-bash`), so the server no longer has to launch in the
|
||||
* workspace. `additionalDirectories` must still be empty: widening the
|
||||
* tool/filesystem scope beyond the single cwd is a separate, unimplemented
|
||||
* concern (a sandbox seam), and silently ignoring extra roots would desync the
|
||||
* client's filesystem-scope UI. Both request shapes carry `cwd: string` and
|
||||
* `additionalDirectories?: string[]`, so one validator covers both.
|
||||
*/
|
||||
function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void {
|
||||
if (!isAbsolute(params.cwd)) {
|
||||
throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
||||
}
|
||||
if (params.cwd !== process.cwd()) {
|
||||
throw invalidParams(
|
||||
`cwd must equal the server's launch directory (${process.cwd()}); honoring an arbitrary cwd is not yet supported — launch the server in the workspace root`,
|
||||
)
|
||||
}
|
||||
if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
|
||||
throw invalidParams('additionalDirectories is not supported in this MVP')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user