Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.11.4] - 2026-08-22

### Fixed

- Session file roots are backend-authoritative: `session/load`·`resume` no
longer adopt the client's cwd as the session root (a remote App derived
its cwd from the hub instance list and fell back to "/" whenever that list
was stale, hijacking the /fs file browser to the filesystem root and — via
`projectCwd()` — poisoning the instance's advertised workspace for every
later load). The root now comes from what the bridge recorded at creation,
corrected by the backend's own `session/resume` result
(`session.workspace.workspacePath`); a client cwd is only consulted at
`session/new`, and "/" is never accepted as a root anywhere.
- `/fs` defense in depth: a session whose recorded root resolves to "/" is
refused (403) — a polluted record can never widen remote file access to
the whole filesystem.
- `projectCwd()` picks the most recently active session's cwd (insertion
order was arbitrary across load timing) and skips "/" entries entirely.
- Tests: `tests/session-cwd.test.ts` (client-cwd trust boundary, backend
workspace adoption, polluted-entry healing) + a /fs root-"/" refusal case
in `tests/remote-file-endpoint.test.ts`.

## [0.11.3] - 2026-08-22

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zcode-acp-server",
"version": "0.11.3",
"version": "0.11.4",
"description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.",
"type": "module",
"license": "Apache-2.0",
Expand Down
79 changes: 68 additions & 11 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,41 @@ function workspaceFor(cwd?: string): { workspacePath: string; workspaceKey: stri
return { workspacePath: p, workspaceKey: p };
}

/**
* A client-supplied cwd is only ever trusted for `session/new` — creating a
* session is the editor declaring its worktree. "/" is never a project root:
* remote clients fall back to it when their instance list is stale, and a
* session root decides what the /fs file endpoint exposes.
*/
function sanitizeClientCwd(client: string | undefined): string | null {
return client && client !== "/" ? client : null;
}

/**
* The authoritative Session Root for an EXISTING session: what the bridge
* already recorded (set at creation, or refreshed from the backend's resume
* result). Client cwds are NOT consulted — a remote client must not be able
* to widen or move a session's file scope by sending its own cwd. A
* previously-polluted "/" entry counts as unknown so the next resume
* repopulates it from the backend.
*/
function authoritativeSessionCwd(server: ZcodeAcpServer, acpSid: string): string {
const existing = server.sessionCwds.get(acpSid);
return existing && existing !== "/" ? existing : process.cwd();
}

/**
* Extract the backend-recorded workspace from a session/resume result
* (`result.session.workspace.workspacePath`). This is the session's own
* project directory as the backend sees it — the value remote file access
* is scoped to. Returns null when absent or malformed.
*/
function workspaceFromResumeResult(result: unknown): string | null {
const ws = (result as { session?: { workspace?: { workspacePath?: unknown } } } | null)?.session
?.workspace?.workspacePath;
return typeof ws === "string" && ws !== "" && ws !== "/" ? ws : null;
}

