fix(cli): preserve trustedFolders comments on save#4746
Conversation
| let content: string; | ||
| if (fs.existsSync(trustedFoldersFile.path)) { | ||
| const originalContent = fs.readFileSync(trustedFoldersFile.path, 'utf-8'); | ||
| const parsed = parse(originalContent) as Record<string, unknown>; |
There was a problem hiding this comment.
[Critical] Self-healing regression: the old save path wrote JSON.stringify(config) unconditionally, which always produced valid JSON — silently repairing a corrupted trustedFolders.json on the next save. The new path calls parse(originalContent) first; if that throws (severe file corruption), the outer catch aborts the entire save and the corrupted file persists. Every subsequent trust decision then fails to persist with only a generic stderr message.
Suggested fix — fall back to a clean write when the comment-preserving round-trip fails:
let content: string;
if (fs.existsSync(trustedFoldersFile.path)) {
try {
const originalContent = fs.readFileSync(trustedFoldersFile.path, 'utf-8');
const parsed = parse(originalContent) as Record<string, unknown>;
const updated = applyUpdates(
parsed,
trustedFoldersFile.config as Record<string, unknown>,
true,
);
content = stringify(updated, null, 2);
parse(content); // validate
} catch {
// Comment-preserving round-trip failed — fall back to clean write
// so the file self-heals instead of staying broken.
content = stringify(trustedFoldersFile.config, null, 2);
}
} else {
content = stringify(trustedFoldersFile.config, null, 2);
}— qwen3.7-max via Qwen Code /review
| const updated = applyUpdates( | ||
| parsed, | ||
| trustedFoldersFile.config as Record<string, unknown>, | ||
| true, |
There was a problem hiding this comment.
[Suggestion] sync=true causes applyUpdates to actively delete every key in the on-disk file that is absent from the in-memory config. When multiple Qwen Code processes run concurrently (common — multiple terminal windows), this silently destroys trust entries that another process added between this process's load-time and save-time. The old code (JSON.stringify(config)) had the same blind-overwrite behavior, but the new code's file re-read creates an opportunity to use sync=false for merge semantics, preserving concurrent additions.
Consider changing to sync=false (or omitting the default) and adjusting the second test accordingly.
— qwen3.7-max via Qwen Code /review
| ); | ||
| }); | ||
|
|
||
| it('setValue should preserve existing comments when rewriting the trust file', () => { |
There was a problem hiding this comment.
[Suggestion] Both new error branches introduced by the comment-preserving save are untested:
parse(originalContent)throwing when the file exists but is corrupted- Validation
parse(content)failing afterstringify
Without tests, a future refactor could silently remove the validation guard or the fallback behavior without CI catching it. Consider adding tests that mock comment-json's parse to throw on the relevant call and assert that atomicWriteFileSync is not called (error path) or that the fallback clean write is used instead.
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| let content: string; | ||
| if (fs.existsSync(trustedFoldersFile.path)) { |
There was a problem hiding this comment.
[Suggestion] The read → parse → applyUpdates → validate → write pipeline here (lines 185-201) is a near-exact duplicate of packages/cli/src/utils/commentJson.ts:updateSettingsFilePreservingFormat (lines 39-82). The only meaningful difference is the write mechanism (atomicWriteFileSync with noFollow vs writeWithBackupSync). A future maintainer who spots this duplication will see an obvious "refactor to reuse" — and by consolidating into updateSettingsFilePreservingFormat (which lacks noFollow), they silently drop the symlink-attack mitigation that is intentional here.
Consider either extracting a shared pure helper that returns prepared content (without writing), or adding a comment at this site documenting why the intentional divergence exists.
— qwen3.7-max via Qwen Code /review
|
Thanks - I pushed an update.
Please take another look when you have a chance. |
wenshao
left a comment
There was a problem hiding this comment.
All R1 findings have been addressed in this revision:
- Self-healing regression → try/catch fallback to clean rewrite ✅
sync=trueconcern → author chosesync=falseto preserve disk-only entries, with dedicated test ✅- Missing failure-path tests → 4 new tests covering parse failure, validation failure, comment preservation, and disk-only entry preservation ✅
- Duplication with commentJson.ts → explanatory comment documenting the intentional divergence ✅
Deterministic: tsc ✅, eslint ✅, 25/25 tests ✅, build ✅. 9 review agents + reverse audit: 0 high-confidence Critical or Suggestion findings.
— qwen3.7-max via Qwen Code /review
|
All requested changes should be addressed now, and the required checks are green. Please take another look when convenient. |
|
|
||
| it('saveTrustedFolders should fall back to a clean rewrite when preserved output validation fails', () => { | ||
| const userPath = getTrustedFoldersPath(); | ||
| const dirPath = path.dirname(userPath); |
There was a problem hiding this comment.
[Critical] This test does not actually exercise the validation-failure code path. The first mockImplementationOnce replaces parse with JSON.parse, but originalContent contains // work repos — a JSONC comment that JSON.parse cannot handle. So parse(originalContent) throws at the initial parse step (line ~198 of trustedFolders.ts), the catch block fires immediately, and execution never reaches the validation parse(preservedContent) at line ~208. The second mockImplementationOnce is never consumed.
This makes the test a duplicate of test 3 (parse-failure fallback). The validation-failure branch — where the round-trip succeeds but stringify produces unparseable output — remains untested.
| const dirPath = path.dirname(userPath); | |
| it('saveTrustedFolders should fall back to a clean rewrite when preserved output validation fails', () => { | |
| const userPath = getTrustedFoldersPath(); | |
| const dirPath = path.dirname(userPath); | |
| const originalContent = `{ | |
| // work repos | |
| "/existing/path": "TRUST_FOLDER" | |
| }`; | |
| const parseSpy = vi.mocked(commentJson.parse); | |
| (mockFsExistsSync as Mock).mockImplementation( | |
| (p) => p === userPath || p === dirPath, | |
| ); | |
| (fs.readFileSync as Mock).mockImplementation((p) => { | |
| if (p === userPath) return originalContent; | |
| return '{}'; | |
| }); | |
| // Let the first parse() call use the real comment-json parser (handles JSONC). | |
| // Only mock the second call (validation step) to throw. | |
| parseSpy.mockImplementationOnce(() => { | |
| throw new Error('invalid preserved output'); | |
| }); | |
| saveTrustedFolders({ | |
| path: userPath, | |
| config: { | |
| '/new/path': TrustLevel.TRUST_FOLDER, | |
| }, | |
| }); | |
| expect(atomicWriteFileSync).toHaveBeenCalledTimes(1); | |
| expect(vi.mocked(atomicWriteFileSync).mock.calls[0]?.[1]).toBe( | |
| `{\n "/new/path": "TRUST_FOLDER"\n}`, | |
| ); | |
| }); |
— qwen3.7-max via Qwen Code /review
|
Thanks - good catch. I updated that test so the first parse call now uses the real Re-ran:
|
| }); | ||
| parseSpy.mockImplementationOnce(() => { | ||
| throw new Error('invalid preserved output'); | ||
| }); |
There was a problem hiding this comment.
[Suggestion] This mockImplementationOnce intercepts the first parse() call — parse(originalContent) at trustedFolders.ts:196 — not the validation parse(preservedContent) at line 207. Execution throws here and jumps to the catch block before applyUpdates, stringify, or the validation parse are ever reached. This makes test 4 functionally identical to test 3 ("fails during parse"); the validation-failure branch at line 207 remains untested.
To fix: let the first parse call succeed with the real implementation, and throw only on the second call:
| }); | |
| parseSpy | |
| .mockImplementationOnce((...args: Parameters<typeof commentJson.parse>) => | |
| commentJson.parse(...args), | |
| ) | |
| .mockImplementationOnce(() => { | |
| throw new Error('invalid preserved output'); | |
| }); |
— qwen3.7-max via Qwen Code /review
|
Thanks - you were right. The previous version still intercepted the first I updated the test so that:
That means this test now actually exercises the validation-failure branch instead of duplicating the corrupted-input fallback case. Re-ran:
|
| ); | ||
| const parsed = parse(originalContent) as Record<string, unknown>; | ||
| const updated = applyUpdates( | ||
| parsed, |
There was a problem hiding this comment.
[Critical] Top-level array in trustedFolders.json causes silent trust-data loss, bypassing the self-healing fallback.
parse(originalContent) can return a CommentArray when the file contains [] or [1,2,3]. Unlike the load path (loadTrustedFolders, line 147) which has an explicit Array.isArray(parsed) guard, the save path has no type check. The downstream consequences are:
applyUpdates(array, config, false)sets string keys (e.g."/home/user/project-a") as non-numeric own properties on the array.comment-json.stringify(array)only serializes numeric indices, producing[]— all config entries are silently dropped.- The validation
parse("[]")succeeds (it is valid JSON), so the catch block is never triggered. content = "[]"overwrites the correct clean fallback.atomicWriteFileSyncwrites[]to disk — all trust decisions are destroyed.
Add the same structural guard the load path uses:
| parsed, | |
| const parsed = parse(originalContent); | |
| if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { | |
| throw new Error('trusted folders file is not a JSON object'); | |
| } | |
| const updated = applyUpdates( | |
| parsed as Record<string, unknown>, |
— qwen3.7-max via Qwen Code /review
| parse(preservedContent); | ||
| content = preservedContent; | ||
| } catch { | ||
| // Fall back to a clean rewrite when comment-preserving round-trip fails. |
There was a problem hiding this comment.
[Suggestion] The catch block is empty, so when comment preservation fails the user's comments are silently stripped with no diagnostic output. The reference implementation updateSettingsFilePreservingFormat() in commentJson.ts (lines 48-55, 71-79) logs via writeStderrLine in every failure branch — writeStderrLine is already imported in this file.
Adding a one-line log makes silent degradation diagnosable (e.g. after a comment-json version bump that changes parser behavior):
| // Fall back to a clean rewrite when comment-preserving round-trip fails. | |
| } catch (error) { | |
| // Fall back to a clean rewrite when comment-preserving round-trip fails. | |
| writeStderrLine( | |
| `Falling back to clean rewrite for trusted folders: ${error instanceof Error ? error.message : String(error)}`, | |
| ); | |
| } |
— qwen3.7-max via Qwen Code /review
|
Thanks - I pushed another update. Changes in this revision:
I also re-ran:
|
| typeof parsed !== 'object' || | ||
| parsed === null || | ||
| Array.isArray(parsed) | ||
| ) { |
There was a problem hiding this comment.
[Critical] comment-json.parse() wraps JSON primitives ("hello", 42, true) in CommentObject instances that are typeof === 'object', non-null, non-Array — passing all three type-guard checks. When applyUpdates sets string keys on a wrapper like new String("hello") and stringify serializes it, the config entries are silently dropped and only the primitive value is written to disk.
The self-healing fallback is never reached because no exception is thrown — the validation parse(preservedContent) succeeds since the primitive is valid JSON. Verified empirically: parse('"hello"') → instanceof String === true, and after mutation + stringify → "hello" (all config entries lost).
| ) { | |
| if ( | |
| typeof parsed !== 'object' || | |
| parsed === null || | |
| Array.isArray(parsed) || | |
| parsed instanceof String || | |
| parsed instanceof Number || | |
| parsed instanceof Boolean | |
| ) { |
— qwen3.7-max via Qwen Code /review
| ); | ||
| (fs.readFileSync as Mock).mockImplementation((p) => { | ||
| if (p === userPath) return originalContent; | ||
| return '{}'; |
There was a problem hiding this comment.
[Suggestion] mockImplementationOnce((...args) => commentJson.parse(...args)) calls the spy itself (since commentJson.parse IS the mock), which consumes the second mockImplementationOnce (the throw). The throw therefore happens during parse(originalContent) — not parse(preservedContent) as the test name claims. The test passes only because both failures hit the same catch block, but the "validation failure" code path at trustedFolders.ts:213 is never actually exercised.
A symptom of this: the test is missing the writeStderrLine assertion that the other two fallback tests include.
Fix: use vi.importActual to get the real parse reference, avoiding the self-call:
const actualCommentJson = await vi.importActual<typeof commentJson>('comment-json');
parseSpy
.mockImplementationOnce((...args: Parameters<typeof commentJson.parse>) =>
actualCommentJson.parse(...args),
)
.mockImplementationOnce(() => {
throw new Error('invalid preserved output');
});— qwen3.7-max via Qwen Code /review
| content, | ||
| // noFollow: refuse to follow any pre-placed symlink at the | ||
| // config path — a redirected write could either leak the | ||
| // config path 鈥?a redirected write could either leak the |
There was a problem hiding this comment.
[Suggestion] The em-dash — (U+2014, bytes e2 80 94) was corrupted to mojibake 鈥? (bytes e9 88 a5 3f). On origin/main the line reads // config path — a redirected write but the PR branch reads // config path 鈥?a redirected write. This suggests an encoding conversion error in the editing environment.
| // config path 鈥?a redirected write could either leak the | |
| // config path — a redirected write could either leak the |
The same corruption also appears in trustedFolders.test.ts:222 (// posture 鈥?a pre-placed symlink → // posture — a pre-placed symlink).
— qwen3.7-max via Qwen Code /review
|
Thanks - I pushed another update addressing the latest review points. Changes in this revision:
Re-ran:
|
| ); | ||
| }); | ||
|
|
||
| it.each(['"hello"', '42', 'true'])( |
There was a problem hiding this comment.
[Suggestion] The parsed === null branch in the type guard (trustedFolders.ts:199) has no test coverage. comment-json.parse('null') returns literal null (typeof 'object', === null true), which the guard catches — but no test exercises this path.
| it.each(['"hello"', '42', 'true'])( | |
| it.each(['"hello"', '42', 'true', 'null'])( |
The existing assertion ('trusted folders file is not a JSON object') already matches.
— qwen3.7-max via Qwen Code /review
|
Thanks - added Re-ran:
|
wenshao
left a comment
There was a problem hiding this comment.
No review findings — code looks good. Downgraded from Approve to Comment: CI failing (Test on ubuntu/macos/windows, Lint). — qwen3.7-max via Qwen Code /review
Local Verification ReportBranch: TypeScript Compilation (
|
| Package | PR Branch | main | Status |
|---|---|---|---|
packages/core |
7 errors | 7 errors | ✅ No new errors |
packages/cli |
81 errors | 109 errors | ✅ No new errors (fewer than main due to branch divergence) |
All errors are pre-existing (vite plugin type mismatches in sdk-typescript, check-i18n.ts script issues, @modelcontextprotocol/sdk resolution).
Unit Tests (vitest)
| Test File | PR Branch | main | Status |
|---|---|---|---|
trustedFolders.test.ts |
30 passed | 21 passed | ✅ All pass (+9 new tests) |
New tests cover:
- Preserving existing comments when rewriting trusted folders
- Preserving disk-only entries when updating
- Fallback to clean rewrite on parse failure
- Fallback on validation failure
- Handling of array/primitive top-level files gracefully
CI Failure Analysis
CI build fails on src/tools/skill.ts(465,30): error TS2322 — this file is NOT modified by this PR. The error is a pre-existing type assignment issue unrelated to this change. See branch fix/skill-ts-type-error for the fix.
Code Review Notes
saveTrustedFolders()now preserves comments viacomment-jsonparse/stringify withapplyUpdates()round-trip- Validates serialized output before writing — falls back to clean rewrite on any failure (self-healing)
- Maintains security posture:
atomicWriteFileSyncwithnoFollow: trueandmode: 0o600 - No new dependencies added (
comment-jsonandapplyUpdatesalready used elsewhere in the codebase)
Verdict
✅ Ready to merge — No regressions introduced. All new tests pass. CI failure is unrelated pre-existing issue.
DragonnZhang
left a comment
There was a problem hiding this comment.
Review Summary
Verdict: No new findings. Code looks good.
This revision addresses all 12 prior inline comments from the previous review rounds:
- Self-healing regression -- resolved via try/catch fallback to clean rewrite with stderr diagnostic.
sync=trueconcern -- resolved by choosingsync=falseto preserve concurrent-process disk entries.- Untested error branches -- resolved with comprehensive tests for parse failure, validation failure, top-level array, and top-level primitives (including
null). - Duplication with
updateSettingsFilePreservingFormat-- acceptable; inline comment explains the rationale (must useatomicWriteFileSyncwithnoFollow:true). - Test mock ordering issues -- resolved; first
mockImplementationOncenow correctly delegates to the realcomment-json.parse. - Top-level array/primitive bypass -- resolved with
Array.isArray,=== null, andinstanceof String/Number/Booleanguards. - Empty catch block -- resolved with
writeStderrLinediagnostic. - Em-dash mojibake -- changed to plain hyphen (minor style regression from original em-dash, but acceptable).
Deterministic checks: tsc and eslint produced 0 findings on changed files.
Downgraded from Approve to Comment: CI still failing (Test on ubuntu/macos/windows, Lint). Once CI is green, this should be ready to merge.
— qwen-code via Qwen Code /review
DragonnZhang
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: CI failing (Test on ubuntu/macos/windows, Lint). — qwen3-coder-480b-a35b-instruct via Qwen Code /review
|
The current failing CI run appears to be stale. It started before #4804 was merged and fails during install/build on Could you please rerun the checks when convenient? |
What this PR does
This changes
trustedFolders.jsonsaving to preserve existing comments and formatting when trust rules are updated, instead of rewriting the file with plainJSON.stringify.Why it's needed
trustedFolders.jsonis currently read with comment-tolerant parsing, so users can maintain it as a commented JSONC-style config file. But when Qwen Code updates trust rules, the save path rewrites the whole file as plain JSON and strips all existing comments. This change makes the write path consistent with the read behavior while preserving the existing full-sync semantics of trusted folder updates.Reviewer Test Plan
How to verify
trustedFolders.jsonfile with comments, for example:{ // work repos "/existing/path": "TRUST_FOLDER" }Trigger a trusted folder update so Qwen Code rewrites the file.
Confirm that:
the existing comment is still present after the write
the updated trust rule is persisted
stale entries are removed rather than left behind
Evidence (Before & After)
N/A
Tested on
OS Status
🍗 macOS ⬜ not tested
🪟 Windows ✅ tested
🐧 Linux ⬜ not tested
Environment (optional)
Local workspace, unit tests only.
Commands run:
npm run test --workspace=packages/cli -- trustedFolders.test.ts
npm run typecheck --workspace=packages/cli
Risk & Scope
Main risk or tradeoff: This changes only the trusted folders save path and keeps the existing secure atomicWriteFileSync(..., noFollow: true) write path.
Not validated / out of scope: No manual end-to-end CLI interaction test was added in this change.
Breaking changes / migration notes: None.
Linked Issues
Fixes #3983
中文说明
这个 PR 做了什么 这个 PR 修改了 trustedFolders.json 的保存逻辑:当 trust rule 被更新时,保留文件里原有的注释和格式,而不是继续用普通 JSON.stringify 把整个文件重写掉。为什么需要这个改动
当前 trustedFolders.json 在读取时支持带注释的 JSONC 风格内容,因此用户可以把它当作可手动维护的配置文件来写。但 Qwen Code 在更新 trust rule 时,会把整个文件按纯 JSON 重写,导致已有注释全部丢失。这个改动让写入行为和读取行为保持一致,同时保留 trusted folders 现有的“完整同步”语义。
Reviewer Test Plan
如何验证
先创建一个带注释的 trustedFolders.json,例如:
{
// work repos
"/existing/path": "TRUST_FOLDER"
}
触发一次 trusted folder 更新,让 Qwen Code 重写这个文件。
确认:
原有注释在写回后仍然存在
更新后的 trust rule 被正确保存
已被移除的旧条目不会残留在文件里
前后对比证据
N/A
已测试环境
OS Status
🍗 macOS ⬜ not tested
🪟 Windows ✅ tested
🐧 Linux ⬜ not tested
环境说明(可选)
本地工作区,仅运行单元测试。
执行命令:
npm run test --workspace=packages/cli -- trustedFolders.test.ts
npm run typecheck --workspace=packages/cli
风险与范围
主要风险或权衡:这次改动只影响 trusted folders 的保存路径,并保留了现有的 atomicWriteFileSync(..., noFollow: true) 安全写入方式。
未验证 / 不在本次范围内:这次没有补手动的 CLI 端到端交互测试。
Breaking changes / migration notes: None.
关联 Issue
Fixes #3983