Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
159002d
feat(daemon): worktree-isolated sessions for parallel tasks
wenshao Jul 19, 2026
738b6aa
Merge remote-tracking branch 'origin/main' into feat/webshell-worktre…
wenshao Jul 19, 2026
f7951df
feat(daemon): restore worktree isolation on session load/resume
wenshao Jul 19, 2026
c4b8b22
Merge remote-tracking branch 'origin/main' into feat/worktree-restore…
wenshao Jul 20, 2026
f184757
fix(daemon): use restoreWorktreeContext for load/resume worktree restore
wenshao Jul 20, 2026
0d24900
fix(daemon): durable worktree membership + pre-read sidecar before load
wenshao Jul 20, 2026
8fa7125
fix(daemon): read sidecar after load, reorder membership checks
wenshao Jul 20, 2026
dc1a551
test(daemon): worktree membership + load/resume restore tests
wenshao Jul 20, 2026
505edb3
fix(test): use bracket notation for path.sep in strict tsconfig
wenshao Jul 20, 2026
c62b15e
fix(daemon): symlink-safe containment, nested worktree membership, te…
wenshao Jul 20, 2026
2bc4147
test(daemon): add containment guard rejection test
wenshao Jul 20, 2026
af19e30
fix(daemon): realpath-based containment for worktree restore
wenshao Jul 20, 2026
2260f42
fix(daemon): containment uses workspaceCwd root, canonical path, nati…
wenshao Jul 20, 2026
94c882e
fix(daemon): monorepo worktree containment via repo top-level fallback
wenshao Jul 20, 2026
5a6b69d
fix(daemon): close TOCTOU with allowedRoots containment at sessionCd …
wenshao Jul 20, 2026
0d4aab9
fix(daemon): wrap getRepoTopLevel in try-catch for worktree create path
wenshao Jul 20, 2026
5efdb97
fix(daemon): narrow allowedRoots to .qwen/worktrees, containment befo…
wenshao Jul 20, 2026
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
8 changes: 8 additions & 0 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5564,6 +5564,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
{
sessionId,
path: req.path,
...(req.allowedRoots ? { allowedRoots: req.allowedRoots } : {}),
},
);
const extResult = raw as {
Expand Down Expand Up @@ -5624,6 +5625,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return { sessionId, ...result };
},

setSessionWorktree(sessionId, worktree) {
const entry = byId.get(sessionId);
if (entry) {
entry.worktree = worktree;
}
},
Comment thread
wenshao marked this conversation as resolved.