/**
* Push the provider registry to the backend so third-party providers (those in
* config.json) are recognised. The V4 backend doesn't auto-load them from
Expand Down Expand Up @@ -93,7 +128,9 @@ export async function newSession(
server: ZcodeAcpServer,
params: acp.NewSessionRequest,
): Promise<acp.NewSessionResponse> {
const cwd = params.cwd ?? process.cwd();
// Creation is the one moment a client's cwd is trusted (the editor
// declaring its worktree); "/" is still rejected as a degenerate root.
const cwd = sanitizeClientCwd(params.cwd) ?? process.cwd();
// Placeholder id — the client addresses this session with it until the
// backend session materializes; never shown in session/list.
const acpSid = randomUUID();
Expand Down Expand Up @@ -167,7 +204,7 @@ export async function ensureRealSession(server: ZcodeAcpServer, acpSid: string):
if (record) {
pending = { cwd: record.cwd };
server.pendingSessions.set(acpSid, pending);
server.sessionCwds.set(acpSid, record.cwd);
if (record.cwd !== "/") server.sessionCwds.set(acpSid, record.cwd);
}
}
if (!pending) throw new Error(`session ${acpSid} not found`);
Expand Down Expand Up @@ -347,8 +384,12 @@ export async function resumeSession(
cx: acp.AgentContext,
): Promise<acp.ResumeSessionResponse> {
const acpSid = params.sessionId;
const cwd = params.cwd ?? process.cwd();
if (!acpSid) throw new Error("sessionId required");
// The Session Root never comes from the client (params.cwd is ignored):
// start from what the bridge recorded, then let the backend's own resume
// result correct it below — a remote client must not move a session's
// file scope by sending its own cwd.
let cwd = authoritativeSessionCwd(server, acpSid);

// Lazy placeholders (session/new) resolve to their real backend session
// here; alreadyLive targets skip the resume RPC because the session is live
Expand Down Expand Up @@ -377,14 +418,18 @@ export async function resumeSession(
// third-party model in its history, and the backend needs the provider
// registered to even process the resume turn.
await syncProviderRegistry(server, cwd);
await resumeBackendSession(server, zcParams);
const resumeResult = await resumeBackendSession(server, zcParams);
// The resume RPC succeeded — the session is now loaded in this backend.
server.markBackendLoaded(acpSid);
// The backend's session record is the root authority: adopt its
// workspace as the session root (heals any stale/polluted entry).
const backendWs = workspaceFromResumeResult(resumeResult);
if (backendWs) cwd = backendWs;
}

server.registerSession(acpSid, zcodeSid);
// The load's cwd becomes the session root for remote file access (same as
// session/new) — without this, a loaded session has no readable root.
// The session root for remote file access — backend-authoritative (see
// above); without this, a loaded session has no readable root.
server.sessionCwds.set(acpSid, cwd);
log(`session/resume -> ${zcodeSid}`);
server.ensureBackgroundListener(zcodeSid);
Expand All @@ -410,8 +455,12 @@ export async function loadSession(
cx: acp.AgentContext,
): Promise<acp.LoadSessionResponse> {
const acpSid = params.sessionId;
const cwd = params.cwd ?? process.cwd();
if (!acpSid) throw new Error("sessionId required");
// The Session Root never comes from the client (params.cwd is ignored):
// start from what the bridge recorded, then let the backend's own resume
// result correct it below — a remote client must not move a session's
// file scope by sending its own cwd.
let cwd = authoritativeSessionCwd(server, acpSid);

// Same placeholder resolution as resumeSession; alreadyLive targets skip the
// backend resume RPC (the session is live in this subprocess).
Expand All @@ -428,12 +477,16 @@ export async function loadSession(
// third-party model in its history, and the backend needs the provider
// registered to process it.
await syncProviderRegistry(server, cwd);
await resumeBackendSession(server, zcParams);
const resumeResult = await resumeBackendSession(server, zcParams);
// The resume RPC succeeded — the session is now loaded in this backend.
server.markBackendLoaded(acpSid);
// The backend's session record is the root authority: adopt its
// workspace as the session root (heals any stale/polluted entry).
const backendWs = workspaceFromResumeResult(resumeResult);
if (backendWs) cwd = backendWs;
}
server.registerSession(acpSid, zcodeSid);
// Same as resumeSession: record the cwd as the session root for file access.
// Same as resumeSession: backend-authoritative session root for file access.
server.sessionCwds.set(acpSid, cwd);
log(`session/load → ${zcodeSid}`);
server.ensureBackgroundListener(zcodeSid);
Expand Down Expand Up @@ -1166,11 +1219,14 @@ function fileUriToPath(uri: string): string {
* can land in that gap and time out without the backend ever seeing it. A single
* retry — issued after the startup window has elapsed — succeeds. Non-timeout
* errors (Invalid params, session not found) fail fast.
*
* Returns the response's result object on success — callers extract the
* backend-authoritative session workspace from it. Throws on failure.
*/
async function resumeBackendSession(
server: ZcodeAcpServer,
zcParams: Record<string, unknown>,
): Promise<void> {
): Promise<Record<string, unknown>> {
const backend = server.ensureBackend();
const MAX_ATTEMPTS = 2;
const ATTEMPT_TIMEOUT_MS = 15_000;
Expand All @@ -1181,7 +1237,7 @@ async function resumeBackendSession(
zcParams,
ATTEMPT_TIMEOUT_MS,
);
if (!resp.error) return;
if (!resp.error) return (resp.result ?? {}) as Record<string, unknown>;
const isTimeout = resp.error.message === "timeout";
if (!isTimeout || attempt === MAX_ATTEMPTS) {
throw new Error(`zcode resume failed: ${resp.error.message ?? ""}`);
Expand All @@ -1191,6 +1247,7 @@ async function resumeBackendSession(
);
await sleep(1000);
}
throw new Error("zcode resume failed: exhausted retries");
}

