feat(cli): add configurable plansDirectory for Plan Mode - #4062
Conversation
Add a plansDirectory setting that allows users to define a custom directory for approved Plan Mode files. Relative paths are resolved against the project root and validated to prevent path traversal. - Storage: add isPathWithinDirectory() with realpathSync-based symlink resolution to prevent traversal bypass attacks (direct, intermediate, and cross-drive) - Config: cache plansDir at construction time, use atomic write (write-temp then rename) to prevent corrupted plan files on crash - CLI: respect bareMode by clearing plansDirectory in minimal mode - Docs: document plansDirectory with requiresRestart and gitignore hint - Tests: 26 new tests covering path validation, symlink attacks (direct and intermediate), Windows cross-drive paths, mixed separators, and configuration integration Closes QwenLM#3548
wenshao
left a comment
There was a problem hiding this comment.
Critical: When users change plansDirectory from the default (~/.qwen/plans) to a project-local path, all existing plan files become invisible — loadPlan() silently returns undefined with no migration warning. Consider checking the old default location and emitting a console warning if plans exist there but not in the new location.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
…ns-directory # Conflicts: # packages/core/src/config/config.test.ts
|
@wenshao 已按您的 review 全部对应处理,并确认最新 CI 通过:
最新 checks 已全部通过:Linux / Windows / macOS tests、Lint、CodeQL 都是 green。 |
wenshao
left a comment
There was a problem hiding this comment.
Additional Critical finding — test gap:
packages/core/src/config/config.test.ts: No happy-path test for savePlan() / loadPlan() when plansDirectory is configured. All existing I/O tests use the default directory. The only configured-directory I/O test (TOCTOU revalidation) mocks realpathSync to always throw — meaning the actual write/read paths for a custom directory are never verified to succeed. A regression in path.join(this.plansDir, ...) or the atomic-write sequence would go unnoticed.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
- Handle EXDEV during atomic plan writes (cross-device rename fallback) - Sanitize session IDs to prevent path traversal in plan filenames - Expand tilde (~) in configured plansDirectory paths - Preserve plansDirectory in bare mode - Add EACCES/EPERM handling to getPlanFileNames with user-visible warnings - Close TOCTOU gap with post-write path containment validation - Fix docs to clarify plansDirectory is a top-level key - Add happy-path I/O tests for configured plansDirectory
|
Reply to review #4291847967 Thank you for the thorough review. All findings have been addressed in commit 1. [Critical] Added 2. [Critical] Updated 3. [Critical] Test gap — no happy-path I/O test for configured Added 4. [Suggestion] Extended error handling in 5. [Suggestion] Introduced 6. [Suggestion] Replaced manual 7. [Suggestion] Removed the 8. [Suggestion] Dead code: Added an inline comment in 9. [Suggestion] Narrow TOCTOU window between validation and I/O ✅ Fixed After 10. [Suggestion] Errors in All changes have been validated with:
|
wenshao
left a comment
There was a problem hiding this comment.
No new issues found. All 13 findings from the previous review round have been addressed. LGTM! ✅
4 pre-existing tsc errors remain in packages/cli/src/config/config.test.ts (lines 1867, 1872, 1885, 1899) — getMcpServers() undefined handling in MCP config parsing tests. These predate this PR and are not caused by these changes.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
No new issues found in the incremental changes. All 11 previously flagged concerns are addressed: sessionId sanitization, symlink-aware containment checks, atomic writes with EXDEV fallback, tilde expansion, legacy migration warnings, EACCES/EPERM handling, and bareMode fix. Build and all 444 tests pass.
LGTM! ✅ — DeepSeek/deepseek-v4-pro via Qwen Code /review
|
Local tmux verification transcript, for the record. 1. Anti-corruption sanity (post-3850 lesson)All four files start with the license 2. PR-listed
|
| Check | Result |
|---|---|
| Anti-corruption file sanity | clean (3544/399/1749/2175 lines, license headers intact) |
| Post-approval force-push check | none (latest commit pre-dates both approvals) |
core storage + config vitest |
218 passed |
cli config + settingsSchema vitest |
226 passed + 2 skipped |
| Total local tests | 444 passed + 2 skipped |
| Remote CI | Lint / CodeQL / Test mac · ubuntu · windows all SUCCESS |
| Reviews | 2× APPROVED (wenshao, 5/15 11:53Z + 23:51Z), 13 threads all resolved, 0 Critical |
Environment
- Linux x86_64, Node v20.19.2 (project engines require ≥22; only the project's own
doctorChecksNode-version assertion reflects this in CI's matrix) - PR head
80add66aaeagainstmainmerge-base from PR's own merge log - Worktree at
/tmp/pr4062withnode_modulesandcore/distsymlinked from a parent install
|
@shenyankm sorry for the late ping — wanted to merge this just now and hit:
Could you: git fetch origin main
git merge origin/main # or: git rebase origin/main
# resolve conflicts (mostly settingsSchema.ts additions ordering)
git push # or: git push --force-with-lease if you rebasedI've enabled auto-merge — once the conflicts are resolved and CI re-runs green, it'll merge automatically without needing me back in the loop. No new review needed; the approvals carry through unless something substantive shifts during the rebase. The verification I posted earlier still stands for |
Head branch was pushed to by a user without write access
wenshao
left a comment
There was a problem hiding this comment.
Re-approving after the upstream merge fe3b4e4cbd "Merge upstream/main into sheny/plan-mode-plans-directory". The rebase resolved the conflicts cleanly; PR-only intent is unchanged.
Sanity check (post-3850 reflex)
All four PR-touched files have real content after the merge, no @path placeholder corruption:
3644 lines packages/core/src/config/config.ts (was 3544 — picked up main's additions)
399 lines packages/core/src/config/storage.ts
1840 lines packages/cli/src/config/config.ts (was 1749 — picked up main's additions)
2196 lines packages/cli/src/config/settingsSchema.ts (was 2175 — picked up main's additions)
All start with /** license header.
Local re-verification on fe3b4e4cbd
$ cd packages/core && npx vitest run src/config/storage.test.ts src/config/config.test.ts
✓ src/config/storage.test.ts (63 tests) 24ms
✓ src/config/config.test.ts (159 tests) 118ms
Tests 222 passed (222)
$ cd packages/cli && npx vitest run src/config/config.test.ts src/config/settingsSchema.test.ts
✓ src/config/settingsSchema.test.ts (18 tests) 11ms
✓ src/config/config.test.ts (220 tests | 2 skipped) 593ms
Tests 236 passed | 2 skipped (238)
Test counts ticked up vs my pre-rebase run (218 → 222 in core, 226 → 236 in cli) — the extras came from main during the merge, not new code in this PR. No regressions.
Remote CI: Lint / CodeQL / Classify PR already SUCCESS on fe3b4e4cbd; Test mac · ubuntu · windows still IN_PROGRESS.
Re-enabling auto-merge.
LGTM.
Summary
plansDirectorysetting for Plan Mode so approved plan files can be stored in a project-specific directory instead of always using the global default.plansDirectoryis unset.Validation
Commands run:
Prompts / inputs used: N/A. This change was validated through focused configuration and storage tests rather than manual TUI interaction.
Expected result:
plansDirectoryvalues resolve from the project root.~/.qwen/planswhen unset.Observed result:
npm run buildcompleted successfully.npm run typecheckcompleted successfully.Quickest reviewer verification path:
"plansDirectory": "./.qwen/plans"to project.qwen/settings.json..qwen/plans."../plans"and confirm startup fails with a configuration error.Evidence:
Scope / Risk
plansDirectoryis unset.Testing Matrix
Testing matrix notes:
Linked Issues / Bugs
Closes #3548