feat(desktop): Add desktop app package with Qwen ACP SDK integration - #3778
Conversation
There was a problem hiding this comment.
CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.
There was a problem hiding this comment.
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.dodomains unless connecting to official Craft services
But this PR keeps:
name: "craft-agent"inpackages/desktop/package.jsonappId: com.lukilabs.craft-agent,productName: Craft Agents, andcopyright: Copyright © 2026 Craft Docs Ltd.inpackages/desktop/apps/electron/electron-builder.yml- All
resources/craft-logos/*.png,resources/icon.icns,resources/icon.ico,resources/Assets.car - Reference to
legal@craft.doinTRADEMARK.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.tsis brand new (all+additions). There's no fix to existing code; theDispatcher.DispatchHandlersusage 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:cistill pass.
3. Architectural concern: this is co-location, not integration
The desktop package:
- Is excluded from the npm workspace (
!packages/desktopinpackage.json) — necessary becausepackages/*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,vitestsetup — none of which integrate with qwen-code's rootlint:all,typecheck,test:ci. - Imports nothing from qwen-code packages —
network-proxy.tsreferences@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.mdsaysbun run electron:start, but the validation block in the PR body useselectron:dev. Pick one.packages/desktop/package.jsonpins"@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:allrunsnode scripts/lint.js. Worth confirming this script doesn't recurse intopackages/desktop(bun-only subtree, eslint flat config,.cjsrule 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 testfrom insidepackages/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/desktopexcluded from workspace, root eslint won't reach in — but iflint.jsdoes 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
- Resolve trademark: rename
craft-agentto something non-"Craft", updateappId, replace logos/icons, dropcraft.doreferences. Keep NOTICE for Apache attribution. - 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.
- Don't commit
bridge-mcp-server/index.js; generate it at build time. - Remove the dead
packages/desktop/.github/workflows/*.yml, or move them to root with a path filter (paths: ['packages/desktop/**']). - 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.
- Confirm the
package-lock.jsonpeer: trueremoval is intentional. - 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.json中name: "craft-agent"packages/desktop/apps/electron/electron-builder.yml中appId: com.lukilabs.craft-agent、productName: Craft Agents、copyright: Copyright © 2026 Craft Docs Ltd.- 所有
resources/craft-logos/*.png、resources/icon.icns、resources/icon.ico、resources/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。
- 自带
husky、eslint、tsconfig、vitest配置——没有任何一项接入 qwen-code 根目录的lint:all、typecheck、test:ci。 - 没有从 qwen-code 包中导入任何东西——
network-proxy.ts中所有导入都来自@craft-agent/shared/*,即新目录树内部。 - workflow 文件被放在
packages/desktop/.github/workflows/validate-server.yml和validate.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.js 是 18,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.json中lint:all是node scripts/lint.js。需要确认这个脚本不会递归进入packages/desktop(那是 bun-only 的子树,有 eslint flat config、.cjs规则文件等)。 - desktop 子树自带
LICENSE、CODE_OF_CONDUCT.md、SECURITY.md、CONTRIBUTING.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 的条目。
合并前我希望看到的条件
- 解决商标问题:把
craft-agent重命名为不含 "Craft" 的名字,更新appId,替换 logo/图标,移除craft.do引用。NOTICE 保留以满足 Apache 署名。 - 决定共仓 vs. 独立仓库 / submodule。如果坚持共仓,在 qwen-code 顶层 README 加一段说明:此目录使用 bun 管理,已排除在 npm workspace 之外。
- 不要把
bridge-mcp-server/index.js入库,改为构建时生成。 - 删除失效的
packages/desktop/.github/workflows/*.yml,或者把它们挪到根目录并加 path 过滤(paths: ['packages/desktop/**'])。 - 修正 PR 描述:写清楚上游真实名称(Craft Agents),撤掉"修复 undici 类型"这一项(它根本不是修复),补充对 qwen-code 现有 build/test 不受影响的验证步骤。
- 确认
package-lock.json中peer: true的移除是有意为之。 - 要么把 desktop 测试接入根
test:ci,要么在文档里明确写它不在 qwen-code CI 范围内。
总结
集成方向本身没问题——qwen-code 完全可以有桌面客户端。但当前这个 PR 是一次"保留品牌的 1497 文件代码倾倒",违反了它自己一同提交的商标政策,带着打包好的 JS 产物,有死文件 workflow,描述还有误导。核心阻断是 第 1 点(商标);其余都是可以清理的细节问题。
🤖 Generated with Claude Code — Claude Opus 4.7 (1M context)
| // ============================================================ | ||
|
|
||
| // Get files in session directory (recursive tree structure) | ||
| server.handle(RPC_CHANNELS.sessions.GET_FILES, async (_ctx, sessionId: string) => { |
There was a problem hiding this comment.
[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, |
There was a problem hiding this comment.
[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[]) => { |
There was a problem hiding this comment.
[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).
| 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 |
There was a problem hiding this comment.
[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.
| 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)) |
There was a problem hiding this comment.
[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.
| 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); | ||
|
|
There was a problem hiding this comment.
[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.
| 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)) { |
There was a problem hiding this comment.
[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.
| 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
left a comment
There was a problem hiding this comment.
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:
-
eslint.config.jsignoresarray missing'packages/desktop/**'—npx eslint . --ext .ts,.tsx --max-warnings 0from root exits 1 with ~2400 errors. Breaksnpm run lint:ci(ci.ymlRun ESLint, release.ymlRun Lint,npm run preflight). Fix: add'packages/desktop/**'to theignoresarray. -
.prettierignoremissingpackages/desktop/—npx prettier --check packages/desktopreports 1204 files would be reformatted by root prettier config.npm run format(release.ymlFormat Project) silently rewrites them in-place;lint-stagedrewrites them on every contributor commit;scripts/lint.js --prettier(ci.ymlRun Prettier) rewrites on every CI run. Fix: addpackages/desktop/to.prettierignore. -
.yamllint.ymlignoreblock missing'packages/desktop/'— yamllint produces ~232 errors across desktop's 5 yaml files (mostly[quoted-strings]violations in issue templates andelectron-builder.yml). Breaks ci.ymlRun yamllint. Fix: append'packages/desktop/'to theignorelist. -
scripts/lint.jsshellcheck pipeline picks up ~17 desktop scripts viagit ls-files | grep -v '^integration-tests/terminal-bench/' | xargs shellcheck(with--severity=style --enable=all). Likely fails ci.ymlRun shellcheck. Fix: add| grep -v '^packages/desktop/'to the chain. -
Husky pre-commit blocks contributors editing desktop ts/tsx — lint-staged runs
eslint --fix --max-warnings 0 --no-warn-ignored;--no-warn-ignoredis 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. -
Nightly release workflow (
.github/workflows/release.ymlFormat Project+Run Lintsteps) — same root cause as #1+#2; halts on next cron firing.
Nice to have — CI scope bloat
-
Dockerfile+.dockerignore—COPY .copies the full ~325k-line desktop tree (includingbun.lock, electron resources, bundledbridge-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: addpackages/desktopto.dockerignore. -
CodeQL scope (
.github/workflows/ci.ymlcodeqljob has nopaths-ignore) — now scans 1500+ desktop JS/TS files plus the 18,276-line bundledbridge-mcp-server/index.js. Already mirrored by the @github-advanced-security[bot] comment ("CodeQL found more than 20 potential problems"). Fix: add acodeql-config.ymlwithpaths-ignore: ['packages/desktop/**'](or at minimum the bundled bridge-mcp-server) referenced from theinitstep.
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' |
There was a problem hiding this comment.
[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) => { |
There was a problem hiding this comment.
[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.
| 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', |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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:
| 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) => { |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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:
buildElectronProxyConfigbuildsproxyBypassRulesonly fromsplitCommaSeparated(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 onlyparseNoProxyRules/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; |
There was a problem hiding this comment.
[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 { |
There was a problem hiding this comment.
[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:
- HTTP-to-HTTPS proxy mis-routing — line 64-66 chooses by
url?.startsWith('https:'). A regression that defaults tohttpsProxywhen URL is undefined would send HTTP requests through HTTPS-only proxy (auth header leak / 502s). - Resource leak across reconfigure —
currentProxyDispatcher.close().catch(() => {})(line 98) is fire-and-forget; ifclose()no longer cascades to all three underlying agents, sockets accumulate every time the user toggles proxy settings. - 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: { |
There was a problem hiding this comment.
[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.clientIdin a Map; reject when a client has more than e.g. 4 in-flight. - Bound chunk size in
transfer:CHUNKto ~1.5×CHUNK_SIZEafter base64 expansion.
— claude-opus-4-7 via Claude Code /qreview
| host: rpcHost, | ||
| port: rpcPort, | ||
| requireAuth: true, | ||
| validateToken: async (t) => t === serverToken, |
There was a problem hiding this comment.
[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:
| 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
* 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
left a comment
There was a problem hiding this comment.
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
desktopas 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.tsadds ~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/、.dockerignore 和 LICENSE。工作区排除(!packages/desktop)意味着它无法与 monorepo 其余部分共享 npm install。这提出了一个真正的问题:将其放在 monorepo 中与维护为独立仓库相比,优势是什么? PR 描述提到"共享代码和统一开发",但工作区排除削弱了这一论点——唯一的共享边界似乎只是 packages/cli 和 packages/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
left a comment
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
[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>` : ''} |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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') |
There was a problem hiding this comment.
[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, |
There was a problem hiding this comment.
[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)) |
There was a problem hiding this comment.
[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:
| 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 } }) | ||
| } |
There was a problem hiding this comment.
[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:
| } | |
| } 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) |
There was a problem hiding this comment.
[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( |
There was a problem hiding this comment.
[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.
| 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() |
There was a problem hiding this comment.
[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': |
There was a problem hiding this comment.
[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' |
There was a problem hiding this comment.
[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" |
There was a problem hiding this comment.
[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
| - name: 'Check lockfile' | ||
| run: 'npm run check:lockfile' | ||
|
|
||
| - name: 'Check desktop workspace isolation' |
There was a problem hiding this comment.
[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 }}' |
There was a problem hiding this comment.
[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
|
|
||
| export function getCurrentMacCodeSignatureStatus(executablePath: string): MacCodeSignatureStatus { | ||
| const appBundlePath = getMacAppBundlePath(executablePath) | ||
| const result = spawnSync('/usr/bin/codesign', ['-d', '-vvv', appBundlePath], { |
There was a problem hiding this comment.
[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.
| 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 |
There was a problem hiding this comment.
[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 { |
There was a problem hiding this comment.
[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', |
There was a problem hiding this comment.
[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) { |
There was a problem hiding this comment.
[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="$( |
There was a problem hiding this comment.
[Suggestion] Two divergences from the root .github/workflows/desktop-release.yml:
-
Missing
desktop-v*tag filter: The root workflow (line 559) filtersprevious_tagby.tagName | startswith("desktop-v"), but this workflow'sgh release listquery does not. This may select a CLI release tag (e.g.,v1.2.3) asprevious_tag, producing release notes that include unrelated CLI changes. -
Missing
--latest=false: The root workflow (line 584) passes--latest=falsetogh release createto 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
left a comment
There was a problem hiding this comment.
LGTM!Bring small but beautiful changes to the world.
Summary
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.package.json, theDispatchHandler → DispatchHandlerstype fix innetwork-proxy.ts, and the overall structure ofpackages/desktop/.Validation
Scope / Risk
packages/desktopis excluded from root npm workspace (!packages/desktopin package.json workspaces). This meansnpm installfrom root will not hoist desktop dependencies.Screenshots / Video Demo
Testing Matrix
Testing matrix notes:
Linked Issues / Bugs
No linked issues.
🤖 Generated with Qwen Code