async closeSession(sessionId, context, closeOpts) {
return closeSessionImpl(sessionId, context, closeOpts);
},
Expand Down
19 changes: 19 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,14 @@ export interface BridgeForkAgentResult {

export interface ChangeSessionCwdRequest {
path: string;
/**
* Server-controlled containment roots. When present, the agent-side
* sessionCd handler verifies (after its own realpath) that the
* canonical target is under one of these roots. Only set by the
* daemon's worktree create/restore paths; direct user cd omits this
* field, preserving existing behavior.
*/
allowedRoots?: string[];
}

export interface ChangeSessionCwdResult {
Expand Down Expand Up @@ -723,6 +731,17 @@ export interface AcpSessionBridge {
context?: BridgeClientRequestContext,
): Promise<ChangeSessionCwdResult>;

/**
* Set worktree metadata on an existing session entry. Used when
* restoring a worktree session after daemon restart — the sidecar
* file provides the metadata, and this populates the in-memory entry
* so `getSessionSummary` returns it.
*/
setSessionWorktree(
sessionId: string,
worktree: { slug: string; path: string; branch: string },
): void;

/**
* Forward a prompt to the agent. Concurrent prompts against the same
* session FIFO-serialize through a per-session queue.
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7571,6 +7571,25 @@ class QwenAgent implements Agent {
// Canonicalize path
const canonicalPath = await fs.realpath(targetPath);

// Server-controlled containment check (worktree create/restore).
// Must run BEFORE the no-op check: a no-op cd to a directory
// outside the allowed roots must still be rejected.
const allowedRoots = params['allowedRoots'];
if (Array.isArray(allowedRoots) && allowedRoots.length > 0) {
const contained = allowedRoots.some((root: unknown) => {
if (typeof root !== 'string') return false;
const rel = path.relative(root, canonicalPath);
return !rel.startsWith('..') && !path.isAbsolute(rel);
});
if (!contained) {
throw new RequestError(
-32004,
`Path outside allowed roots: ${canonicalPath}`,
{ errorKind: 'containment_violation', path: canonicalPath },
);
}
}

// Noop check
const previousCwd = config.getTargetDir();
if (canonicalPath === previousCwd) {
Expand Down
109 changes: 109 additions & 0 deletions packages/cli/src/serve/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
APPROVAL_MODES,
Expand All @@ -23,6 +24,7 @@ import {
runWithoutDebugLogSession,
writeWorktreeSessionMarker,
writeWorktreeSession,
readWorktreeSession,
type ApprovalMode,
type SessionGroupColor,
type SessionGroupPresetColor,
Expand Down Expand Up @@ -1376,8 +1378,28 @@ export function registerSessionRoutes(
// before any subsequent prompt is processed.
if (worktreeMeta) {
try {
// Compute allowed roots for the sessionCd containment check.
// Narrow to <root>/.qwen/worktrees (not the whole repo) so a
// symlink .qwen/worktrees/task -> <repo>/src is rejected.
const createAllowedRoots = [
path.join(workspaceCwd, '.qwen', 'worktrees'),
];
let createRepoTop: string | null = null;
try {
createRepoTop = await new GitWorktreeService(
workspaceCwd,
).getRepoTopLevel();
} catch {
// Not a git repo or getRepoTopLevel unavailable.
}
if (createRepoTop && createRepoTop !== workspaceCwd) {
createAllowedRoots.push(
path.join(createRepoTop, '.qwen', 'worktrees'),
);
}
await runtime.bridge.changeSessionCwd(session.sessionId, {
path: worktreeMeta.path,
allowedRoots: createAllowedRoots,
});
await writeWorktreeSessionMarker(
worktreeMeta.path,
Expand Down Expand Up @@ -1551,6 +1573,93 @@ export function registerSessionRoutes(
}
return;
}
// Restore worktree isolation. Read the sidecar AFTER load/resume
// so we inherit the ACP layer's verdict: #restoreWorktreeOnResume
// clears the sidecar on dead-worktree / containment-failure paths,
// so a post-read naturally skips those cases. On the healthy path
// the sidecar is untouched and we relocate + populate the entry.
// Note: the !res.writable early-return above skips this restore;
// a client that disconnects mid-load leaves the session parked in
// the main workspace (pre-existing shape, low frequency).
if (!session.worktree) {
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
const sidecar = await readWorktreeSession(
new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId),
).catch(() => null);
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
if (sidecar) {
// Defense-in-depth: resolve symlinks on both the target and
// the expected worktrees root, then verify containment. This
// defeats both `..` traversal and symlink escapes (e.g.
// .qwen/worktrees/escape -> /etc). The allowed root is always
// derived from the server (never from the sidecar, which is
// attacker-writable). The canonical realTarget is passed to
// changeSessionCwd to eliminate the TOCTOU window between
// validation and relocation.
// For monorepo subdirectory workspaces, worktrees live under
// the repo top-level, not the workspace cwd. Try workspaceCwd
// first, then fall back to the git repo top-level.
let realTarget: string | undefined;
const candidateRoots = [
path.join(workspaceCwd, '.qwen', 'worktrees'),
];
try {
realTarget = fs.realpathSync(sidecar.worktreePath);
let repoTop: string | null = null;
try {
repoTop = await new GitWorktreeService(
workspaceCwd,
).getRepoTopLevel();
} catch {
// Not a git repo or getRepoTopLevel unavailable.
}
if (repoTop && repoTop !== workspaceCwd) {
candidateRoots.push(path.join(repoTop, '.qwen', 'worktrees'));
}
Comment thread
wenshao marked this conversation as resolved.
const contained = candidateRoots.some((root) => {
try {
const realRoot = fs.realpathSync(root);
const rel = path.relative(realRoot, realTarget!);
return !rel.startsWith('..') && !path.isAbsolute(rel);
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
} catch {
return false;
}
});
if (!contained) {
realTarget = undefined;
}
} catch {
realTarget = undefined;
}
if (!realTarget) {
daemonLog?.warn('worktree sidecar path failed containment', {
sessionId,
path: sidecar.worktreePath,
});
Comment thread
wenshao marked this conversation as resolved.
} else {
const wt = {
slug: sidecar.slug,
path: realTarget,
branch: sidecar.worktreeBranch,
};
try {
await runtime.bridge.changeSessionCwd(sessionId, {
path: wt.path,
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
allowedRoots: candidateRoots,
Comment thread
wenshao marked this conversation as resolved.
});
runtime.bridge.setSessionWorktree(sessionId, wt);
session.worktree = wt;
} catch (restoreErr) {
daemonLog?.warn('worktree restore failed on load/resume', {
sessionId,
worktreePath: wt.path,
error:
restoreErr instanceof Error
? restoreErr.message
: String(restoreErr),
});
}
}
}
}
res.status(200).json(session);
} catch (err) {
sendBridgeError(res, err, {
Expand Down
Loading
Loading