fix(cli): validate channel service pidfile#5300
Conversation
|
@qwen-code /triage |
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Thanks for the PR, @tt-a1i! The fix itself looks sensible — validating PIDs before using them is a real safety concern. However, the PR body is missing several required sections from the PR template:
- Why it's needed — what problem or risk motivates this change? (e.g., did you encounter a malformed pidfile in the wild, or is this a preventive hardening?)
- Reviewer Test Plan — the template asks for structured sections: How to verify, Evidence (Before & After), and a Tested on table (macOS / Windows / Linux). Your "Test Plan" lists unit-test commands but doesn't follow this structure.
- Risk & Scope — any tradeoffs, out-of-scope items, or breaking changes?
- Linked Issues — is there an issue this closes?
- 中文说明 — the template includes a
<details>block for a Chinese translation.
Could you update the PR description to fill in these sections? The unit-test commands you listed are great — they just need to sit inside the template's structure so reviewers can quickly verify. Happy to re-review once updated.
中文说明
感谢 @tt-a1i 的贡献!修复本身是合理的——在使用 PID 前进行验证确实是一个重要的安全措施。不过 PR 描述缺少 PR 模板 中的几个必填部分:
- Why it's needed(为什么需要) — 这个改动解决了什么问题或风险?(比如是否在真实场景中遇到过损坏的 pidfile,还是预防性加固?)
- Reviewer Test Plan(审查者测试计划) — 模板要求有结构化的部分:How to verify、Evidence (Before & After)、以及 Tested on 表格(macOS / Windows / Linux)。当前的 "Test Plan" 列出了单元测试命令,但没有遵循这个结构。
- Risk & Scope(风险与范围) — 有什么权衡、不在范围内的项目或破坏性变更吗?
- Linked Issues(关联 Issue) — 是否有关联的 issue?
- 中文说明 — 模板包含一个
<details>区块用于中文翻译。
请补充这些部分,更新后我们会重新审查。
— Qwen Code · qwen3.7-max
✅ Maintainer verification — real
|
| Check | Command | Result |
|---|---|---|
| Unit tests | vitest run …/channel/pidfile.test.ts |
✅ 16/16 (incl. 4 new: pid-0 cleanup, malformed cleanup, no-signal, no-poll) |
| Unit tests | vitest run …/commands/channel |
✅ 35/35 (whole channel dir) |
| Lint | eslint pidfile.ts(.test) |
✅ clean |
| Format | prettier --check |
✅ clean |
| Whitespace | git diff --check |
✅ clean |
| Core build | npm run build -w packages/core |
✅ exit 0 |
| Typecheck | npm run typecheck -w packages/cli |
✅ exit 0, 0 errors |
2. Why this matters (the bug)
process.kill(pid, sig) has special semantics for non-positive pids: pid 0 targets the caller's entire process group, and pid -1 targets every process the user may signal. The pre-PR code trusted whatever pid sat in ~/.qwen/channels/service.pid, so a malformed/leftover pidfile with pid: 0 would (a) be reported as a running service, and (b) make qwen channel stop signal its own process group — a self-kill / DoS.
3. Real command A/B in tmux
Drove the real binary against planted pidfiles (sandboxed HOME). status cases use signal 0 (probe only — safe). The stop case is contained in a fresh setsid process group with a sentinel sleep, so a stray kill(0) is observed without touching anything outside it.
[A] qwen channel status:
| pidfile contents | OLD (pre-PR) | NEW (this PR) |
|---|---|---|
| valid live pid (control) | running (PID …) · kept |
running (PID …) · kept |
pid: 0 |
running (PID 0) · kept |
No channel service is running · cleaned up |
pid: -1 |
running (PID -1) · kept |
No channel service is running · cleaned up |
pid: "abc" |
No channel service is running · cleaned |
same |
missing pid |
No channel service is running · cleaned |
same |
| corrupt JSON | No channel service is running · cleaned |
same |
The already-handled cases (string/missing pid, corrupt JSON) behave identically — no regression — while pid 0 / pid -1 are the cases the fix closes. Valid running services are still detected and their pidfile preserved.
[B] qwen channel stop with pid: 0 (contained in a setsid group + sentinel):
OLD (pre-PR): Stopping channel service (PID 0)...
SENTINEL: KILLED -> kill(0) signaled the whole process group (pid-0 DANGER)
NEW (this PR): No channel service is running. (stop_exit=0)
SENTINEL: ALIVE -> no kill(0); process group untouched (SAFE)
The pre-PR binary printed Stopping … (PID 0)… and then process.kill(0, 'SIGTERM') took down the entire process group (the sentinel died); the PR binary treats the malformed file as "not running" and never signals. Exactly issue #5337's concern, demonstrated safely. (I deliberately did not run stop with pid: -1, since kill(-1, …) is not bounded by a process group — setsid couldn't contain it. The guard makes that path safe too.)
Observations (non-blocking)
- Scope is intentionally narrow: the guard rejects invalid pids (≤ 0, non-integer, non-safe-integer, NaN, wrong shape). A valid-but-recycled pid (e.g. a stale file whose pid was reused, or
pid: 1) is still trusted — but that's PID-reuse, a separate problem this PR doesn't claim to solve. Fine to leave out of scope. startedAtvalidation is lenient (!Number.isNaN(Date.parse(...))), butpid— the security-relevant field — is strictly validated, andwriteServiceInfoonly ever emits ISO strings, so real files pass. No concern.channels: []passesisServiceInfo([].every(...)istrue). Reasonable — a structurally valid record with zero channels.- Cross-platform: the dangerous
kill(0|-1)semantics are POSIX; theisValidPidguard is platform-agnostic and correct everywhere. Verified on Linux, where the risk is real.
🇨🇳 中文版(点击展开)
✅ 维护者验证 —— 在 tmux 中对 qwen channel 进行真实测试
我在本地通过驱动真实的 qwen channel status / stop 命令针对各种异常 pidfile 进行了验证(除单测之外)。结论:这是一个正确且有价值的安全/健壮性修复,建议合并。 本次还补上了 🐧 Linux 平台覆盖(恰恰是这种危险信号语义会真正触发的平台)。
环境
- 在 PR head(
1939cd4c,基于当前上游main)上独立git worktree,全新npm ci(绝不软链node_modules),真实 esbuild 打包。 - Linux · Node
v22.22.2· 打包后的qwen0.18.3。 - 改动范围干净:恰好 2 个文件,+109 / −19,单个提交。
1. 静态检查(复现 PR 测试计划)
| 检查项 | 命令 | 结果 |
|---|---|---|
| 单元测试 | vitest run …/channel/pidfile.test.ts |
✅ 16/16(含 4 个新增:pid-0 清理、异常清理、不发信号、不轮询) |
| 单元测试 | vitest run …/commands/channel |
✅ 35/35(整个 channel 目录) |
| Lint | eslint pidfile.ts(.test) |
✅ 干净 |
| 格式 | prettier --check |
✅ 干净 |
| 空白字符 | git diff --check |
✅ 干净 |
| core 构建 | npm run build -w packages/core |
✅ 退出码 0 |
| 类型检查 | npm run typecheck -w packages/cli |
✅ 退出码 0,0 错误 |
2. 为什么重要(这个 bug)
process.kill(pid, sig) 对非正数 pid 有特殊语义:pid 0 会作用于调用方的整个进程组,pid -1 会作用于该用户能发送信号的所有进程。PR 之前的代码无条件信任 ~/.qwen/channels/service.pid 里的 pid,因此一个异常/残留的、pid: 0 的 pidfile 会:(a) 被当作正在运行的服务,(b) 让 qwen channel stop 向自己的进程组发信号——即自杀式 / DoS。
3. tmux 中真实命令的 A/B
针对预置的 pidfile 驱动真实二进制(沙箱化 HOME)。status 用信号 0(仅探测——安全)。stop 用例封装在一个全新的 setsid 进程组里,并放置一个哨兵 sleep,这样即便发生 kill(0) 也能被观测到,且不会影响进程组之外的任何东西。
[A] qwen channel status:
| pidfile 内容 | 旧版(PR 前) | 新版(本 PR) |
|---|---|---|
| 有效存活 pid(对照) | running (PID …) · 保留 |
running (PID …) · 保留 |
pid: 0 |
running (PID 0) · 保留 |
No channel service is running · 已清理 |
pid: -1 |
running (PID -1) · 保留 |
No channel service is running · 已清理 |
pid: "abc" |
No channel service is running · 已清理 |
同上 |
缺少 pid |
No channel service is running · 已清理 |
同上 |
| 损坏 JSON | No channel service is running · 已清理 |
同上 |
原本已处理的情形(字符串/缺失 pid、损坏 JSON)两版表现一致——无回归——而 pid 0 / pid -1 正是本修复要堵上的口子。有效运行中的服务仍能被正确识别,pidfile 也保留。
[B] qwen channel stop,pid: 0(封装在 setsid 进程组 + 哨兵):
旧版(PR 前): Stopping channel service (PID 0)...
哨兵:被杀 -> kill(0) 向整个进程组发了信号(pid-0 危险)
新版(本 PR): No channel service is running. (stop_exit=0)
哨兵:存活 -> 没有 kill(0);进程组未受影响(安全)
PR 前的二进制先打印 Stopping … (PID 0)…,随后 process.kill(0, 'SIGTERM') 端掉了整个进程组(哨兵被杀);PR 后的二进制把异常文件当作「未运行」,从不发信号。这正是 issue #5337 的关切,并以安全的方式被复现。(我特意没有用 pid: -1 跑 stop,因为 kill(-1, …) 不受进程组约束,setsid 无法封装它;而该修复同样让这条路径变安全。)
观察项(非阻断)
- 范围有意收窄: 该守卫只拒绝无效 pid(≤ 0、非整数、非安全整数、NaN、结构不对)。一个有效但被复用的 pid(例如残留文件的 pid 已被复用,或
pid: 1)仍会被信任——但那属于 PID 复用,是本 PR 不声称解决的另一类问题,留在范围外是合理的。 startedAt校验较宽松(!Number.isNaN(Date.parse(...))),但与安全相关的pid字段是严格校验的,且writeServiceInfo只会写 ISO 字符串,真实文件都能通过。无需担心。channels: []能通过isServiceInfo([].every(...)为true)。合理——一个零频道但结构有效的记录。- 跨平台: 危险的
kill(0|-1)语义属于 POSIX;isValidPid守卫与平台无关、处处正确。已在 Linux(风险真实存在的平台)验证。
|
@qwen-code /triage |
|
Thanks for the PR, @tt-a1i! Template looks good — all required sections filled in ✓ On direction: this is a real safety issue. On approach: the scope is tight — 2 files, +109/−19, single-purpose. Three layers of defense (validate on read → guard Moving on to code review. 🔍 中文说明感谢贡献,@tt-a1i! 模板完整 ✓ 方向:这是一个真实的安全问题。 方案:范围紧凑——2 个文件,+109/−19,目的单一。三层防御(读取时校验 → 守卫 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
|
Code review: Clean implementation. My independent proposal before reading the diff was: add a
No blockers. Testing: Unit tests: 16/16 pass (12 original + 4 new). ESLint and Prettier clean. Real-scenario test on Linux (Node v22.22.3) — planted malformed pidfiles and drove the real Before (installed qwen, pre-PR)Planted Pidfile not cleaned up. Bug confirmed — After (this PR, via npm run dev)Same Pidfile cleaned up (deleted). Fix confirmed. Same test with Pidfile cleaned up. The 中文说明代码审查: 实现干净。我在看 diff 之前的独立方案是:添加
无阻断项。 测试: 单元测试:16/16 通过(12 个原有 + 4 个新增)。ESLint 和 Prettier 均干净。 Linux 真实场景测试(Node v22.22.3)——预置异常 pidfile 并驱动真实 — Qwen Code · qwen3.7-max |
|
Stepping back: this is a textbook defensive fix. The problem is real (POSIX My independent proposal from Stage 2 was essentially identical to what the PR does. The The tmux A/B test provides clear evidence: the installed build incorrectly reports Ships cleanly. Approving. 中文说明退一步看:这是一个教科书级的防御性修复。问题是真实的(POSIX 我在 Stage 2 的独立方案与 PR 的做法基本一致。 tmux A/B 测试提供了清晰的证据:已安装版本错误地报告 可以合并。批准中。 — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM — correct and minimal defensive fix for the pid:0/pid:-1 signal footgun. Tests prove the guard works, scope is tight. ✅
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
What this PR does
Hardens the channel service PID-file handling so a malformed or leftover
service.pidcan no longer be mistaken for a running service or trigger a dangerous signal.readServiceInfo()now parses the file asunknownand runs it through anisServiceInfotype guard: the record must be an object whosepidis a strictly-positive safe integer (isValidPid), whosestartedAtis a parseable date string, and whosechannelsis an array of strings. Anything that fails (pid: 0,pid: -1, fractional/NaN/string PIDs, wrong shape, corrupt JSON) is treated as invalid.unlinkPidFilebest-effort helper) and returnnull, so an invalid PID is never passed toprocess.kill.isProcessAlive,signalService, and (transitively)waitForExitnow reject non-positive / invalid PIDs up front:isProcessAlive(0)returnsfalse,signalService(0)returnsfalsewithout signalling, andwaitForExit(0, …)returnstrueimmediately without polling. This closes the case whereprocess.kill(0, sig)would target the caller's entire process group (andpid -1every signalable process).Net effect: a planted
pid: 0file that previously reportedrunning (PID 0)and letqwen channel stopsignal its own process group is now cleaned up and reported as "no service running". Existing behavior for valid live PIDs, stale (dead) PIDs, and already-handled corrupt files is unchanged.Why it's needed
process.kill(pid, sig)has special POSIX semantics for non-positive PIDs:pid 0signals the caller's whole process group andpid -1signals every process the user may signal. The pre-PR code trusted whatever PID sat in~/.qwen/channels/service.pid. A malformed or leftover pidfile containingpid: 0would therefore (a) be reported as a running service byqwen channel status, and (b) makeqwen channel stopcallprocess.kill(0, 'SIGTERM')— signalling its own process group, i.e. a self-kill / local DoS. This is preventive robustness/security hardening of that path; it is not tied to a specific user-filed issue.Reviewer Test Plan
How to verify
Author's verification commands (all from the original PR body — kept as-is):
npx vitest run packages/cli/src/commands/channel/pidfile.test.tsnpx vitest run packages/cli/src/commands/channelnpx eslint packages/cli/src/commands/channel/pidfile.ts packages/cli/src/commands/channel/pidfile.test.tsnpx prettier --check packages/cli/src/commands/channel/pidfile.ts packages/cli/src/commands/channel/pidfile.test.tsnpm run build --workspace=packages/corenpm run typecheck --workspace=packages/cligit diff --check upstream/main...HEADThe new unit tests assert the repro directly: a
pid: 0file is cleaned up andreadServiceInfo()returnsnullwithout callingprocess.kill; a battery of malformed records (pid: -1,1.5,"1234", badstartedAt, non-array / non-stringchannels) all clean up and returnnull;signalService(0)returnsfalseand never signals; andwaitForExit(0, …)resolvestruewithout polling.End-to-end (optional, POSIX): plant a
~/.qwen/channels/service.pidwith{"pid":0,…}, then runqwen channel status(expected: "No channel service is running", file removed) andqwen channel stopinside a contained process group (expected: no signal sent, group untouched).Evidence (Before & After)
N/A — non-visible logic/safety fix in the
channelPID-file helpers; covered by the unit tests above (the new tests assert thepid: 0and malformed-record paths clean up and never callprocess.kill). A separate end-to-end A/B verification of the realqwen channel status/stopcommands is recorded in a maintainer comment on this PR thread.Tested on
✅ tested ·⚠️ not tested · N/A
Test (macos-latest / windows-latest / ubuntu-latest, Node 22.x)all pass on this PR. Linux additionally has the maintainer's real-binaryqwen channelA/B verification above (the platform where the bad signal semantics actually bite).Environment (optional)
Unit tests run via npm workspaces (
packages/cli). The dangerouskill(0|-1)semantics are POSIX; theisValidPidguard is platform-agnostic.Risk & Scope
packages/cli/src/commands/channel/pidfile.ts. The behavior change is limited to rejecting/cleaning up invalid pidfiles (non-positive, non-safe-integer, NaN, or wrong-shape records); valid live/stale-PID and already-handled corrupt-file paths are unchanged.pid: 1) is still trusted; that is a separate problem this PR does not claim to solve. No unrelated refactors, public API changes, or UI changes.startedAtvalidation is intentionally lenient (any parseable date), sincewriteServiceInfoonly ever emits ISO strings.Linked Issues
No linked issue — this is a preventive robustness/security fix for the channel PID-file path; there is no corresponding open GitHub issue this PR closes.
中文说明
这个 PR 做了什么
加固 channel 服务的 PID 文件处理,使得一个异常或残留的
service.pid不再可能被误判为「正在运行的服务」,也不再可能触发危险的信号调用。readServiceInfo()现在先把文件解析为unknown,再通过isServiceInfo类型守卫:记录必须是对象,其pid为严格正的安全整数(isValidPid),startedAt为可解析的日期字符串,channels为字符串数组。任何不满足的情况(pid: 0、pid: -1、小数/NaN/字符串 PID、结构错误、损坏 JSON)都被视为无效。unlinkPidFile)并返回null,因此无效 PID 永远不会被传给process.kill。isProcessAlive、signalService、以及(间接地)waitForExit现在在入口处就拒绝非正数 / 无效 PID:isProcessAlive(0)返回false,signalService(0)不发信号直接返回false,waitForExit(0, …)不轮询直接返回true。这堵上了process.kill(0, sig)会作用于调用方整个进程组(而pid -1作用于所有可发信号的进程)的口子。最终效果:一个预置的
pid: 0文件,之前会被报告为running (PID 0)并让qwen channel stop向自己的进程组发信号,现在会被清理并报告为「无服务运行」。对有效存活 PID、陈旧(已死)PID、以及原本已处理的损坏文件,行为保持不变。为什么需要
process.kill(pid, sig)对非正数 PID 有特殊的 POSIX 语义:pid 0会向调用方的整个进程组发信号,pid -1会向该用户能发信号的所有进程发信号。PR 之前的代码无条件信任~/.qwen/channels/service.pid里的 PID。因此一个含pid: 0的异常或残留 pidfile 会:(a) 被qwen channel status报告为正在运行的服务;(b) 让qwen channel stop调用process.kill(0, 'SIGTERM')——向自己的进程组发信号,即自杀式 / 本地 DoS。这是对该路径的预防性健壮性/安全加固,并非针对某个用户提交的 issue。审查者测试计划
如何验证
作者的验证命令(全部来自原始 PR 描述,原样保留):
npx vitest run packages/cli/src/commands/channel/pidfile.test.tsnpx vitest run packages/cli/src/commands/channelnpx eslint packages/cli/src/commands/channel/pidfile.ts packages/cli/src/commands/channel/pidfile.test.tsnpx prettier --check packages/cli/src/commands/channel/pidfile.ts packages/cli/src/commands/channel/pidfile.test.tsnpm run build --workspace=packages/corenpm run typecheck --workspace=packages/cligit diff --check upstream/main...HEAD新增的单元测试直接验证了复现:
pid: 0的文件被清理、readServiceInfo()返回null且未调用process.kill;一批异常记录(pid: -1、1.5、"1234"、错误的startedAt、非数组 / 非字符串channels)全部被清理并返回null;signalService(0)返回false且从不发信号;waitForExit(0, …)不轮询直接 resolve 为true。端到端(可选,POSIX):预置一个
{"pid":0,…}的~/.qwen/channels/service.pid,然后运行qwen channel status(预期:「No channel service is running」,文件被删除)以及在受控进程组里运行qwen channel stop(预期:不发信号,进程组不受影响)。证据(前 & 后)
N/A ——
channelPID 文件辅助函数中的非可见逻辑/安全修复,由上述单元测试覆盖(新增测试断言pid: 0与异常记录路径会被清理且从不调用process.kill)。针对真实qwen channel status/stop命令的端到端 A/B 验证记录在本 PR 讨论串的一条维护者评论中。测试平台
✅ 已测试 ·⚠️ 未测试 · N/A
本 PR 的
Test (macos-latest / windows-latest / ubuntu-latest, Node 22.x)全部通过。Linux 另有上述维护者的真实二进制qwen channelA/B 验证(这恰恰是危险信号语义会真正触发的平台)。环境(可选)
单元测试通过 npm workspaces(
packages/cli)运行。危险的kill(0|-1)语义属于 POSIX;isValidPid守卫与平台无关。风险与范围
packages/cli/src/commands/channel/pidfile.ts。行为变化仅限于拒绝/清理无效的 pidfile(非正数、非安全整数、NaN 或结构错误的记录);有效存活/陈旧 PID 以及原本已处理的损坏文件路径不变。pid: 1)仍会被信任;那是本 PR 不声称解决的另一类问题。无无关重构、公共 API 变更或 UI 变更。startedAt校验有意保持宽松(任何可解析的日期),因为writeServiceInfo只会写 ISO 字符串。关联 Issue
无关联 issue——这是对 channel PID 文件路径的预防性健壮性/安全修复,没有对应的开放 GitHub issue 被本 PR 关闭。
AI Assistance Disclosure
I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.