/**
Expand Down
14 changes: 13 additions & 1 deletion src/remote/file-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,20 @@ async function sessionRoot(
sendText(res, 403, "unknown session");
return null;
}
// Defense in depth: a session root of "/" is never legitimate (projects
// live in subdirectories; "/" could only come from a polluted cwd record).
// Serving it would expose the whole filesystem to remote clients.
if (cwd === "/") {
sendText(res, 403, "session root unavailable");
return null;
}
try {
return await realpath(cwd);
const real = await realpath(cwd);
if (real === "/") {
sendText(res, 403, "session root unavailable");
return null;
}
return real;
} catch {
sendText(res, 404, "session root unavailable");
return null;
Expand Down
21 changes: 18 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,26 @@ export class ZcodeAcpServer {
}

/**
* The bridge's project directory: first known session cwd, else the bridge
* process cwd (Zed spawns the server with the worktree root as cwd).
* The bridge's project directory: the cwd of the most recently active
* session, else the bridge process cwd (Zed spawns the server with the
* worktree root as cwd). Recent-activity wins over Map order — insertion
* order is arbitrary across load/resume timing, and a single polluted
* entry ("/") must never decide the label for every session. Roots of "/"
* are skipped entirely: they can only come from a client fallback, never a
* real worktree.
*/
projectCwd(): string {
return this.sessionCwds.values().next().value ?? process.cwd();
let best = "";
let bestAt = -1;
for (const [acpSid, cwd] of this.sessionCwds) {
if (!cwd || cwd === "/") continue;
const at = this.sessionSummaries.get(acpSid)?.updatedAt ?? 0;
if (at > bestAt) {
best = cwd;
bestAt = at;
}
}
return best || process.cwd();
}

/** Best-effort workspace label for the hub discovery payload. */
Expand Down
14 changes: 14 additions & 0 deletions tests/remote-file-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ interface Fixture {
/** Sibling dir outside the session root (for escape fixtures). */
outside: string;
endpoint: { port: number; stop(): Promise<void> };
/** The bridge's server state (for polluting cwd records in tests). */
server: ZcodeAcpServer;
}

async function spawnFixture(): Promise<Fixture> {
Expand Down Expand Up @@ -82,6 +84,7 @@ async function spawnFixture(): Promise<Fixture> {
dir,
outside,
endpoint: endpoint!,
server,
};
}

Expand Down Expand Up @@ -121,6 +124,17 @@ describe("session files over the hub proxy", () => {
expect(bad.status).toBe(404);
});

it("refuses to serve a session whose recorded root is /", async () => {
const { base, server } = await spawnFixture();
// A polluted cwd record must never widen file access to the filesystem
// root — defense in depth behind the load-side guards.
server.sessionCwds.set("s-polluted", "/");
const res = await fsFetch(base, "/list?sessionId=s-polluted");
expect(res.status).toBe(403);
const file = await fsFetch(base, "/file?sessionId=s-polluted&path=etc/passwd");
expect(file.status).toBe(403);
});

it("streams whole files with a Content-Type from the extension", async () => {
const { base } = await spawnFixture();
const res = await fsFetch(base, "/file?sessionId=s-fs&path=README.md");
Expand Down
Loading
Loading