From 159002d4cf06b757863e9ee888e59bc87b53c29f Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 19 Jul 2026 18:21:13 +0800 Subject: [PATCH 01/15] feat(daemon): worktree-isolated sessions for parallel tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for creating sessions in isolated git worktrees from the Web Shell, enabling multiple tasks to run in parallel within the same workspace without polluting the main working directory. Daemon: - POST /session accepts optional worktree param, creates worktree via GitWorktreeService, relocates session via changeSessionCwd - Worktree metadata persisted in SessionEntry, BridgeSessionSummary, and sidecar file (.worktree.json) for daemon restart recovery - GET /workspaces/:workspace/git supports ?cwd= for worktree-scoped git status queries (path.resolve + containment check) SDK: - CreateSessionRequest/DaemonSession/DaemonSessionSummary gain worktree field; DaemonSessionClient exposes worktree getter - WorkspaceDaemonClient.workspaceGit() accepts optional cwd param Web Shell: - Workspace branch pill dropdown offers 'New Worktree Task' (git repos only) with purple GitForkIcon and description - Git chip turns purple with GitForkIcon for worktree sessions - Session list shows inline ⑂ badge for worktree sessions - Empty-state welcome badge explains worktree isolation - Git status queries target worktree path, not workspace root - session_cwd_changed event filtered from chat transcript Design doc: docs/design/2026-07-19-webshell-worktree-sessions.md --- .../2026-07-19-webshell-worktree-sessions.md | 428 ++++++++++++++++++ packages/acp-bridge/src/bridge.ts | 10 +- packages/acp-bridge/src/bridgeTypes.ts | 6 + packages/cli/src/serve/routes/session.ts | 132 ++++++ .../cli/src/serve/routes/workspace-git.ts | 23 +- packages/cli/src/serve/server.test.ts | 179 ++++++++ packages/cli/src/serve/server/session-list.ts | 47 ++ .../sdk-typescript/src/daemon/DaemonClient.ts | 15 +- .../src/daemon/DaemonSessionClient.ts | 4 + packages/sdk-typescript/src/daemon/index.ts | 1 + packages/sdk-typescript/src/daemon/types.ts | 11 + packages/sdk-typescript/src/index.ts | 1 + packages/web-shell/client/App.module.css | 23 + packages/web-shell/client/App.tsx | 104 ++++- .../client/adapters/transcriptToMessages.ts | 1 + .../client/components/ChatEditor.module.css | 4 + .../client/components/ChatEditor.tsx | 6 + .../client/components/GitBranchIndicator.tsx | 27 +- .../sidebar/WebShellSidebar.module.css | 8 + .../components/sidebar/WebShellSidebar.tsx | 32 +- .../sidebar/WorkspaceSection.test.tsx | 13 +- .../components/sidebar/WorkspaceSection.tsx | 55 ++- packages/web-shell/client/i18n.tsx | 14 + .../client/utils/sessionPreparation.test.ts | 2 +- .../client/utils/sessionPreparation.ts | 23 +- .../daemon/session/DaemonSessionProvider.tsx | 8 +- packages/webui/src/daemon/session/actions.ts | 9 +- 27 files changed, 1126 insertions(+), 60 deletions(-) create mode 100644 docs/design/2026-07-19-webshell-worktree-sessions.md diff --git a/docs/design/2026-07-19-webshell-worktree-sessions.md b/docs/design/2026-07-19-webshell-worktree-sessions.md new file mode 100644 index 00000000000..caa010c6e48 --- /dev/null +++ b/docs/design/2026-07-19-webshell-worktree-sessions.md @@ -0,0 +1,428 @@ +# Web Shell worktree 隔离会话 + +## 背景 + +当前 Web Shell 的一个 workspace 同一时间只能有效地跑一个任务:所有 session +共享同一个 working tree,agent 的文件编辑、`git add`、`git checkout` 等操作 +直接作用于主目录。如果用户想同时推进两个独立任务(比如"修 bug A"和"实现 +feature B"),两个 session 会互相踩踏——一个改了 `foo.ts`,另一个也在改 +`foo.ts`,结果不可预测。 + +CLI 侧已有 worktree 基础设施: + +- `enter_worktree` / `exit_worktree` 工具:agent 可以在会话中手动创建和退出 + worktree,但这是 **tool 级别** 的——需要 agent 自己调用,且 worktree 内的 + cwd 切换靠 model 记住路径前缀,不是真正的进程级 cwd 切换。 +- `GitWorktreeService`(`packages/core/src/services/gitWorktreeService.ts`): + 成熟的 worktree 生命周期管理——创建(`createUserWorktree`)、slug 生成与 + 校验、分支命名(`worktree-`)、session marker、symlink 目录、清理 + (`removeWorktree`)、启动时孤儿扫描(`sweepStaleWorktrees`)。 +- agent 工具的 `isolation: "worktree"`:子 agent 可以在隔离 worktree 中运行, + 结果通过 branch 返回。 + +但这些都是 **会话内** 或 **子 agent** 级别的。用户想要的是:**创建 session +时自动隔离**——点"新建会话",session 直接在自己的 worktree 里工作,主目录 +干净,多个 session 可以真正并行。 + +## 目标 + +- 创建 session 时可选"worktree 隔离":daemon 自动创建 git worktree,CLI + 子进程直接以 worktree 路径为 cwd 启动。 +- 同一 workspace 的多个 worktree session 可以真正并行,互不干扰。 +- 主目录保持干净——agent 的所有文件操作发生在 worktree 里。 +- 复用 `GitWorktreeService` 已有能力,不重新实现 worktree 管理。 +- session 列表和 git chip 能区分 worktree session 和普通 session。 +- worktree 生命周期与 session 绑定:session 结束时提示清理或保留。 +- 向后兼容:不传 worktree 参数时行为完全不变。 + +## 非目标 + +- 不做 merge-back UI(worktree 的改动合回主分支)。agent 可以在终端里做 + `git merge` / `git rebase`,UI 层面的合并工作流属于后续增量。 +- 不做 worktree 之间的文件对比或冲突解决。 +- 不改变 `enter_worktree` / `exit_worktree` 工具的行为——它们继续作为会话内 + 的手动 worktree 管理工具。 +- 不在非 git 仓库下提供 worktree 隔离(worktree 是 git 概念)。 +- 不做 worktree 的远程同步(push/pull)。 + +## 现状链路 + +### session 创建 + +```text +Web Shell UI (createSession) + → SDK DaemonClient.createSession({ workspaceCwd, ... }) + → POST /session (routes/session.ts) + → resolveRuntimeForSessionCreation(body) + → workspaceRegistry 解析 workspace runtime + → bridge.spawnOrAttach({ workspaceCwd, ... }) + → spawnChannel: spawn(cliEntry, { cwd: workspaceCwd }) + → CLI 子进程以 workspaceCwd 为 cwd 启动 +``` + +关键约束:`workspaceCwd` 必须是 `workspaceRegistry` 中已注册的 workspace。 +worktree 路径(如 `/.qwen/worktrees/my-task/`)不在注册表中,不能 +直接作为 `workspaceCwd` 传入。 + +### worktree 基础设施 + +- `GitWorktreeService.createUserWorktree(slug, baseBranch?, opts?)`: + 创建 worktree + 分支 `worktree-`,返回 `{ success, worktree: { path, branch } }`。 +- worktree 存放路径:`/.qwen/worktrees//`。 +- `writeWorktreeSessionMarker(path, sessionId)`:写入 `.qwen-session` 标记。 +- `GitWorktreeService.removeWorktree(slug)`:清理 worktree + 分支。 +- `GitWorktreeService.sweepStaleWorktrees()`:启动时清理孤儿 worktree。 +- `WorktreeSession` sidecar(`worktreeSessionService.ts`):已有 + `{ slug, worktreePath, worktreeBranch, originalCwd, originalBranch, +originalHeadCommit }` 结构,存于 `/.worktree.json`, + 用于 `--resume` 恢复上下文。可直接复用。 +- `cleanupStaleAgentWorktrees`(`worktreeCleanup.ts`):30 天孤儿扫描, + 但只清理 `agent-{7hex}` slug,用户命名的 worktree 不会被自动清理。 +- `Config.relocateWorkingDirectory(newDir)`:运行时切换 cwd 的方法 + (ACP 模式跳过 `process.chdir`),可作为备选路径但不如 `initialCwd` + 直接。 + +### git 状态绑定 + +- `WorkspaceGitState`(daemon):每个 workspace 一个 entry,用 + `watchRepoBranch(workspaceCwd)` 监听 branch 变化。 +- Web Shell 的 git chip / `/diff` 绑定的是 **workspace cwd**,不是 session + 的实际 cwd。agent cd 进 worktree 后,chip 仍显示主目录的状态。 + +## 方案 + +### 核心思路 + +在 `POST /session` 增加可选的 `worktree` 参数。daemon 在 spawn 子进程之前 +创建 worktree,然后把子进程的 cwd 设为 worktree 路径(而非 workspace cwd)。 +workspace 注册和 runtime 解析仍走主目录,worktree 只影响子进程的实际工作目录。 + +> **为什么不用 `--worktree` CLI 参数?** CLI 已有 `--worktree` 启动参数 +> (`worktreeStartup.ts`),但它在 ACP 模式下被显式拒绝(`gemini.tsx:623`: +> "--worktree cannot be combined with --acp"),因为 ACP host 自己管理 +> per-session cwd。错误信息建议"Pass the worktree path as the cwd of the +> ACP loadSession / newSession request instead"——正是本方案的做法。 + +```text +POST /session { cwd: "/repo", worktree: { slug?: "my-task" } } + │ + ├─ resolveRuntimeForSessionCreation (仍用 /repo 解析 runtime) + │ + ├─ 创建 worktree + │ GitWorktreeService("/repo").createUserWorktree("my-task", currentBranch) + │ → /repo/.qwen/worktrees/my-task/ (branch: worktree-my-task) + │ + ├─ bridge.spawnOrAttach({ + │ workspaceCwd: "/repo", ← workspace 注册 / runtime 解析 + │ initialCwd: "/repo/.qwen/worktrees/my-task/", ← 子进程实际 cwd + │ worktree: { slug, path, branch } ← session 元数据 + │ }) + │ + └─ CLI 子进程以 worktree 路径为 cwd 启动 + → 所有文件操作、git 命令自然发生在 worktree 里 +``` + +### 数据流变更 + +```text +SDK CreateSessionRequest + + worktree?: { slug?: string } [新增] + +BridgeSpawnRequest + + initialCwd?: string [新增] 子进程实际 cwd + + worktree?: { [新增] worktree 元数据 + slug: string; + path: string; + branch: string; + } + +BridgeSession + + worktree?: { slug, path, branch } [新增] 返回给调用方 + +DaemonSessionSummary (SSE / REST) + + worktree?: { slug, path, branch } [新增] session 列表可展示 + +spawnChannel + spawn(cliEntry, { cwd: initialCwd ?? workspaceCwd }) [改动] +``` + +### daemon 侧 + +#### `POST /session` 路由扩展 + +```ts +// routes/session.ts +app.post('/session', mutate(), async (req, res) => { + const body = safeBody(req); + const resolvedRuntime = resolveRuntimeForSessionCreation(body, res); + if (!resolvedRuntime) return; + const { runtime, workspaceCwd } = resolvedRuntime; + + // —— 新增:worktree 创建 —— + let worktreeMeta: { slug: string; path: string; branch: string } | undefined; + let initialCwd: string | undefined; + + if (body['worktree'] && typeof body['worktree'] === 'object') { + const wtReq = body['worktree'] as { slug?: string }; + const service = new GitWorktreeService(workspaceCwd); + + // 前置检查:必须是 git 仓库 + if (!(await service.isGitRepository())) { + res.status(400).json({ + error: 'Worktree isolation requires a git repository', + code: 'worktree_not_git_repo', + }); + return; + } + + const slug = wtReq.slug ?? GitWorktreeService.generateAutoSlug(); + const validation = GitWorktreeService.validateUserWorktreeSlug(slug); + if (validation) { + res + .status(400) + .json({ error: validation, code: 'worktree_invalid_slug' }); + return; + } + + const baseBranch = await service.getCurrentBranch().catch(() => undefined); + const result = await service.createUserWorktree(slug, baseBranch); + if (!result.success || !result.worktree) { + res.status(500).json({ + error: result.error ?? 'Failed to create worktree', + code: 'worktree_create_failed', + }); + return; + } + + worktreeMeta = { + slug, + path: result.worktree.path, + branch: result.worktree.branch, + }; + initialCwd = result.worktree.path; + } + + const session = await runtime.bridge.spawnOrAttach({ + workspaceCwd, + ...(initialCwd ? { initialCwd } : {}), + ...(worktreeMeta ? { worktree: worktreeMeta } : {}), + // ... 其余参数不变 + }); + + // worktree session 写入 session marker + if (worktreeMeta) { + await writeWorktreeSessionMarker( + worktreeMeta.path, + session.sessionId, + ).catch(() => {}); + } + + res.json({ ...session }); +}); +``` + +#### spawnChannel 改动 + +```ts +// acp-bridge/src/spawnChannel.ts +// 现有:cwd: workspaceCwd +// 改为:cwd: initialCwd ?? workspaceCwd +const child = spawn(process.execPath, [...args], { + cwd: initialCwd ?? workspaceCwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: childEnv, +}); +``` + +#### worktree 清理 + +session 结束时(`session_ended` 事件或 bridge 的 `onExit` 回调),检查 +session 是否有 worktree 元数据: + +- 有未提交改动 → 保留 worktree,在 session 摘要中标记 + `worktree.cleanupNeeded: true`,Web Shell 提示用户。 +- 无改动 → 自动清理(`removeWorktree`)。 +- 用户也可以显式保留(后续 UI 支持)。 + +清理逻辑放在 bridge 的 session 退出回调中,不在路由层。 + +### SDK 侧 + +```ts +// DaemonClient.ts +export interface CreateSessionRequest { + // ... 现有字段 + /** 创建 worktree 隔离会话。slug 可选,不传则自动生成。 */ + worktree?: { slug?: string }; +} + +// DaemonSessionSummary (session 列表 / SSE 事件) +export interface DaemonSessionSummary { + // ... 现有字段 + worktree?: { + slug: string; + path: string; + branch: string; + /** true 表示 session 已结束但 worktree 有未提交改动,需用户处理。 */ + cleanupNeeded?: boolean; + }; +} +``` + +### Web Shell 侧 + +#### 新建会话 UI + +Web Shell 的 session 创建是**懒加载**的:点"新建会话"只清前端状态 +(`clearSession`),daemon session 在第一次提交 prompt 时才创建 +(`ensureSessionForPrompt` → `createAndAttachSessionForPrompt` → +`sessionActions.createSession`)。worktree 参数需要穿透这条路径: +`createNewSession` 时记住"下一个 session 要 worktree 隔离", +`ensureSessionForPrompt` 时把 `worktree` 参数传给 `createSession`。 + +在"新建会话"按钮旁增加 worktree 选项。两种形态: + +**最小方案(推荐先做)**:新建会话时,如果当前 workspace 是 git 仓库, +在会话创建请求中自动带 `worktree: {}`(自动 slug)。用户无需额外操作, +每个新 session 天然隔离。 + +**可选方案**:在"新建会话"按钮旁加一个下拉/开关,让用户选择"普通会话" +或"worktree 隔离会话"。适合不想每次都隔离的用户。 + +先做最小方案,通过 settings 或 workspace 级开关控制是否默认启用。 + +#### session 列表 + +worktree session 在会话列表中显示分支标记: + +```text +┌─────────────────────────────────────────┐ +│ 💬 Fix login bug │ ← 普通 session +│ main │ +├─────────────────────────────────────────┤ +│ 💬 Add dark mode ⑂ worktree-dark-mode │ ← worktree session +│ worktree-dark-mode │ +└─────────────────────────────────────────┘ +``` + +#### git chip + +worktree session 的 git chip 显示 worktree 分支名,git status 跟随 +session 的实际 cwd(worktree 路径)而非 workspace cwd。 + +实现:`App.tsx` 中 `workspaceGit()` 的调用改为使用 session 的 +`worktree.path`(如果有)作为 cwd 参数。 + +> **已有基础**:daemon 已有 `POST /session/:id/cd` 路由和 +> `session_cwd_changed` 事件(`bridge.changeSessionCwd`),但 Web Shell +> 的 mappers 未消费该事件。worktree session 不需要走 cd 路由(子进程直接 +> 以 worktree 为 cwd 启动),但 git 状态刷新需要知道 session 的实际 cwd。 +> 最简做法:session 创建响应中返回 `worktree.path`,Web Shell 用它替代 +> `connection.workspaceCwd` 来拉取 git 状态。 + +#### `/diff` + +worktree session 中 `/diff` 显示 worktree 内的 diff,不是主目录的。 +`diffWorkspaceCwd` 改为从 session 的实际 cwd 取值。 + +### 刷新策略 + +worktree session 的 git 状态刷新与普通 session 一致(focus / branch 变化 / +30s 轮询),只是 cwd 指向 worktree 路径。`watchRepoBranch` 对 worktree +同样有效(worktree 共享 `.git` 目录,reflog 变化会触发 watch)。 + +## 兼容性 + +- **旧 daemon + 新 client**:client 传 `worktree` 参数,旧 daemon 忽略 + 未知字段,创建普通 session。行为退化但不报错。 +- **新 daemon + 旧 client**:不传 `worktree`,行为完全不变。 +- **非 git 仓库**:传 `worktree` 时返回 `400 worktree_not_git_repo`, + client 应捕获并提示用户。 +- **worktree 创建失败**(磁盘满、权限、分支冲突):返回 `500`,session + 不创建,client 提示错误。 +- **session 异常退出**(daemon 崩溃、kill -9):worktree 残留在 + `.qwen/worktrees/` 下。已有的 `sweepStaleWorktrees` 在 daemon 重启时 + 清理无 session marker 的孤儿 worktree。 + +## 关键修改点 + +| 操作 | 文件 | 说明 | +| ---- | ----------------------------------------------------- | ---------------------------------------------------------- | +| 修改 | `packages/acp-bridge/src/bridgeTypes.ts` | `BridgeSpawnRequest` 加 `initialCwd` / `worktree` | +| 修改 | `packages/acp-bridge/src/spawnChannel.ts` | spawn cwd 改为 `initialCwd ?? workspaceCwd` | +| 修改 | `packages/cli/src/serve/routes/session.ts` | `POST /session` 处理 worktree 创建 | +| 修改 | `packages/sdk-typescript/src/daemon/DaemonClient.ts` | `CreateSessionRequest` 加 `worktree` | +| 修改 | `packages/sdk-typescript/src/daemon/types.ts` | `DaemonSessionSummary` 加 `worktree` | +| 修改 | `packages/web-shell/client/App.tsx` | `createSession` 传 worktree 参数;git 状态跟随 session cwd | +| 修改 | `packages/web-shell/client/components/ChatEditor.tsx` | git chip 使用 session 实际 cwd | +| 修改 | session 列表组件 | 显示 worktree 分支标记 | + +## 测试计划 + +### Unit tests + +- `POST /session` + `worktree`:创建成功返回 worktree 元数据;非 git 仓库 + 返回 400;无效 slug 返回 400;创建失败返回 500。 +- `spawnChannel`:`initialCwd` 传入时子进程 cwd 为 worktree 路径;不传时 + 仍为 workspaceCwd。 +- `GitWorktreeService.createUserWorktree`:已有充分测试,不需新增。 +- SDK `createSession({ worktree })`:正确序列化参数。 +- Web Shell `createSession`:worktree 参数透传。 + +### Integration / browser verification + +- 创建 worktree session → agent 在 worktree 里工作 → 主目录无变化。 +- 同时创建两个 worktree session → 各自独立,互不影响。 +- session 结束 → 无改动时 worktree 自动清理;有改动时保留并提示。 +- git chip 显示 worktree 分支名和状态。 +- `/diff` 显示 worktree 内的 diff。 +- 非 git 仓库下创建 worktree session → 友好错误提示。 +- daemon 重启 → 孤儿 worktree 被 sweep 清理。 + +## 风险和控制 + +- **风险**:worktree 创建增加 session 启动延迟(~100-500ms,取决于仓库 + 大小)。**控制**:`git worktree add` 是轻量操作(不复制文件,只创建 + 目录和 `.git` 文件),对大仓库也很快。如果成为瓶颈,可以异步创建并 + 让 session 先启动、后切换。 + +- **风险**:worktree 残留占用磁盘。**控制**:session 正常退出时自动清理; + 异常退出靠 `sweepStaleWorktrees` 兜底;Web Shell 可展示残留 worktree + 列表供手动清理(后续增量)。 + +- **风险**:worktree 内的 branch 与主目录 branch 冲突(同一 branch 不能 + 同时被两个 worktree checkout)。**控制**:`createUserWorktree` 总是创建 + 新分支 `worktree-`,不会 checkout 已有分支。 + +- **风险**:用户在 worktree session 里做的改动"找不到"(不知道在哪个 + worktree 里)。**控制**:session 列表显示 worktree 分支名和路径; + session 摘要包含 worktree 元数据。 + +- **风险**:`sessionScope: 'single'` 下同一 workspace 的第二次 + `POST /session` 会 attach 到已有 session 而非创建新的,worktree 参数 + 被忽略。**控制**:worktree session 强制 `sessionScope: 'thread'`, + 确保每次调用创建独立 session。 + +## 实施分步 + +### Phase 1:daemon + SDK(核心链路) + +1. `BridgeSpawnRequest` 加 `initialCwd` / `worktree` 字段。 +2. `spawnChannel` 支持 `initialCwd`。 +3. `POST /session` 处理 worktree 创建。 +4. SDK `CreateSessionRequest` 加 `worktree`。 +5. session 摘要返回 worktree 元数据。 +6. 单测。 + +### Phase 2:Web Shell UI + +1. `createSession` 传 worktree 参数。 +2. git chip / `/diff` 跟随 session 实际 cwd。 +3. session 列表显示 worktree 标记。 +4. 浏览器验收。 + +### Phase 3:生命周期管理(后续增量) + +1. session 结束时 worktree 清理策略。 +2. 残留 worktree 列表和手动清理 UI。 +3. worktree session 的 merge-back 辅助(agent 侧)。 diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index a0d0133e786..3a461d201e4 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -435,6 +435,8 @@ interface SessionEntry { /** Immutable creator attribution, persisted in the transcript when present. */ sourceType?: string; sourceId?: string; + /** Worktree isolation metadata, when created with worktree param. */ + worktree?: { slug: string; path: string; branch: string }; channel: AcpChannel; connection: ClientSideConnection; /** Per-session event bus drives `GET /session/:id/events`. */ @@ -1668,6 +1670,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { hasTurnError: entry.turnError !== undefined, ...(entry.turnError !== undefined ? { turnError: entry.turnError } : {}), pendingInteractions: [...entry.pendingInteractions.values()], + ...(entry.worktree ? { worktree: entry.worktree } : {}), }; }; // Pending + resolved permission state lives in @@ -2223,6 +2226,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { parentSessionId?: string, sourceType?: string, sourceId?: string, + worktree?: { slug: string; path: string; branch: string }, ): Promise { // Get-or-create the daemon's single channel, then call // `connection.newSession()` on it. Sessions share the child's @@ -2327,7 +2331,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { newSessionResp.sessionId, boundWorkspace, undefined, - { parentSessionId, sourceType, sourceId }, + { parentSessionId, sourceType, sourceId, worktree }, ); initializedSessionId = entry.sessionId; sessionRegistered = true; @@ -2518,6 +2522,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...(entry.parentSessionId ? { parentSessionPersisted: parentSessionPersisted === true } : {}), + ...(entry.worktree ? { worktree: entry.worktree } : {}), }; } finally { ci.sessionSpawnsInFlight = Math.max(0, ci.sessionSpawnsInFlight - 1); @@ -3462,6 +3467,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { parentSessionId?: string; sourceType?: string; sourceId?: string; + worktree?: { slug: string; path: string; branch: string }; } = {}, ): SessionEntry => { const entry: SessionEntry = { @@ -3473,6 +3479,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { : {}), ...(options.sourceType ? { sourceType: options.sourceType } : {}), ...(options.sourceId !== undefined ? { sourceId: options.sourceId } : {}), + ...(options.worktree ? { worktree: options.worktree } : {}), channel: ci.channel, connection: ci.connection, events, @@ -4651,6 +4658,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { req.parentSessionId, source.sourceType, source.sourceId, + req.worktree, ); // Track in-flight spawns regardless of scope. Under `single` // this also serves the coalescing path above (a parallel diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 436bbf56be0..2f0c4343539 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -94,6 +94,8 @@ export interface BridgeSpawnRequest { /** Optional source-specific identifier. Valid only with `sourceType`. */ sourceId?: string; approvalMode?: ApprovalMode; + /** Worktree isolation metadata, set by the daemon route before spawn. */ + worktree?: { slug: string; path: string; branch: string }; } export interface BridgeSession { @@ -126,6 +128,8 @@ export interface BridgeSession { sourceId?: string; /** True iff the source metadata was durably written to the transcript. */ sourcePersisted?: boolean; + /** Present when the session was created with worktree isolation. */ + worktree?: { slug: string; path: string; branch: string }; } export interface BridgeRestoreSessionRequest { @@ -406,6 +410,8 @@ export interface BridgeSessionSummary { groupId?: string | null; /** Quick color grouping tag; mutually exclusive with `groupId` in the UI. */ color?: SessionGroupPresetColor | null; + /** Present when the session was created with worktree isolation. */ + worktree?: { slug: string; path: string; branch: string }; } /** diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index a8ef3705400..fa3316df989 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -10,6 +10,7 @@ import { APPROVAL_MODES, BTW_MAX_INPUT_LENGTH, GROUP_COLOR_OPTIONS, + GitWorktreeService, SessionService, SessionOrganizationError, SESSION_TRANSCRIPT_MAX_LIMIT, @@ -20,6 +21,8 @@ import { SessionTranscriptSnapshotUnavailableError, addDaemonRequestAttribute, runWithoutDebugLogSession, + writeWorktreeSessionMarker, + writeWorktreeSession, type ApprovalMode, type SessionGroupColor, type SessionGroupPresetColor, @@ -1195,6 +1198,84 @@ export function registerSessionRoutes( } const clientId = parseClientIdHeader(req, res); if (clientId === null) return; + + // ── Worktree isolation ────────────────────────────────────────── + // When `worktree` is present, create a git worktree before spawning + // and relocate the session into it immediately after. The workspace + // runtime resolution still uses the main workspace cwd; only the + // child process's effective working directory changes. + let worktreeMeta: + | { slug: string; path: string; branch: string } + | undefined; + const rawWorktree = body['worktree']; + if (rawWorktree !== undefined && rawWorktree !== null) { + if (typeof rawWorktree !== 'object' || Array.isArray(rawWorktree)) { + res.status(400).json({ + error: + '`worktree` must be an object (e.g. `{}` or `{"slug":"my-task"}`)', + code: 'invalid_worktree', + }); + return; + } + const wtReq = rawWorktree as Record; + let wtService: GitWorktreeService; + try { + wtService = new GitWorktreeService(workspaceCwd); + } catch { + res.status(500).json({ + error: 'Failed to initialize worktree service', + code: 'worktree_init_failed', + }); + return; + } + if (!(await wtService.isGitRepository())) { + res.status(400).json({ + error: 'Worktree isolation requires a git repository', + code: 'worktree_not_git_repo', + }); + return; + } + const rawSlug = wtReq['slug']; + let slug: string; + if (rawSlug === undefined || rawSlug === null) { + slug = GitWorktreeService.generateAutoSlug(); + } else if (typeof rawSlug !== 'string' || rawSlug.length === 0) { + res.status(400).json({ + error: '`worktree.slug` must be a non-empty string when provided', + code: 'worktree_invalid_slug', + }); + return; + } else { + slug = rawSlug; + } + const slugError = GitWorktreeService.validateUserWorktreeSlug(slug); + if (slugError) { + res + .status(400) + .json({ error: slugError, code: 'worktree_invalid_slug' }); + return; + } + const baseBranch = await wtService + .getCurrentBranch() + .catch(() => undefined); + const wtResult = await wtService.createUserWorktree(slug, baseBranch); + if (!wtResult.success || !wtResult.worktree) { + res.status(500).json({ + error: wtResult.error ?? 'Failed to create worktree', + code: 'worktree_create_failed', + }); + return; + } + worktreeMeta = { + slug, + path: wtResult.worktree.path, + branch: wtResult.worktree.branch, + }; + // Worktree sessions must be independent — never coalesce onto an + // existing single-scope session that lives in the main checkout. + sessionScope = 'thread'; + } + try { const session = await runtime.bridge.spawnOrAttach({ workspaceCwd, @@ -1206,6 +1287,7 @@ export function registerSessionRoutes( ? { sourceType: source.sourceType } : {}), ...(source.sourceId !== undefined ? { sourceId: source.sourceId } : {}), + ...(worktreeMeta ? { worktree: worktreeMeta } : {}), }); // Client may have disconnected during the 1–3s spawn window. If // so, the response can't be delivered. The session is otherwise @@ -1282,8 +1364,58 @@ export function registerSessionRoutes( } return; } + + // Relocate the freshly spawned session into its worktree. The + // cd chains onto the session's promptQueue, so it completes + // before any subsequent prompt is processed. + if (worktreeMeta) { + try { + await runtime.bridge.changeSessionCwd(session.sessionId, { + path: worktreeMeta.path, + }); + await writeWorktreeSessionMarker( + worktreeMeta.path, + session.sessionId, + ).catch(() => {}); + // Write the worktree sidecar so the session list can restore + // worktree metadata after a daemon restart. + await writeWorktreeSession( + new SessionService(workspaceCwd).getWorktreeSessionPath( + session.sessionId, + ), + { + slug: worktreeMeta.slug, + worktreePath: worktreeMeta.path, + worktreeBranch: worktreeMeta.branch, + originalCwd: workspaceCwd, + originalBranch: '', + originalHeadCommit: '', + }, + ).catch(() => {}); + } catch (cdErr) { + // The session is usable even if the cd fails — it just stays + // in the main checkout. Surface the worktree metadata so the + // client knows what was intended. + if (daemonLog) { + daemonLog.warn('worktree cd failed after spawn', { + sessionId: session.sessionId, + worktreePath: worktreeMeta.path, + error: cdErr instanceof Error ? cdErr.message : String(cdErr), + }); + } + } + } + res.status(200).json(session); } catch (err) { + // Roll back the worktree if spawn failed — otherwise the directory + // and branch are orphaned (the agent-* stale cleanup won't collect + // user-named worktrees). + if (worktreeMeta) { + await new GitWorktreeService(workspaceCwd) + .removeUserWorktree(worktreeMeta.slug, { deleteBranch: true }) + .catch(() => {}); + } sendBridgeError(res, err, { route: 'POST /session' }); } }); diff --git a/packages/cli/src/serve/routes/workspace-git.ts b/packages/cli/src/serve/routes/workspace-git.ts index fea33d7fb28..53038716183 100644 --- a/packages/cli/src/serve/routes/workspace-git.ts +++ b/packages/cli/src/serve/routes/workspace-git.ts @@ -5,6 +5,8 @@ */ import type { Application, Request, Response } from 'express'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import type { AcpSessionBridge } from '../acp-session-bridge.js'; import type { SendBridgeError } from '../server/error-response.js'; import type { WorkspaceGitState } from '../workspace-git-state.js'; @@ -59,12 +61,27 @@ export function registerWorkspaceQualifiedGitRoutes( const runtime = resolveTrustedRuntime(deps.workspaceRegistry, req, res); if (!runtime) return; const route = 'GET /workspaces/:workspace/git'; + // Optional ?cwd= override for worktree sessions whose working directory + // differs from the workspace root. Canonicalize both paths with realpath + // to prevent symlink escape, then validate containment. + const rawCwd = req.query['cwd']; + let gitCwd = runtime.workspaceCwd; + if (typeof rawCwd === 'string' && rawCwd.length > 0) { + try { + const resolved = fs.realpathSync(path.resolve(rawCwd)); + const root = fs.realpathSync(runtime.workspaceCwd); + const rel = path.relative(root, resolved); + if (!rel.startsWith('..') && !path.isAbsolute(rel)) { + gitCwd = resolved; + } + } catch { + // Path doesn't exist or can't be resolved — use workspace root. + } + } try { res .status(200) - .json( - await deps.gitState.getStatus(runtime.workspaceCwd, runtime.bridge), - ); + .json(await deps.gitState.getStatus(gitCwd, runtime.bridge)); } catch (err) { deps.sendBridgeError(res, err, { route }); } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index c26f3f012e3..f047d7d9f78 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -152,6 +152,32 @@ import { TRUSTED_FOLDERS_FILENAME, } from '../config/trustedFolders.js'; +// ── Worktree mock infrastructure ──────────────────────────────────── +// GitWorktreeService's constructor calls simpleGit() which validates +// the directory exists — test workspaces (/work/bound) don't. Replace +// the class with a controllable mock; each worktree test sets +// `mockWt.impl` to control instance behaviour. +const mockWt = vi.hoisted(() => ({ + impl: undefined as (() => Record) | undefined, +})); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + GitWorktreeService: class MockGitWorktreeService { + static validateUserWorktreeSlug = + original.GitWorktreeService.validateUserWorktreeSlug; + static generateAutoSlug = original.GitWorktreeService.generateAutoSlug; + constructor() { + if (mockWt.impl) { + Object.assign(this, mockWt.impl()); + } + } + }, + }; +}); + const baseOpts: ServeOptions = { hostname: '127.0.0.1', port: 4170, @@ -725,6 +751,7 @@ interface FakeBridge extends AcpSessionBridge { opts?: { requireZeroAttaches?: boolean }; }>; detachCalls: Array<{ sessionId: string; clientId?: string }>; + changeSessionCwdCalls: Array<{ sessionId: string; path: string }>; enqueueMidTurnCalls: Array<{ sessionId: string; message: string; @@ -902,6 +929,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { opts?: { requireZeroAttaches?: boolean }; }> = []; const detachCalls: FakeBridge['detachCalls'] = []; + const changeSessionCwdCalls: Array<{ sessionId: string; path: string }> = []; const enqueueMidTurnCalls: FakeBridge['enqueueMidTurnCalls'] = []; const enqueueMidTurnImpl = opts.enqueueMidTurnImpl ?? (() => ({ accepted: true })); @@ -964,6 +992,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { workspaceCwd: req.workspaceCwd, attached: false, clientId: `client-${calls.length}`, + ...(req.worktree ? { worktree: req.worktree } : {}), })); const loadImpl = opts.loadImpl ?? @@ -1465,6 +1494,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { cancelCalls, killCalls, detachCalls, + changeSessionCwdCalls, enqueueMidTurnCalls, permissionVotes, sessionPermissionVotes, @@ -1942,6 +1972,15 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { ...(clientId !== undefined ? { clientId } : {}), }); }, + async changeSessionCwd(sessionId, req) { + changeSessionCwdCalls.push({ sessionId, path: req.path }); + return { + sessionId, + previousCwd: '/fake/previous', + newCwd: req.path, + warnings: [], + }; + }, isChannelLive() { return false; }, @@ -8070,6 +8109,146 @@ describe('createServeApp', () => { // dangerous key landed via spread, this check would fail.) expect(({} as Record)['polluted']).toBeUndefined(); }); + + // ── Worktree isolation ────────────────────────────────────────── + + it('creates a worktree session and relocates via changeSessionCwd', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const mockCreate = vi.fn().mockResolvedValue({ + success: true, + worktree: { + path: '/work/a/.qwen/worktrees/my-task', + branch: 'worktree-my-task', + }, + }); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + createUserWorktree: mockCreate, + }); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ worktree: { slug: 'my-task' } }); + + expect(res.status).toBe(200); + expect(res.body.worktree).toEqual({ + slug: 'my-task', + path: '/work/a/.qwen/worktrees/my-task', + branch: 'worktree-my-task', + }); + expect(bridge.calls[0]?.sessionScope).toBe('thread'); + expect(bridge.changeSessionCwdCalls).toHaveLength(1); + expect(bridge.changeSessionCwdCalls[0]?.path).toBe( + '/work/a/.qwen/worktrees/my-task', + ); + expect(mockCreate).toHaveBeenCalledWith('my-task', 'main'); + } finally { + mockWt.impl = undefined; + } + }); + + it('400 when worktree is requested on a non-git workspace', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(false), + }); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ worktree: {} }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('worktree_not_git_repo'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + } + }); + + it('400 when worktree slug is invalid', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + }); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ worktree: { slug: '../escape' } }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('worktree_invalid_slug'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + } + }); + + it('400 when worktree is not an object', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ worktree: 'yes' }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_worktree'); + expect(bridge.calls).toHaveLength(0); + }); + + it('500 when worktree creation fails', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + createUserWorktree: () => + Promise.resolve({ success: false, error: 'disk full' }), + }); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ worktree: {} }); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('worktree_create_failed'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + } + }); }); describe('POST /session/:id/load and /resume', () => { diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index ee51a282329..c4fbed6db09 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -7,6 +7,7 @@ import { SessionService, SessionOrganizationError, + readWorktreeSession, type SessionArchiveState, type SessionGroupPresetColor, } from '@qwen-code/qwen-code-core'; @@ -491,6 +492,21 @@ async function listOrganizedWorkspaceSessionsForResponse( ); } + // Enrich persisted sessions with worktree metadata from sidecar files. + for (const [sessionId, summary] of bySessionId) { + if (summary.worktree) continue; + const sidecar = await readWorktreeSession( + sessionService.getWorktreeSessionPath(sessionId), + ).catch(() => null); + if (sidecar) { + summary.worktree = { + slug: sidecar.slug, + path: sidecar.worktreePath, + branch: sidecar.worktreeBranch, + }; + } + } + if ( readOptions.mergeLive !== false && archiveState !== 'archived' && @@ -606,6 +622,21 @@ async function listWorkspaceSessionsByMetadataForResponse( bySessionId.set(session.sessionId, session); } + // Enrich persisted sessions with worktree metadata from sidecar files. + for (const [sessionId, summary] of bySessionId) { + if (summary.worktree) continue; + const sidecar = await readWorktreeSession( + sessionService.getWorktreeSessionPath(sessionId), + ).catch(() => null); + if (sidecar) { + summary.worktree = { + slug: sidecar.slug, + path: sidecar.worktreePath, + branch: sidecar.worktreeBranch, + }; + } + } + let liveMergeFailed = false; if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { @@ -749,6 +780,22 @@ export async function listWorkspaceSessionsForResponse( bySessionId.set(item.sessionId, toSummary(item)); } + // Enrich persisted sessions with worktree metadata from sidecar files + // so the info survives daemon restarts. + for (const [sessionId, summary] of bySessionId) { + if (summary.worktree) continue; + const sidecar = await readWorktreeSession( + sessionService.getWorktreeSessionPath(sessionId), + ).catch(() => null); + if (sidecar) { + summary.worktree = { + slug: sidecar.slug, + path: sidecar.worktreePath, + branch: sidecar.worktreeBranch, + }; + } + } + if (archiveState === 'archived' || readOptions.mergeLive === false) { const sessions = [...bySessionId.values()]; const nextCursor = diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 49dd6bf4e7b..cadbd3b037c 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -463,6 +463,15 @@ export interface CreateSessionRequest { sourceType?: string; /** Optional source-specific identifier. Requires `sourceType`. */ sourceId?: string; + /** + * Create the session in an isolated git worktree. The daemon creates + * a worktree under `/.qwen/worktrees/` and relocates + * the session's working directory into it. Pass `{}` for an + * auto-generated slug, or `{ slug: 'my-task' }` for a named one. + * Requires the workspace to be a git repository. Worktree sessions + * are always created with `sessionScope: 'thread'`. + */ + worktree?: { slug?: string }; } export interface RestoreSessionRequest { @@ -1997,6 +2006,7 @@ export class DaemonClient { ? { sourceType: req.sourceType } : {}), ...(req.sourceId !== undefined ? { sourceId: req.sourceId } : {}), + ...(req.worktree !== undefined ? { worktree: req.worktree } : {}), }), }, async (res) => { @@ -4199,10 +4209,11 @@ export class WorkspaceDaemonClient { ); } - workspaceGit(): Promise { + workspaceGit(cwd?: string): Promise { + const suffix = cwd ? `/git?cwd=${encodeURIComponent(cwd)}` : '/git'; return this.client.workspaceJsonRequest( this.workspaceSelector, - '/git', + suffix, 'GET /workspaces/:workspace/git', { mode: 'rest' }, ); diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index d0e6e77da3f..40c2b51adbc 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -263,6 +263,10 @@ export class DaemonSessionClient { return this.session.clientId; } + get worktree(): DaemonSession['worktree'] { + return this.session.worktree; + } + get lastEventId(): number | undefined { return this.lastSeenEventId; } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index fd7c8258a2e..d8a9acdc726 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -427,6 +427,7 @@ export type { DaemonRestoredSession, DaemonSession, DaemonSessionArchiveState, + DaemonWorktreeInfo, DaemonSessionExportFormat, DaemonSessionExportResult, DaemonSessionTranscriptPage, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 65a1569d3b5..d6847e6b8bd 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -555,6 +555,13 @@ export interface DaemonStatusReport { }; } +/** Worktree metadata returned when a session is created with worktree isolation. */ +export interface DaemonWorktreeInfo { + slug: string; + path: string; + branch: string; +} + /** Returned from `POST /session`. */ export interface DaemonSession { sessionId: string; @@ -576,6 +583,8 @@ export interface DaemonSession { sourceId?: string; /** True iff supplied source metadata was durably written to the transcript. */ sourcePersisted?: boolean; + /** Present when the session was created with worktree isolation. */ + worktree?: DaemonWorktreeInfo; } /** @@ -719,6 +728,8 @@ export interface DaemonSessionSummary { groupId?: string | null; /** Quick color grouping tag; mutually exclusive with `groupId` in the UI. */ color?: DaemonSessionGroupPresetColor | null; + /** Present when the session was created with worktree isolation. */ + worktree?: DaemonWorktreeInfo; } export type DaemonSessionExportFormat = 'html' | 'md' | 'json' | 'jsonl'; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 26d75c0fc65..865aaaa6771 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -152,6 +152,7 @@ export { type DaemonRestoredSession, type DaemonSession, type DaemonSessionClosedReason, + type DaemonWorktreeInfo, type DaemonSessionClientOptions, type DaemonSessionContextStatus, type DaemonLspServerStatus, diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index a85c62cf59b..aa910208f89 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -484,6 +484,29 @@ overflow: visible; } +.worktreeWelcomeBadge { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin-top: 12px; + padding: 12px 20px; + border-radius: 12px; + background: var(--color-accent-bg, rgba(139, 92, 246, 0.06)); + border: 1px solid var(--color-accent-border, rgba(139, 92, 246, 0.15)); + color: var(--color-accent-fg, #8b5cf6); +} + +.worktreeWelcomeTitle { + font-size: 14px; + font-weight: 600; +} + +.worktreeWelcomeDesc { + font-size: 12px; + opacity: 0.7; +} + .missingSessionState { flex: 1 1 auto; min-height: 0; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index c9471eead63..eb1b9885e9b 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -38,6 +38,7 @@ import type { DaemonWorkspaceCapability, DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; +import { GitForkIcon } from 'lucide-react'; import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList, type MessageListHandle } from './components/MessageList'; import { extractVoiceModels, type VoiceModelOption } from './voice/voiceModels'; @@ -612,7 +613,11 @@ type SessionActionsWithCreate = { workspaceCwd?: string; approvalMode?: string; sourceType?: string; - }) => Promise<{ sessionId: string }>; + worktree?: { slug?: string }; + }) => Promise<{ + sessionId: string; + worktree?: { slug: string; path: string; branch: string }; + }>; attachSession: () => Promise; clearSession: () => Promise; releaseSession: (sessionId: string) => Promise; @@ -1288,6 +1293,30 @@ export function App({ // branch/dirty counts while the new fetch is in flight; same-workspace // re-runs (branch change, focus, poll) keep the live value to avoid flicker. const gitStatusWorkspaceCwdRef = useRef(undefined); + /** Worktree metadata for the current session (set after creation). */ + const [sessionWorktree, setSessionWorktree] = useState< + { slug: string; path: string; branch: string } | undefined + >(undefined); + // Restore worktree info from the server when switching to an existing session. + useEffect(() => { + const sid = connection.sessionId; + if (!sid) { + setSessionWorktree(undefined); + return; + } + let cancelled = false; + workspace.client + .sessionStatus(sid) + .then((summary) => { + if (!cancelled) setSessionWorktree(summary.worktree); + }) + .catch(() => { + if (!cancelled) setSessionWorktree(undefined); + }); + return () => { + cancelled = true; + }; + }, [connection.sessionId, workspace.client]); // Active workspace: the connected session's workspace, else the workspace // picked for the next session (locked / selected / primary). Computed once // and shared by the git-status effect and the Changes-dialog entry point so @@ -1307,6 +1336,10 @@ export function App({ workspaces, ], ); + // Worktree sessions override the git chip branch via sessionWorktree.branch. + // The enriched status (dirty/ahead/behind) still queries the main workspace + // because the worktree path is not a registered daemon workspace — full + // worktree-scoped git status needs daemon support (Phase 3). useEffect(() => { if (!activeWorkspaceCwd) { gitStatusWorkspaceCwdRef.current = undefined; @@ -1321,7 +1354,7 @@ export function App({ const fetchStatus = () => { void workspace.client .workspaceByCwd(activeWorkspaceCwd) - .workspaceGit() + .workspaceGit(sessionWorktree?.path) .then((git) => { if (!cancelled) setSelectedWorkspaceGitStatus(git); }) @@ -1343,7 +1376,12 @@ export function App({ window.removeEventListener('focus', onFocus); window.clearInterval(poll); }; - }, [activeWorkspaceCwd, connection.gitBranch, workspace.client]); + }, [ + activeWorkspaceCwd, + connection.gitBranch, + workspace.client, + sessionWorktree, + ]); const onToastRef = useRef(onToast); onToastRef.current = onToast; const toastIdRef = useRef(0); @@ -2704,6 +2742,10 @@ export function App({ const [isPreparingPrompt, setIsPreparingPrompt] = useState(false); const createSessionPromiseRef = useRef | null>(null); const preparingSessionIdRef = useRef(null); + /** Worktree request for the next lazily-created session. */ + const pendingWorktreeRef = useRef<{ slug?: string } | undefined>(undefined); + /** Render-visible mirror of pendingWorktreeRef for the empty-state badge. */ + const [worktreePending, setWorktreePending] = useState(false); const newSessionSuggestionSubmitTokenRef = useRef(0); const pendingNewSessionSuggestionSubmitRef = useRef<{ token: number; @@ -2766,12 +2808,19 @@ export function App({ lockedWorkspaceCwd ?? selectedWorkspaceCwdRef.current ?? primaryWorkspaceCwd, + worktree: pendingWorktreeRef.current, onSessionCreated: onSessionCreatedRef.current, onSessionAllocated: (sessionId) => { preparingSessionIdRef.current = sessionId; }, getCurrentSessionId: () => connectionRef.current.sessionId, + }).then((result) => { + if (result.worktree) { + setSessionWorktree(result.worktree); + } }); + pendingWorktreeRef.current = undefined; + setWorktreePending(false); // One-shot: the picker targets only the *next* new session, so clear // it after creation. The next new chat defaults back to the primary // workspace unless the user picks one again. @@ -3853,11 +3902,14 @@ export function App({ * stay mounted until its prompt is admitted, or a rejection has nowhere to * render. Only that caller passes this. */ - opts?: { keepView?: boolean }, + opts?: { keepView?: boolean; worktree?: { slug?: string } }, ) => { const targetWorkspaceCwd = lockedWorkspaceCwd ?? workspaceCwd; selectedWorkspaceCwdRef.current = targetWorkspaceCwd; setSelectedWorkspaceCwd(targetWorkspaceCwd); + pendingWorktreeRef.current = opts?.worktree; + setWorktreePending(Boolean(opts?.worktree)); + setSessionWorktree(undefined); // Close the drawer before awaiting so a failed createSession() doesn't leave // it stuck open with the page scroll still locked, matching loadSidebarSession. closeMobileDrawer(); @@ -4082,6 +4134,9 @@ export function App({ async (sessionId: string, workspaceCwd?: string) => { composerFocusRequestRef.current += 1; setSidebarSwitchingSessionId(sessionId); + pendingWorktreeRef.current = undefined; + setWorktreePending(false); + setSessionWorktree(undefined); // Close the drawer before awaiting the load; the transcript clears // immediately and shows its loading skeleton for the selected session. closeMobileDrawer(); @@ -5891,13 +5946,27 @@ export function App({ ); const welcomeHeader = useMemo( - () => - renderWelcomeHeader ? ( - renderWelcomeHeader(welcomeHeaderProps) - ) : ( - - ), - [renderWelcomeHeader, welcomeHeaderProps], + () => ( + <> + {renderWelcomeHeader ? ( + renderWelcomeHeader(welcomeHeaderProps) + ) : ( + + )} + {worktreePending && ( +
+ + + {t('worktree.welcomeTitle')} + + + {t('worktree.welcomeDesc')} + +
+ )} + + ), + [renderWelcomeHeader, welcomeHeaderProps, worktreePending, t], ); const welcomeFooter = useMemo( () => renderWelcomeFooter?.(welcomeHeaderProps), @@ -6378,8 +6447,8 @@ export function App({ webShellThemeToSettingValue(theme), ); }} - onNewSession={(workspaceCwd) => { - return createNewSession(workspaceCwd); + onNewSession={(workspaceCwd, opts) => { + return createNewSession(workspaceCwd, opts); }} onLoadSession={(sessionId, workspaceCwd) => { setMainView('chat'); @@ -7192,13 +7261,16 @@ export function App({ currentMode={currentMode} currentModel={currentModel} gitBranch={ - connection.sessionId + sessionWorktree?.branch ?? + (connection.sessionId ? connection.gitBranch - : (selectedWorkspaceGitStatus?.branch ?? undefined) + : (selectedWorkspaceGitStatus?.branch ?? + undefined)) } + gitWorktree={Boolean(sessionWorktree)} gitStatus={selectedWorkspaceGitStatus} onOpenGitDiff={ - gitDiffWorkspaceCwd + gitDiffWorkspaceCwd && !sessionWorktree ? () => setDiffWorkspaceCwd(gitDiffWorkspaceCwd) : undefined } diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 648ee9bc049..2dc65eb2d2a 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -57,6 +57,7 @@ interface TranscriptMessageOptions { function isIgnoredWebShellStatus(text: string): boolean { return ( text.startsWith('language_changed (unrecognized daemon event):') || + text.startsWith('session_cwd_changed (unrecognized daemon event):') || text.startsWith('Model switched: ') ); } diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 2178a2ca908..60cbde407b8 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -996,6 +996,10 @@ color: var(--warning-color); } +.gitBranchChip[data-worktree='true'] { + color: var(--color-accent-fg, #8b5cf6); +} + .gitBranchIconWrap { position: relative; display: inline-flex; diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index b638ce8edf1..778987ea8d4 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -131,6 +131,8 @@ interface ChatEditorProps { currentMode?: string; currentModel?: string; gitBranch?: string; + /** Whether the session is in a worktree (styles the git chip purple). */ + gitWorktree?: boolean; /** Enriched working-tree summary (dirty / ahead-behind / stash / operation). */ gitStatus?: DaemonWorkspaceGitStatus; /** Opens the working-tree Changes dialog; makes the git chip clickable. */ @@ -1145,6 +1147,7 @@ export const ChatEditor = memo( currentMode = 'default', currentModel = '', gitBranch, + gitWorktree, gitStatus, onOpenGitDiff, workspaceName, @@ -2121,6 +2124,7 @@ export const ChatEditor = memo( status={gitStatus} compact={!showGitBranchLabel} onOpenDiff={onOpenGitDiff} + worktree={gitWorktree} /> )} {showModeAction && ( @@ -2446,6 +2450,7 @@ export const ChatEditor = memo( branch={gitBranch} status={gitStatus} compact + worktree={gitWorktree} /> diff --git a/packages/web-shell/client/components/GitBranchIndicator.tsx b/packages/web-shell/client/components/GitBranchIndicator.tsx index 57d02dfd42a..175b9065578 100644 --- a/packages/web-shell/client/components/GitBranchIndicator.tsx +++ b/packages/web-shell/client/components/GitBranchIndicator.tsx @@ -5,7 +5,12 @@ */ import type { DaemonWorkspaceGitStatus } from '@qwen-code/sdk/daemon'; -import { CircleDotIcon, LayersIcon, TriangleAlertIcon } from 'lucide-react'; +import { + CircleDotIcon, + GitForkIcon, + LayersIcon, + TriangleAlertIcon, +} from 'lucide-react'; import { useI18n } from '../i18n'; import styles from './ChatEditor.module.css'; import { @@ -87,10 +92,12 @@ export function GitBranchChipContent({ branch, status, compact, + worktree = false, }: { branch: string; status?: DaemonWorkspaceGitStatus; compact: boolean; + worktree?: boolean; }) { const { t } = useI18n(); const s = deriveStatus(status); @@ -99,7 +106,13 @@ export function GitBranchChipContent({ <> - {s.detached ? : } + {worktree ? ( + + ) : s.detached ? ( + + ) : ( + + )} {compact && tone && ( void; + worktree?: boolean; }) { const { t } = useI18n(); const s = deriveStatus(status); @@ -187,10 +202,16 @@ export function GitBranchIndicator({ 'data-dirty': s.dirty ? 'true' : undefined, 'data-operation': s.operation ?? undefined, 'data-clickable': onOpenDiff ? 'true' : undefined, + 'data-worktree': worktree ? 'true' : undefined, } as const; const chipInner = ( - + ); return ( diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 7890f773f79..2fb02fd5116 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -476,6 +476,14 @@ white-space: nowrap; } +.sessionBadgeIcon { + display: inline; + vertical-align: -1px; + margin-right: 3px; + color: var(--color-accent-fg, #8b5cf6); + flex-shrink: 0; +} + .projectName { flex: 0 1 auto; } diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index b42d006e431..af35fd3dd4b 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -41,6 +41,7 @@ import { ArchiveRestoreIcon, DownloadIcon, FolderInputIcon, + GitForkIcon, PencilIcon, PinIcon, Trash2Icon, @@ -222,7 +223,10 @@ interface WebShellSidebarProps { onOpenSplitView: () => void; /** Whether to offer the in-window split view (large screens only). */ canOpenSplitView?: boolean; - onNewSession: (workspaceCwd?: string) => Promise | boolean; + onNewSession: ( + workspaceCwd?: string, + opts?: { worktree?: { slug?: string } }, + ) => Promise | boolean; onLoadSession: ( sessionId: string, workspaceCwd?: string, @@ -1383,14 +1387,14 @@ export function WebShellSidebar({ ]); const handleNewSession = useCallback( - (workspaceCwd?: string) => { + (workspaceCwd?: string, opts?: { worktree?: { slug?: string } }) => { if (creatingSessionRef.current) return; creatingSessionRef.current = true; setCreatingSession(true); void (async () => { try { - const created = await onNewSession(workspaceCwd); + const created = await onNewSession(workspaceCwd, opts); if (created) { void reload().catch(() => undefined); bumpWorkspaceReload(); @@ -2552,7 +2556,17 @@ export function WebShellSidebar({ ) : ( <> - {label} + + {session.worktree && ( + + )} + {label} +
{attentionLabel && ( + handleNewSession(ws.primary ? undefined : cwd, { + worktree: {}, + }) + } formatTime={(iso) => formatRelativeTime(iso, t)} searchQuery={searchQuery} expanded={ws.primary ? projectExpanded : undefined} @@ -3558,6 +3577,7 @@ export function WebShellSidebar({ !ws.primary && ws.removable === true; if (!ws.trusted && !canRemove) return null; + const wsCwd = ws.primary ? undefined : ws.cwd; return (
{ event.preventDefault(); event.stopPropagation(); - handleNewSession( - ws.primary ? undefined : ws.cwd, - ); + handleNewSession(wsCwd); }} > { }); describe('WorkspaceSection git chip', () => { - it('renders a clickable git chip for a trusted repo and opens its diff', async () => { + it('renders a git chip inside a dropdown trigger for a trusted repo', async () => { const status: DaemonWorkspaceGitStatus = { v: 2, workspaceCwd: '/tmp/project', @@ -136,17 +136,12 @@ describe('WorkspaceSection git chip', () => { const chip = gitChip(); expect(chip).not.toBeNull(); - expect(chip?.tagName).toBe('BUTTON'); + // The chip is now a read-only OUTPUT inside a DropdownMenuTrigger + // (the dropdown offers "Changes" and "New Worktree Task"). + expect(chip?.tagName).toBe('OUTPUT'); expect(chip?.getAttribute('data-dirty')).toBe('true'); - // Icon-only (compact) form: the branch name is not shown as inline text but - // stays reachable via the accessible name (the hover tooltip). expect(chip?.className).toContain(gitStyles.gitBranchChipCompact); expect(chip?.getAttribute('aria-label')).toContain('main'); - - act(() => { - chip?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - expect(onOpenGitDiff).toHaveBeenCalledWith('/tmp/project'); }); it('hides the chip for an untrusted workspace and never queries git', async () => { diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx index 851382fd578..98d241eaa76 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -13,9 +13,16 @@ import type { DaemonWorkspaceCapability, DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; -import { FolderClosedIcon, FolderOpenIcon } from 'lucide-react'; +import { FolderClosedIcon, FolderOpenIcon, GitForkIcon } from 'lucide-react'; import { GitBranchIndicator } from '../GitBranchIndicator'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; import { SESSION_LIST_PAGE_SIZE } from '../../constants/sessions'; +import { useI18n } from '../../i18n'; import { readWorkspaceCollapsedGroupIds, writeWorkspaceCollapsedGroupIds, @@ -97,6 +104,8 @@ interface WorkspaceSectionProps { * fires this on click. Omitted for untrusted workspaces (no git surface). */ onOpenGitDiff?: (workspaceCwd: string) => void; + /** Create a new worktree-isolated session in this workspace. */ + onNewWorktreeSession?: (workspaceCwd: string) => void; } export function WorkspaceSection({ @@ -126,7 +135,9 @@ export function WorkspaceSection({ groupActionsDisabled, excludePinned = false, onOpenGitDiff, + onNewWorktreeSession, }: WorkspaceSectionProps) { + const { t } = useI18n(); const [sessions, setSessions] = useState([]); const [groups, setGroups] = useState([]); const [loadError, setLoadError] = useState(false); @@ -352,14 +363,40 @@ export function WorkspaceSection({ )} {onOpenGitDiff && workspace.trusted && gitStatus?.branch && ( - - onOpenGitDiff(workspace.cwd)} - /> - + + + + + + + + onOpenGitDiff(workspace.cwd)}> + {t('gitDiff.title')} + + {onNewWorktreeSession && ( + onNewWorktreeSession(workspace.cwd)} + className="flex-col items-start gap-0" + > + + + {t('sidebar.newWorktreeTask')} + + + {t('sidebar.worktreeDescription')} + + + )} + + )} {headerActions?.(actionsVisible)}
diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 03940d1b41a..8c0b1481a56 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -47,6 +47,11 @@ const EN: Messages = { 'gitDiff.hidden': (v) => `${v?.count ?? 0} more file(s) not shown`, 'gitDiff.expand': (v) => `Show changes for ${v?.path ?? 'file'}`, 'gitDiff.collapse': (v) => `Hide changes for ${v?.path ?? 'file'}`, + 'worktree.isolatedBanner': (v) => + `Isolated worktree session — branch ${v?.branch ?? ''}`, + 'worktree.welcomeTitle': 'Worktree isolated session', + 'worktree.welcomeDesc': + 'Changes are made in a separate copy of the repo and won’t affect your main branch', 'workspace.paneLabel': (v) => `Workspace: ${v?.name ?? ''}`, 'about.auth': 'Auth', 'about.baseUrl': 'Base URL', @@ -871,6 +876,9 @@ const EN: Messages = { 'sidebar.toggleMenu': 'Toggle menu', 'sidebar.newChat': 'New chat', 'sidebar.newTask': 'New task', + 'sidebar.newWorktreeTask': 'New worktree task', + 'sidebar.worktreeDescription': + 'Work in an isolated copy — changes stay separate from the main branch', 'sidebar.plugins': 'Plugins', 'sidebar.project': 'Project', 'sidebar.pinnedSessions': 'Pinned', @@ -2178,6 +2186,10 @@ const ZH: Messages = { 'gitDiff.hidden': (v) => `还有 ${v?.count ?? 0} 个文件未显示`, 'gitDiff.expand': (v) => `显示 ${v?.path ?? '文件'} 的变更`, 'gitDiff.collapse': (v) => `隐藏 ${v?.path ?? '文件'} 的变更`, + 'worktree.isolatedBanner': (v) => + `Worktree 隔离会话 — 分支 ${v?.branch ?? ''}`, + 'worktree.welcomeTitle': 'Worktree 隔离会话', + 'worktree.welcomeDesc': '变更在仓库的独立副本中进行,不会影响主分支', 'workspace.paneLabel': (v) => `工作区:${v?.name ?? ''}`, // Tool display names (chat-stream badge labels). Keyed by `toolName.`; // a wire name with no entry here falls back to the English display name via @@ -3013,6 +3025,8 @@ const ZH: Messages = { 'sidebar.toggleMenu': '切换菜单', 'sidebar.newChat': '新对话', 'sidebar.newTask': '新建任务', + 'sidebar.newWorktreeTask': '新建 Worktree 任务', + 'sidebar.worktreeDescription': '在隔离的仓库副本中工作——变更不会影响主分支', 'sidebar.plugins': '插件', 'sidebar.project': '项目', 'sidebar.pinnedSessions': '置顶', diff --git a/packages/web-shell/client/utils/sessionPreparation.test.ts b/packages/web-shell/client/utils/sessionPreparation.test.ts index 9cf38078f73..2e764bebcaf 100644 --- a/packages/web-shell/client/utils/sessionPreparation.test.ts +++ b/packages/web-shell/client/utils/sessionPreparation.test.ts @@ -133,7 +133,7 @@ describe('createAndAttachSessionForPrompt', () => { modeId: 'yolo', warn, }), - ).resolves.toBeUndefined(); + ).resolves.toEqual({}); expect(order).toEqual(['create', 'attach', 'model']); expect(warn).toHaveBeenCalledWith( diff --git a/packages/web-shell/client/utils/sessionPreparation.ts b/packages/web-shell/client/utils/sessionPreparation.ts index 9f3807f9dcc..a638d6e81a7 100644 --- a/packages/web-shell/client/utils/sessionPreparation.ts +++ b/packages/web-shell/client/utils/sessionPreparation.ts @@ -11,7 +11,11 @@ type PromptSessionActions = { workspaceCwd?: string; approvalMode?: DaemonApprovalMode; sourceType?: string; - }) => Promise<{ sessionId: string }>; + worktree?: { slug?: string }; + }) => Promise<{ + sessionId: string; + worktree?: { slug: string; path: string; branch: string }; + }>; attachSession: () => Promise; clearSession: () => Promise; releaseSession: (sessionId: string) => Promise; @@ -27,6 +31,7 @@ export async function createAndAttachSessionForPrompt({ modelId, modeId, workspaceCwd, + worktree, onSessionCreated, onSessionAllocated, getCurrentSessionId, @@ -36,11 +41,12 @@ export async function createAndAttachSessionForPrompt({ modelId?: string; modeId?: string; workspaceCwd?: string; + worktree?: { slug?: string }; onSessionCreated?: (sessionId: string) => Promise | void; onSessionAllocated?: (sessionId: string) => void; getCurrentSessionId: () => string | undefined; warn?: (message?: unknown, ...optionalParams: unknown[]) => void; -}): Promise { +}): Promise<{ worktree?: { slug: string; path: string; branch: string } }> { // Seed the approval mode in the create request itself so the daemon applies // it atomically at spawn (`POST /session` → `spawnOrAttach({ approvalMode })`), // saving a follow-up round-trip. Approval mode is fail-closed at spawn: if the @@ -49,11 +55,13 @@ export async function createAndAttachSessionForPrompt({ // The model, by contrast, stays a best-effort follow-up below. const approvalMode = modeId && isDaemonApprovalMode(modeId) ? modeId : undefined; - const { sessionId } = await sessionActions.createSession({ - workspaceCwd, - sourceType: WEB_SHELL_SESSION_SOURCE_TYPE, - ...(approvalMode ? { approvalMode } : {}), - }); + const { sessionId, worktree: worktreeInfo } = + await sessionActions.createSession({ + workspaceCwd, + sourceType: WEB_SHELL_SESSION_SOURCE_TYPE, + ...(approvalMode ? { approvalMode } : {}), + ...(worktree ? { worktree } : {}), + }); onSessionAllocated?.(sessionId); let preparationStep = 'prepare new session'; try { @@ -124,4 +132,5 @@ export async function createAndAttachSessionForPrompt({ warn('[WebShell] failed to set model for new session:', error); }); } + return worktreeInfo ? { worktree: worktreeInfo } : {}; } diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 40911f4faa7..cefbc1b0738 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2055,7 +2055,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }), createDetachedSession: ( workspaceCwd?: string, - overrides?: Pick, + overrides?: Pick< + CreateSessionRequest, + 'approvalMode' | 'sourceType' | 'worktree' + >, ) => { const client = workspaceClientRef.current ?? @@ -2076,6 +2079,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...(overrides?.sourceType !== undefined ? { sourceType: overrides.sourceType } : {}), + ...(overrides?.worktree !== undefined + ? { worktree: overrides.worktree } + : {}), }; const requestClientId = clientId ? clientIdRef.current diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 9f1e7bba774..ce14b5b1f4d 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -61,7 +61,10 @@ export interface CreateDaemonSessionActionsArgs { getCreateSessionRequest: () => CreateSessionRequest; createDetachedSession: ( workspaceCwd?: string, - overrides?: Pick, + overrides?: Pick< + CreateSessionRequest, + 'approvalMode' | 'sourceType' | 'worktree' + >, ) => Promise; getConnection: () => DaemonConnectionState; hasSessionActivePrompt: () => boolean; @@ -619,6 +622,7 @@ export function createDaemonSessionActions({ workspaceCwd?: string; approvalMode?: DaemonApprovalMode; sourceType?: string; + worktree?: { slug?: string }; }) { try { manualSessionClearRef.current = false; @@ -635,6 +639,9 @@ export function createDaemonSessionActions({ ...(options?.sourceType !== undefined ? { sourceType: options.sourceType } : {}), + ...(options?.worktree !== undefined + ? { worktree: options.worktree } + : {}), }; const session = sessionRef.current; const activeSession = From f7951df69ac405274066c11e0d917968b9f27d26 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 07:52:05 +0800 Subject: [PATCH 02/15] feat(daemon): restore worktree isolation on session load/resume After daemon restart, loading a worktree session now: 1. Reads the worktree sidecar file (.worktree.json) 2. Calls changeSessionCwd to relocate the session into the worktree 3. Populates the bridge entry via setSessionWorktree so GET /session/:id/status returns worktree metadata 4. Includes worktree info in the load/resume response If the worktree directory was removed, the session continues in the main workspace without isolation (graceful degradation). Bridge: adds setSessionWorktree(sessionId, worktree) to AcpSessionBridge interface and implementation. --- packages/acp-bridge/src/bridge.ts | 7 +++++ packages/acp-bridge/src/bridgeTypes.ts | 11 ++++++++ packages/cli/src/serve/routes/session.ts | 27 ++++++++++++++++++++ packages/core/src/services/sessionService.ts | 15 ++++++++++- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 3a461d201e4..e1f54514738 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5624,6 +5624,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return { sessionId, ...result }; }, + setSessionWorktree(sessionId, worktree) { + const entry = byId.get(sessionId); + if (entry) { + entry.worktree = worktree; + } + }, + async closeSession(sessionId, context, closeOpts) { return closeSessionImpl(sessionId, context, closeOpts); }, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 2f0c4343539..018cff39c83 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -723,6 +723,17 @@ export interface AcpSessionBridge { context?: BridgeClientRequestContext, ): Promise; + /** + * 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. diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 5cfc148f069..8753ccefbe8 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -23,6 +23,7 @@ import { runWithoutDebugLogSession, writeWorktreeSessionMarker, writeWorktreeSession, + readWorktreeSession, type ApprovalMode, type SessionGroupColor, type SessionGroupPresetColor, @@ -1551,6 +1552,32 @@ export function registerSessionRoutes( } return; } + // Restore worktree isolation for sessions that were created in a + // worktree. After daemon restart the bridge entry has no worktree + // metadata and the session cwd is the main workspace root. Read the + // sidecar, relocate the session, and populate the bridge entry. + if (!session.worktree) { + const sidecar = await readWorktreeSession( + new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId), + ).catch(() => null); + if (sidecar) { + const wt = { + slug: sidecar.slug, + path: sidecar.worktreePath, + branch: sidecar.worktreeBranch, + }; + try { + await runtime.bridge.changeSessionCwd(sessionId, { + path: wt.path, + }); + runtime.bridge.setSessionWorktree(sessionId, wt); + Object.assign(session, { worktree: wt }); + } catch { + // Worktree directory may have been removed; the session + // continues in the main workspace without isolation. + } + } + } res.status(200).json(session); } catch (err) { sendBridgeError(res, err, { diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 90e947d6783..0aa0f13a9c2 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -6,6 +6,7 @@ import { Storage } from '../config/storage.js'; import { getProjectHash } from '../utils/paths.js'; +import { readWorktreeSession } from './worktreeSessionService.js'; import path from 'node:path'; import fs from 'node:fs'; import { randomUUID } from 'node:crypto'; @@ -384,9 +385,21 @@ export class SessionService { const status = await readRuntimeStatus( this.storage.getRuntimeStatusPath(sessionId), ); - return ( + if ( status?.sessionId === sessionId && getProjectHash(status.workDir) === this.projectHash + ) { + return true; + } + + // Worktree sessions record cwd as the worktree path (different project + // hash). Check the sidecar's originalCwd to recover membership. + const sidecar = await readWorktreeSession( + this.getWorktreeSessionPath(sessionId), + ).catch(() => null); + return ( + sidecar != null && + getProjectHash(sidecar.originalCwd) === this.projectHash ); } From f184757e7dd7e5d11e9d8abf3dc0c3e86db77332 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 11:50:24 +0800 Subject: [PATCH 03/15] fix(daemon): use restoreWorktreeContext for load/resume worktree restore Replace the hand-rolled containment check (startsWith, bypassable by '..' traversal, broken on Windows) with the shared restoreWorktreeContext helper which uses path.resolve + path.sep (cross-platform), validates directory liveness, and clears stale sidecars automatically. --- packages/cli/src/serve/routes/session.ts | 65 ++++++++++++------------ 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 5e42d4208e0..a13898384a4 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -23,7 +23,7 @@ import { runWithoutDebugLogSession, writeWorktreeSessionMarker, writeWorktreeSession, - readWorktreeSession, + restoreWorktreeContext, type ApprovalMode, type SessionGroupColor, type SessionGroupPresetColor, @@ -1554,42 +1554,41 @@ export function registerSessionRoutes( } // Restore worktree isolation for sessions that were created in a // worktree. After daemon restart the bridge entry has no worktree - // metadata and the session cwd is the main workspace root. Read the - // sidecar, relocate the session, and populate the bridge entry. + // metadata and the session cwd is the main workspace root. Use the + // shared restoreWorktreeContext helper which validates path + // containment (path.resolve + path.sep, cross-platform), checks + // directory liveness, and clears stale sidecars. if (!session.worktree) { const svc = new SessionService(workspaceCwd); - const sidecar = await readWorktreeSession( + const { session: sidecar } = await restoreWorktreeContext( svc.getWorktreeSessionPath(sessionId), - ).catch(() => null); + (e) => + daemonLog?.warn('worktree sidecar restore', { + sessionId, + error: String(e), + }), + ); if (sidecar) { - // Validate containment: the worktree path must be under - // /.qwen/worktrees/ to prevent a tampered sidecar - // from redirecting file operations elsewhere. - const expectedParent = sidecar.originalCwd + '/.qwen/worktrees/'; - if (sidecar.worktreePath.startsWith(expectedParent)) { - const wt = { - slug: sidecar.slug, - path: sidecar.worktreePath, - branch: sidecar.worktreeBranch, - }; - try { - await runtime.bridge.changeSessionCwd(sessionId, { - path: wt.path, - }); - runtime.bridge.setSessionWorktree(sessionId, wt); - Object.assign(session, { worktree: wt }); - } catch (restoreErr) { - if (daemonLog) { - daemonLog.warn('worktree restore failed on load/resume', { - sessionId, - worktreePath: wt.path, - error: - restoreErr instanceof Error - ? restoreErr.message - : String(restoreErr), - }); - } - } + const wt = { + slug: sidecar.slug, + path: sidecar.worktreePath, + branch: sidecar.worktreeBranch, + }; + try { + await runtime.bridge.changeSessionCwd(sessionId, { + path: wt.path, + }); + runtime.bridge.setSessionWorktree(sessionId, wt); + Object.assign(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), + }); } } } From 0d249000d722024c0180634d98ab29b328187a5e Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 15:11:13 +0800 Subject: [PATCH 04/15] fix(daemon): durable worktree membership + pre-read sidecar before load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. sessionBelongsToCurrentProject: replace sidecar-based membership with path-based inference (extract repo root from recordCwd's .qwen/worktrees/ segment). Durable — survives sidecar cleanup when the worktree is removed. 2. Pre-read the worktree sidecar BEFORE loadSession/resumeSession to avoid the race where #restoreWorktreeOnResume clears it during load. Eliminates the double restoreWorktreeContext call. --- packages/cli/src/serve/routes/session.ts | 74 +++++++++----------- packages/core/src/services/sessionService.ts | 23 +++--- 2 files changed, 45 insertions(+), 52 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index a13898384a4..b23ef66973e 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -23,7 +23,7 @@ import { runWithoutDebugLogSession, writeWorktreeSessionMarker, writeWorktreeSession, - restoreWorktreeContext, + readWorktreeSession, type ApprovalMode, type SessionGroupColor, type SessionGroupPresetColor, @@ -1493,6 +1493,13 @@ export function registerSessionRoutes( return; } try { + // Pre-read the worktree sidecar BEFORE loadSession/resumeSession. + // The ACP layer's #restoreWorktreeOnResume may clear the sidecar + // during load (dead worktree, containment failure), so we capture + // the metadata here while it still exists. + const preReadSidecar = await readWorktreeSession( + new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId), + ).catch(() => null); const session = await archiveCoordinator.runSharedMany( [sessionId], async () => { @@ -1552,44 +1559,33 @@ export function registerSessionRoutes( } return; } - // Restore worktree isolation for sessions that were created in a - // worktree. After daemon restart the bridge entry has no worktree - // metadata and the session cwd is the main workspace root. Use the - // shared restoreWorktreeContext helper which validates path - // containment (path.resolve + path.sep, cross-platform), checks - // directory liveness, and clears stale sidecars. - if (!session.worktree) { - const svc = new SessionService(workspaceCwd); - const { session: sidecar } = await restoreWorktreeContext( - svc.getWorktreeSessionPath(sessionId), - (e) => - daemonLog?.warn('worktree sidecar restore', { - sessionId, - error: String(e), - }), - ); - if (sidecar) { - const wt = { - slug: sidecar.slug, - path: sidecar.worktreePath, - branch: sidecar.worktreeBranch, - }; - try { - await runtime.bridge.changeSessionCwd(sessionId, { - path: wt.path, - }); - runtime.bridge.setSessionWorktree(sessionId, wt); - Object.assign(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), - }); - } + // Restore worktree isolation using the sidecar pre-read before + // loadSession/resumeSession (the ACP layer's + // #restoreWorktreeOnResume may have cleared it during load). + // The ACP layer already handles containment validation, liveness + // checking, stale-sidecar cleanup, and the pendingWorktreeNotice. + // Here we only relocate the cwd and populate the bridge entry. + if (!session.worktree && preReadSidecar) { + const wt = { + slug: preReadSidecar.slug, + path: preReadSidecar.worktreePath, + branch: preReadSidecar.worktreeBranch, + }; + try { + await runtime.bridge.changeSessionCwd(sessionId, { + path: wt.path, + }); + runtime.bridge.setSessionWorktree(sessionId, wt); + Object.assign(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); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 6efa0b5f3c8..68e8856504a 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -6,7 +6,6 @@ import { Storage } from '../config/storage.js'; import { getProjectHash } from '../utils/paths.js'; -import { readWorktreeSession } from './worktreeSessionService.js'; import path from 'node:path'; import fs from 'node:fs'; import { randomUUID } from 'node:crypto'; @@ -392,18 +391,16 @@ export class SessionService { return true; } - // Worktree sessions record cwd as the worktree path (different project - // hash). Check the sidecar's originalCwd to recover membership. - // Try both active and archived paths since archiveSessions moves the - // sidecar file. - for (const state of ['active', 'archived'] as const) { - const sidecar = await readWorktreeSession( - this.getWorktreeSessionPathForArchiveState(sessionId, state), - ).catch(() => null); - if ( - sidecar != null && - getProjectHash(sidecar.originalCwd) === this.projectHash - ) { + // Worktree sessions record cwd as the worktree path + // (/.qwen/worktrees/), which has a different project + // hash. Infer the repo root from the path and check its hash. This + // is durable — it doesn't depend on the sidecar file, which is + // transient and cleared when the worktree is removed. + const worktreesMarker = `${path.sep}.qwen${path.sep}worktrees${path.sep}`; + const markerIdx = recordCwd.indexOf(worktreesMarker); + if (markerIdx > 0) { + const repoRoot = recordCwd.substring(0, markerIdx); + if (getProjectHash(repoRoot) === this.projectHash) { return true; } } From 8fa71258c5e01008be02c57c9def67cae5d05d16 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 15:35:04 +0800 Subject: [PATCH 05/15] fix(daemon): read sidecar after load, reorder membership checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Read worktree sidecar AFTER loadSession/resumeSession instead of before, so the route inherits the ACP layer's verdict — dead/tampered sidecars are already cleared by #restoreWorktreeOnResume, closing the containment bypass. 2. Reorder sessionBelongsToCurrentProject: marker-based worktree check (pure string ops) before readRuntimeStatus (file I/O). 3. Object.assign → plain session.worktree = wt (BridgeRestoredSession extends BridgeSession which declares worktree?). 4. Add comment documenting the !res.writable early-return asymmetry. --- packages/cli/src/serve/routes/session.ts | 68 ++++++++++---------- packages/core/src/services/sessionService.ts | 22 +++---- 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index b23ef66973e..fd9557f0756 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1493,13 +1493,6 @@ export function registerSessionRoutes( return; } try { - // Pre-read the worktree sidecar BEFORE loadSession/resumeSession. - // The ACP layer's #restoreWorktreeOnResume may clear the sidecar - // during load (dead worktree, containment failure), so we capture - // the metadata here while it still exists. - const preReadSidecar = await readWorktreeSession( - new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId), - ).catch(() => null); const session = await archiveCoordinator.runSharedMany( [sessionId], async () => { @@ -1559,33 +1552,40 @@ export function registerSessionRoutes( } return; } - // Restore worktree isolation using the sidecar pre-read before - // loadSession/resumeSession (the ACP layer's - // #restoreWorktreeOnResume may have cleared it during load). - // The ACP layer already handles containment validation, liveness - // checking, stale-sidecar cleanup, and the pendingWorktreeNotice. - // Here we only relocate the cwd and populate the bridge entry. - if (!session.worktree && preReadSidecar) { - const wt = { - slug: preReadSidecar.slug, - path: preReadSidecar.worktreePath, - branch: preReadSidecar.worktreeBranch, - }; - try { - await runtime.bridge.changeSessionCwd(sessionId, { - path: wt.path, - }); - runtime.bridge.setSessionWorktree(sessionId, wt); - Object.assign(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), - }); + // 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) { + const sidecar = await readWorktreeSession( + new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId), + ).catch(() => null); + if (sidecar) { + const wt = { + slug: sidecar.slug, + path: sidecar.worktreePath, + branch: sidecar.worktreeBranch, + }; + try { + await runtime.bridge.changeSessionCwd(sessionId, { + path: wt.path, + }); + 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); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 68e8856504a..ec5936f760c 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -381,21 +381,12 @@ export class SessionService { return true; } - const status = await readRuntimeStatus( - this.storage.getRuntimeStatusPath(sessionId), - ); - if ( - status?.sessionId === sessionId && - getProjectHash(status.workDir) === this.projectHash - ) { - return true; - } - // Worktree sessions record cwd as the worktree path // (/.qwen/worktrees/), which has a different project // hash. Infer the repo root from the path and check its hash. This // is durable — it doesn't depend on the sidecar file, which is - // transient and cleared when the worktree is removed. + // transient and cleared when the worktree is removed. Pure string + // ops, so check before the file-read runtime status below. const worktreesMarker = `${path.sep}.qwen${path.sep}worktrees${path.sep}`; const markerIdx = recordCwd.indexOf(worktreesMarker); if (markerIdx > 0) { @@ -404,7 +395,14 @@ export class SessionService { return true; } } - return false; + + const status = await readRuntimeStatus( + this.storage.getRuntimeStatusPath(sessionId), + ); + return ( + status?.sessionId === sessionId && + getProjectHash(status.workDir) === this.projectHash + ); } /** From dc1a551df170c6ba12e9bc2c3eaae6a8e38e8b61 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 18:51:30 +0800 Subject: [PATCH 06/15] test(daemon): worktree membership + load/resume restore tests Add regression tests for the two restart-persistence paths: SessionService (core): - includes a session whose transcript cwd is a worktree under this project - excludes a session whose worktree belongs to a different project Route restore (cli): - restores worktree isolation on load when sidecar exists (changeSessionCwd + setSessionWorktree called, response includes worktree) - skips restore when no sidecar exists - returns 200 without worktree when changeSessionCwd fails Also adds defense-in-depth containment guard in the route: validates the sidecar worktreePath is under /.qwen/worktrees/ using path.resolve + path.relative before relocating. --- packages/cli/src/serve/routes/session.ts | 53 ++++--- packages/cli/src/serve/server.test.ts | 129 ++++++++++++++++++ .../core/src/services/sessionService.test.ts | 45 ++++++ 3 files changed, 209 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index fd9557f0756..805363beed2 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1565,26 +1565,43 @@ export function registerSessionRoutes( new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId), ).catch(() => null); if (sidecar) { - const wt = { - slug: sidecar.slug, - path: sidecar.worktreePath, - branch: sidecar.worktreeBranch, - }; - try { - await runtime.bridge.changeSessionCwd(sessionId, { - path: wt.path, - }); - runtime.bridge.setSessionWorktree(sessionId, wt); - session.worktree = wt; - } catch (restoreErr) { - daemonLog?.warn('worktree restore failed on load/resume', { + // Defense-in-depth: validate the sidecar path is contained + // within the workspace's worktrees dir. The ACP layer already + // validated this, but a local check makes the route airtight. + const resolvedPath = path.resolve(sidecar.worktreePath); + const worktreesRoot = path.resolve( + workspaceCwd, + '.qwen', + 'worktrees', + ); + const rel = path.relative(worktreesRoot, resolvedPath); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + daemonLog?.warn('worktree sidecar path outside worktrees dir', { sessionId, - worktreePath: wt.path, - error: - restoreErr instanceof Error - ? restoreErr.message - : String(restoreErr), + path: sidecar.worktreePath, }); + } else { + const wt = { + slug: sidecar.slug, + path: sidecar.worktreePath, + branch: sidecar.worktreeBranch, + }; + try { + await runtime.bridge.changeSessionCwd(sessionId, { + path: wt.path, + }); + 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), + }); + } } } } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 819565ded47..a3c28bd533e 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -159,12 +159,19 @@ import { // `mockWt.impl` to control instance behaviour. const mockWt = vi.hoisted(() => ({ impl: undefined as (() => Record) | undefined, + readSidecar: undefined as (() => Promise) | undefined, })); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, + readWorktreeSession: (...args: unknown[]) => + mockWt.readSidecar + ? mockWt.readSidecar() + : (original.readWorktreeSession as (...a: unknown[]) => unknown)( + ...args, + ), GitWorktreeService: class MockGitWorktreeService { static validateUserWorktreeSlug = original.GitWorktreeService.validateUserWorktreeSlug; @@ -500,6 +507,15 @@ interface FakeBridgeOpts { promptId: string, ) => { removed: boolean }; spawnImpl?: (req: BridgeSpawnRequest) => Promise; + changeSessionCwdImpl?: ( + sessionId: string, + req: { path: string }, + ) => Promise<{ + sessionId: string; + previousCwd: string; + newCwd: string; + warnings: string[]; + }>; loadImpl?: ( req: BridgeRestoreSessionRequest, ) => Promise; @@ -754,6 +770,10 @@ interface FakeBridge extends AcpSessionBridge { }>; detachCalls: Array<{ sessionId: string; clientId?: string }>; changeSessionCwdCalls: Array<{ sessionId: string; path: string }>; + setSessionWorktreeCalls: Array<{ + sessionId: string; + worktree: { slug: string; path: string; branch: string }; + }>; enqueueMidTurnCalls: Array<{ sessionId: string; message: string; @@ -932,6 +952,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }> = []; const detachCalls: FakeBridge['detachCalls'] = []; const changeSessionCwdCalls: Array<{ sessionId: string; path: string }> = []; + const setSessionWorktreeCalls: Array<{ + sessionId: string; + worktree: { slug: string; path: string; branch: string }; + }> = []; const enqueueMidTurnCalls: FakeBridge['enqueueMidTurnCalls'] = []; const enqueueMidTurnImpl = opts.enqueueMidTurnImpl ?? (() => ({ accepted: true })); @@ -1497,6 +1521,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { killCalls, detachCalls, changeSessionCwdCalls, + setSessionWorktreeCalls, enqueueMidTurnCalls, permissionVotes, sessionPermissionVotes, @@ -1976,6 +2001,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }, async changeSessionCwd(sessionId, req) { changeSessionCwdCalls.push({ sessionId, path: req.path }); + if (opts.changeSessionCwdImpl) { + return opts.changeSessionCwdImpl(sessionId, req); + } return { sessionId, previousCwd: '/fake/previous', @@ -1983,6 +2011,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { warnings: [], }; }, + setSessionWorktree(sessionId, worktree) { + setSessionWorktreeCalls.push({ sessionId, worktree }); + }, isChannelLive() { return false; }, @@ -8842,6 +8873,104 @@ describe('createServeApp', () => { // CI. The same constraint applies here. The cleanup behavior // is exercised manually via the route handler closure shared // between both routes in `restoreSessionHandler`. + + it('restores worktree isolation on load when sidecar exists', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.readSidecar = () => + Promise.resolve({ + slug: 'my-task', + worktreePath: `${WS_BOUND}/.qwen/worktrees/my-task`, + worktreeBranch: 'worktree-my-task', + originalCwd: WS_BOUND, + originalBranch: 'main', + originalHeadCommit: 'abc123', + }); + + try { + const res = await request(app) + .post('/session/wt-session/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: WS_BOUND }); + + expect(res.status).toBe(200); + expect(res.body.worktree).toEqual({ + slug: 'my-task', + path: `${WS_BOUND}/.qwen/worktrees/my-task`, + branch: 'worktree-my-task', + }); + expect(bridge.changeSessionCwdCalls).toHaveLength(1); + expect(bridge.changeSessionCwdCalls[0].path).toBe( + `${WS_BOUND}/.qwen/worktrees/my-task`, + ); + expect(bridge.setSessionWorktreeCalls).toHaveLength(1); + } finally { + mockWt.readSidecar = undefined; + } + }); + + it('skips worktree restore when no sidecar exists', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.readSidecar = () => Promise.resolve(null); + + try { + const res = await request(app) + .post('/session/plain-session/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: WS_BOUND }); + + expect(res.status).toBe(200); + expect(res.body.worktree).toBeUndefined(); + expect(bridge.changeSessionCwdCalls).toHaveLength(0); + expect(bridge.setSessionWorktreeCalls).toHaveLength(0); + } finally { + mockWt.readSidecar = undefined; + } + }); + + it('returns 200 without worktree when changeSessionCwd fails', async () => { + const bridge = fakeBridge({ + changeSessionCwdImpl: async () => { + throw new Error('cd failed'); + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.readSidecar = () => + Promise.resolve({ + slug: 'dead-task', + worktreePath: `${WS_BOUND}/.qwen/worktrees/dead-task`, + worktreeBranch: 'worktree-dead-task', + originalCwd: WS_BOUND, + originalBranch: 'main', + originalHeadCommit: 'abc123', + }); + + try { + const res = await request(app) + .post('/session/dead-session/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: WS_BOUND }); + + expect(res.status).toBe(200); + expect(res.body.worktree).toBeUndefined(); + expect(bridge.setSessionWorktreeCalls).toHaveLength(0); + } finally { + mockWt.readSidecar = undefined; + } + }); }); describe('POST /session/:id/prompt', () => { diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 2ad5ba8eefc..89324b9679d 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -3896,6 +3896,51 @@ describe('SessionService', () => { }); }); + describe('listSessions worktree membership', () => { + const worktreeSessionId = '7ca8c920-e29b-41d4-a716-446655440001'; + + it('includes a session whose transcript cwd is a worktree under this project', async () => { + (path as Record).sep = '/'; + readdirSyncSpy.mockReturnValue([ + `${worktreeSessionId}.jsonl`, + ] as unknown as Array>); + vi.mocked(jsonl.readLines).mockResolvedValue([ + { + ...recordA1, + sessionId: worktreeSessionId, + cwd: '/test/project/root/.qwen/worktrees/my-task', + }, + ]); + vi.mocked(getProjectHash).mockReturnValue('test-project-hash'); + + const result = await sessionService.listSessions(); + + expect(result.items).toHaveLength(1); + expect(result.items[0].sessionId).toBe(worktreeSessionId); + }); + + it('excludes a session whose worktree belongs to a different project', async () => { + (path as Record).sep = '/'; + readdirSyncSpy.mockReturnValue([ + `${worktreeSessionId}.jsonl`, + ] as unknown as Array>); + vi.mocked(jsonl.readLines).mockResolvedValue([ + { + ...recordA1, + sessionId: worktreeSessionId, + cwd: '/other/repo/.qwen/worktrees/my-task', + }, + ]); + vi.mocked(getProjectHash).mockImplementation((p: string) => + p.startsWith('/other/repo') ? 'other-hash' : 'test-project-hash', + ); + + const result = await sessionService.listSessions(); + + expect(result.items).toHaveLength(0); + }); + }); + describe('listSessions parentSessionId round-trip', () => { // Uses real disk like findSessionTitlesByPrefix — readParentSessionIdFromFile // does a synchronous tail/head scan of the file, so the mocked From 505edb3620db43e22b04097aa550f2059deea53c Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 19:04:36 +0800 Subject: [PATCH 07/15] fix(test): use bracket notation for path.sep in strict tsconfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI tsconfig has noPropertyAccessFromIndexSignature — cast through unknown and use bracket notation for path.sep assignment. --- packages/core/src/services/sessionService.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 89324b9679d..bbe3296be7b 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -3900,7 +3900,7 @@ describe('SessionService', () => { const worktreeSessionId = '7ca8c920-e29b-41d4-a716-446655440001'; it('includes a session whose transcript cwd is a worktree under this project', async () => { - (path as Record).sep = '/'; + (path as unknown as Record)['sep'] = '/'; readdirSyncSpy.mockReturnValue([ `${worktreeSessionId}.jsonl`, ] as unknown as Array>); @@ -3920,7 +3920,7 @@ describe('SessionService', () => { }); it('excludes a session whose worktree belongs to a different project', async () => { - (path as Record).sep = '/'; + (path as unknown as Record)['sep'] = '/'; readdirSyncSpy.mockReturnValue([ `${worktreeSessionId}.jsonl`, ] as unknown as Array>); From c62b15e29f322fd1e63f5e518b5a0ea2139a8af3 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 19:27:12 +0800 Subject: [PATCH 08/15] fix(daemon): symlink-safe containment, nested worktree membership, test fixes 1. Containment guard: use path.resolve (defeats .. traversal) + marker check instead of realpathSync (symlink escapes handled by ACP layer's sessionCd fs.realpath). Also accepts any ancestor's worktrees dir for monorepo subdirectory workspaces. 2. Membership: use lastIndexOf instead of indexOf for the worktrees marker, correctly handling nested worktrees (innermost marker gives the parent workspace as repo root). 3. Test: positive membership test now exercises the marker branch by returning a different hash for the full worktree cwd. --- packages/cli/src/serve/routes/session.ts | 19 ++++++++----------- .../core/src/services/sessionService.test.ts | 7 ++++++- packages/core/src/services/sessionService.ts | 5 ++++- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 805363beed2..3ded270a880 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1565,18 +1565,15 @@ export function registerSessionRoutes( new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId), ).catch(() => null); if (sidecar) { - // Defense-in-depth: validate the sidecar path is contained - // within the workspace's worktrees dir. The ACP layer already - // validated this, but a local check makes the route airtight. + // Defense-in-depth: normalize the sidecar path with + // path.resolve (defeats `..` traversal) and verify it lands + // under a .qwen/worktrees/ segment. Symlink escapes are + // handled by the ACP layer's sessionCd handler which does + // fs.realpath on the final target. const resolvedPath = path.resolve(sidecar.worktreePath); - const worktreesRoot = path.resolve( - workspaceCwd, - '.qwen', - 'worktrees', - ); - const rel = path.relative(worktreesRoot, resolvedPath); - if (rel.startsWith('..') || path.isAbsolute(rel)) { - daemonLog?.warn('worktree sidecar path outside worktrees dir', { + const marker = `${path.sep}.qwen${path.sep}worktrees${path.sep}`; + if (!resolvedPath.includes(marker)) { + daemonLog?.warn('worktree sidecar path failed containment', { sessionId, path: sidecar.worktreePath, }); diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index bbe3296be7b..6768e081e2d 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -3911,7 +3911,12 @@ describe('SessionService', () => { cwd: '/test/project/root/.qwen/worktrees/my-task', }, ]); - vi.mocked(getProjectHash).mockReturnValue('test-project-hash'); + // The full worktree cwd hashes differently from the repo root, + // so the first getProjectHash(recordCwd) check fails and the + // marker-based inference branch is exercised. + vi.mocked(getProjectHash).mockImplementation((p: string) => + p === '/test/project/root' ? 'test-project-hash' : 'worktree-hash', + ); const result = await sessionService.listSessions(); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index ec5936f760c..dbeca44d18f 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -387,8 +387,11 @@ export class SessionService { // is durable — it doesn't depend on the sidecar file, which is // transient and cleared when the worktree is removed. Pure string // ops, so check before the file-read runtime status below. + // Use lastIndexOf to handle nested worktrees: for + // /repo/.qwen/worktrees/parent/.qwen/worktrees/child, the innermost + // marker gives repoRoot = /repo/.qwen/worktrees/parent (the workspace). const worktreesMarker = `${path.sep}.qwen${path.sep}worktrees${path.sep}`; - const markerIdx = recordCwd.indexOf(worktreesMarker); + const markerIdx = recordCwd.lastIndexOf(worktreesMarker); if (markerIdx > 0) { const repoRoot = recordCwd.substring(0, markerIdx); if (getProjectHash(repoRoot) === this.projectHash) { From 2bc41477cee05ce11dff49e7243de11e39e8d241 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 19:42:54 +0800 Subject: [PATCH 09/15] test(daemon): add containment guard rejection test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a test verifying that a sidecar with a path outside .qwen/worktrees/ (e.g. /etc/passwd) is rejected by the containment guard — no changeSessionCwd or setSessionWorktree calls, 200 without worktree. --- packages/cli/src/serve/server.test.ts | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index a3c28bd533e..bbec63bd803 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -8971,6 +8971,38 @@ describe('createServeApp', () => { mockWt.readSidecar = undefined; } }); + + it('skips restore when sidecar path fails containment check', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.readSidecar = () => + Promise.resolve({ + slug: 'escape', + worktreePath: '/etc/passwd', + worktreeBranch: 'worktree-escape', + originalCwd: WS_BOUND, + originalBranch: 'main', + originalHeadCommit: 'abc123', + }); + + try { + const res = await request(app) + .post('/session/escape-session/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: WS_BOUND }); + + expect(res.status).toBe(200); + expect(res.body.worktree).toBeUndefined(); + expect(bridge.changeSessionCwdCalls).toHaveLength(0); + expect(bridge.setSessionWorktreeCalls).toHaveLength(0); + } finally { + mockWt.readSidecar = undefined; + } + }); }); describe('POST /session/:id/prompt', () => { From af19e30ddf642f5e9cfc482457ad9564754417e7 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 20:16:35 +0800 Subject: [PATCH 10/15] fix(daemon): realpath-based containment for worktree restore Replace the lexical marker check with fs.realpathSync on both the target and the expected worktrees root, then verify containment via path.relative. This defeats symlink escapes (e.g. .qwen/worktrees/escape -> /etc) and foreign-repo paths. Uses sidecar.originalCwd as the expected root, falling back to workspaceCwd. --- packages/cli/src/serve/routes/session.ts | 28 +++++++++++++++++------- packages/cli/src/serve/server.test.ts | 14 ++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 3ded270a880..bf6ba4c5f2c 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -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, @@ -1565,14 +1566,25 @@ export function registerSessionRoutes( new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId), ).catch(() => null); if (sidecar) { - // Defense-in-depth: normalize the sidecar path with - // path.resolve (defeats `..` traversal) and verify it lands - // under a .qwen/worktrees/ segment. Symlink escapes are - // handled by the ACP layer's sessionCd handler which does - // fs.realpath on the final target. - const resolvedPath = path.resolve(sidecar.worktreePath); - const marker = `${path.sep}.qwen${path.sep}worktrees${path.sep}`; - if (!resolvedPath.includes(marker)) { + // 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). Uses sidecar.originalCwd + // (repo root at creation) as the expected root, falling back + // to workspaceCwd for monorepo subdirectory workspaces. + let contained = false; + try { + const realTarget = fs.realpathSync(sidecar.worktreePath); + const root = sidecar.originalCwd || workspaceCwd; + const realRoot = fs.realpathSync( + path.join(root, '.qwen', 'worktrees'), + ); + const rel = path.relative(realRoot, realTarget); + contained = !rel.startsWith('..') && !path.isAbsolute(rel); + } catch { + contained = false; + } + if (!contained) { daemonLog?.warn('worktree sidecar path failed containment', { sessionId, path: sidecar.worktreePath, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index bbec63bd803..96df210dc03 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -160,7 +160,16 @@ import { const mockWt = vi.hoisted(() => ({ impl: undefined as (() => Record) | undefined, readSidecar: undefined as (() => Promise) | undefined, + realpath: undefined as ((p: string) => string) | undefined, })); +vi.mock('node:fs', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + realpathSync: (p: fs.PathLike) => + mockWt.realpath ? mockWt.realpath(String(p)) : original.realpathSync(p), + }; +}); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const original = await importOriginal(); @@ -8881,6 +8890,8 @@ describe('createServeApp', () => { undefined, { bridge }, ); + // Identity realpath so the containment check passes in the test env. + mockWt.realpath = (p) => p; mockWt.readSidecar = () => Promise.resolve({ slug: 'my-task', @@ -8910,6 +8921,7 @@ describe('createServeApp', () => { expect(bridge.setSessionWorktreeCalls).toHaveLength(1); } finally { mockWt.readSidecar = undefined; + mockWt.realpath = undefined; } }); @@ -8948,6 +8960,7 @@ describe('createServeApp', () => { undefined, { bridge }, ); + mockWt.realpath = (p) => p; mockWt.readSidecar = () => Promise.resolve({ slug: 'dead-task', @@ -8969,6 +8982,7 @@ describe('createServeApp', () => { expect(bridge.setSessionWorktreeCalls).toHaveLength(0); } finally { mockWt.readSidecar = undefined; + mockWt.realpath = undefined; } }); From 2260f42ec2b6ea695664a1b85104e766ccd661f7 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 20:41:14 +0800 Subject: [PATCH 11/15] fix(daemon): containment uses workspaceCwd root, canonical path, native mock 1. Allowed root always derived from workspaceCwd (never from the sidecar's originalCwd, which is attacker-writable). 2. Pass the canonical realTarget to changeSessionCwd instead of the raw sidecar path, eliminating the TOCTOU window between validation and relocation. 3. Preserve realpathSync.native in the vi.mock wrapper so existing tests calling realpathSync.native() don't break. --- packages/cli/src/serve/routes/session.ts | 25 +++++++++++++----------- packages/cli/src/serve/server.test.ts | 8 ++++++-- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index bf6ba4c5f2c..3defd8e7348 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1569,22 +1569,25 @@ export function registerSessionRoutes( // 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). Uses sidecar.originalCwd - // (repo root at creation) as the expected root, falling back - // to workspaceCwd for monorepo subdirectory workspaces. - let contained = false; + // .qwen/worktrees/escape -> /etc). The allowed root is always + // derived from workspaceCwd (never from the sidecar, which is + // attacker-writable). The canonical realTarget is passed to + // changeSessionCwd to eliminate the TOCTOU window between + // validation and relocation. + let realTarget: string | undefined; try { - const realTarget = fs.realpathSync(sidecar.worktreePath); - const root = sidecar.originalCwd || workspaceCwd; + realTarget = fs.realpathSync(sidecar.worktreePath); const realRoot = fs.realpathSync( - path.join(root, '.qwen', 'worktrees'), + path.join(workspaceCwd, '.qwen', 'worktrees'), ); const rel = path.relative(realRoot, realTarget); - contained = !rel.startsWith('..') && !path.isAbsolute(rel); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + realTarget = undefined; + } } catch { - contained = false; + realTarget = undefined; } - if (!contained) { + if (!realTarget) { daemonLog?.warn('worktree sidecar path failed containment', { sessionId, path: sidecar.worktreePath, @@ -1592,7 +1595,7 @@ export function registerSessionRoutes( } else { const wt = { slug: sidecar.slug, - path: sidecar.worktreePath, + path: realTarget, branch: sidecar.worktreeBranch, }; try { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 96df210dc03..c953ad4f33d 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -164,10 +164,14 @@ const mockWt = vi.hoisted(() => ({ })); vi.mock('node:fs', async (importOriginal) => { const original = await importOriginal(); + const wrapped = ((p: fs.PathLike) => + mockWt.realpath + ? mockWt.realpath(String(p)) + : original.realpathSync(p)) as typeof original.realpathSync; + wrapped.native = original.realpathSync.native; return { ...original, - realpathSync: (p: fs.PathLike) => - mockWt.realpath ? mockWt.realpath(String(p)) : original.realpathSync(p), + realpathSync: wrapped, }; }); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { From 94c882e556035efab41c93fc3dc0a0ea7024d270 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 20:58:09 +0800 Subject: [PATCH 12/15] fix(daemon): monorepo worktree containment via repo top-level fallback The containment check now tries workspaceCwd first, then falls back to the git repo top-level (via GitWorktreeService.getRepoTopLevel). This fixes the monorepo regression where worktrees at /.qwen/ worktrees/ were rejected when the workspace is a subdirectory. Also wraps getRepoTopLevel in try-catch (not .catch) since the mock throws synchronously when the method doesn't exist. --- packages/cli/src/serve/routes/session.ts | 34 +++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 3defd8e7348..9c37012ac20 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1570,18 +1570,40 @@ export function registerSessionRoutes( // 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 workspaceCwd (never from the sidecar, which is + // 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; try { realTarget = fs.realpathSync(sidecar.worktreePath); - const realRoot = fs.realpathSync( - path.join(workspaceCwd, '.qwen', 'worktrees'), - ); - const rel = path.relative(realRoot, realTarget); - if (rel.startsWith('..') || path.isAbsolute(rel)) { + const candidateRoots = [workspaceCwd]; + 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(repoTop); + } + const contained = candidateRoots.some((root) => { + try { + const realRoot = fs.realpathSync( + path.join(root, '.qwen', 'worktrees'), + ); + const rel = path.relative(realRoot, realTarget!); + return !rel.startsWith('..') && !path.isAbsolute(rel); + } catch { + return false; + } + }); + if (!contained) { realTarget = undefined; } } catch { From 5a6b69d571fe2b6f736eb90a3fe580d30440edf6 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 21:53:05 +0800 Subject: [PATCH 13/15] fix(daemon): close TOCTOU with allowedRoots containment at sessionCd boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional allowedRoots to ChangeSessionCwdRequest. The agent-side sessionCd handler verifies (after its own fs.realpath) that the canonical target is under one of the allowed roots. Only set by the daemon's worktree create/restore paths; direct user cd omits the field, preserving existing behavior. This closes the TOCTOU between the route-level validation and the final relocation boundary — a symlink swap between validation and the queued sessionCd consumption is now caught at the relocation boundary itself. --- packages/acp-bridge/src/bridge.ts | 1 + packages/acp-bridge/src/bridgeTypes.ts | 8 ++++++++ packages/cli/src/acp-integration/acpAgent.ts | 20 ++++++++++++++++++++ packages/cli/src/serve/routes/session.ts | 12 +++++++++++- 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index e1f54514738..8010bd79370 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5564,6 +5564,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { { sessionId, path: req.path, + ...(req.allowedRoots ? { allowedRoots: req.allowedRoots } : {}), }, ); const extResult = raw as { diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 018cff39c83..e307f29d43b 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -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 { diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 75f1953346f..0bfae4f45f9 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -7589,6 +7589,26 @@ class QwenAgent implements Agent { } } + // Server-controlled containment check (worktree create/restore). + // When allowedRoots is present, verify the canonical target is + // under one of the roots. This closes the TOCTOU between the + // route-level validation and this final relocation boundary. + 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 }, + ); + } + } + // Relocate working directory (skip process.chdir and artifact // migration for ACP — storage stays at the bound workspace so // branch/load/lifecycle paths remain consistent). diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 9c37012ac20..be566118891 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1378,8 +1378,17 @@ export function registerSessionRoutes( // before any subsequent prompt is processed. if (worktreeMeta) { try { + // Compute allowed roots for the sessionCd containment check. + const createAllowedRoots = [workspaceCwd]; + const createRepoTop = await new GitWorktreeService(workspaceCwd) + .getRepoTopLevel() + .catch(() => null); + if (createRepoTop && createRepoTop !== workspaceCwd) { + createAllowedRoots.push(createRepoTop); + } await runtime.bridge.changeSessionCwd(session.sessionId, { path: worktreeMeta.path, + allowedRoots: createAllowedRoots, }); await writeWorktreeSessionMarker( worktreeMeta.path, @@ -1578,9 +1587,9 @@ export function registerSessionRoutes( // 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 = [workspaceCwd]; try { realTarget = fs.realpathSync(sidecar.worktreePath); - const candidateRoots = [workspaceCwd]; let repoTop: string | null = null; try { repoTop = await new GitWorktreeService( @@ -1623,6 +1632,7 @@ export function registerSessionRoutes( try { await runtime.bridge.changeSessionCwd(sessionId, { path: wt.path, + allowedRoots: candidateRoots, }); runtime.bridge.setSessionWorktree(sessionId, wt); session.worktree = wt; From 0d4aab9917fb14fd159c01301b1607fe312d221e Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 22:42:50 +0800 Subject: [PATCH 14/15] fix(daemon): wrap getRepoTopLevel in try-catch for worktree create path The mock GitWorktreeService throws synchronously when getRepoTopLevel doesn't exist. .catch() only handles rejected promises, not synchronous throws. Use try-catch to match the restore path. --- packages/cli/src/serve/routes/session.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index be566118891..623981610bf 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1380,9 +1380,14 @@ export function registerSessionRoutes( try { // Compute allowed roots for the sessionCd containment check. const createAllowedRoots = [workspaceCwd]; - const createRepoTop = await new GitWorktreeService(workspaceCwd) - .getRepoTopLevel() - .catch(() => null); + 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(createRepoTop); } From 5efdb9772cca0a201037a29394c0ff49392a468b Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 20 Jul 2026 23:50:54 +0800 Subject: [PATCH 15/15] fix(daemon): narrow allowedRoots to .qwen/worktrees, containment before no-op 1. allowedRoots now points to /.qwen/worktrees (not the whole repo), so a symlink .qwen/worktrees/task -> /src is rejected by the containment check. 2. Moved the allowedRoots containment check before the no-op check in sessionCd. A no-op cd to a directory outside the allowed roots is now rejected instead of silently succeeding. --- packages/cli/src/acp-integration/acpAgent.ts | 39 ++++++++++---------- packages/cli/src/serve/routes/session.ts | 20 ++++++---- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 0bfae4f45f9..b5ff845979a 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -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) { @@ -7589,26 +7608,6 @@ class QwenAgent implements Agent { } } - // Server-controlled containment check (worktree create/restore). - // When allowedRoots is present, verify the canonical target is - // under one of the roots. This closes the TOCTOU between the - // route-level validation and this final relocation boundary. - 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 }, - ); - } - } - // Relocate working directory (skip process.chdir and artifact // migration for ACP — storage stays at the bound workspace so // branch/load/lifecycle paths remain consistent). diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 623981610bf..e3b4960be4d 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1379,7 +1379,11 @@ export function registerSessionRoutes( if (worktreeMeta) { try { // Compute allowed roots for the sessionCd containment check. - const createAllowedRoots = [workspaceCwd]; + // Narrow to /.qwen/worktrees (not the whole repo) so a + // symlink .qwen/worktrees/task -> /src is rejected. + const createAllowedRoots = [ + path.join(workspaceCwd, '.qwen', 'worktrees'), + ]; let createRepoTop: string | null = null; try { createRepoTop = await new GitWorktreeService( @@ -1389,7 +1393,9 @@ export function registerSessionRoutes( // Not a git repo or getRepoTopLevel unavailable. } if (createRepoTop && createRepoTop !== workspaceCwd) { - createAllowedRoots.push(createRepoTop); + createAllowedRoots.push( + path.join(createRepoTop, '.qwen', 'worktrees'), + ); } await runtime.bridge.changeSessionCwd(session.sessionId, { path: worktreeMeta.path, @@ -1592,7 +1598,9 @@ export function registerSessionRoutes( // 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 = [workspaceCwd]; + const candidateRoots = [ + path.join(workspaceCwd, '.qwen', 'worktrees'), + ]; try { realTarget = fs.realpathSync(sidecar.worktreePath); let repoTop: string | null = null; @@ -1604,13 +1612,11 @@ export function registerSessionRoutes( // Not a git repo or getRepoTopLevel unavailable. } if (repoTop && repoTop !== workspaceCwd) { - candidateRoots.push(repoTop); + candidateRoots.push(path.join(repoTop, '.qwen', 'worktrees')); } const contained = candidateRoots.some((root) => { try { - const realRoot = fs.realpathSync( - path.join(root, '.qwen', 'worktrees'), - ); + const realRoot = fs.realpathSync(root); const rel = path.relative(realRoot, realTarget!); return !rel.startsWith('..') && !path.isAbsolute(rel); } catch {