Skip to content

fix(cli): canonicalize paths in /directory remove guard - #3867

Closed
B-A-M-N wants to merge 8 commits into
QwenLM:mainfrom
B-A-M-N:feat/directory-remove
Closed

fix(cli): canonicalize paths in /directory remove guard#3867
B-A-M-N wants to merge 8 commits into
QwenLM:mainfrom
B-A-M-N:feat/directory-remove

Conversation

@B-A-M-N

@B-A-M-N B-A-M-N commented May 6, 2026

Copy link
Copy Markdown
Contributor

Security and robustness fix for directory management:

  • Canonicalize paths using fs.realpathSync before checking initial-dir guard.
  • Added test case for symlink/relative path bypass.

@B-A-M-N
B-A-M-N force-pushed the feat/directory-remove branch from 78913ee to b018d09 Compare May 6, 2026 23:23
B-A-M-N and others added 3 commits May 7, 2026 00:52
The "WorkspaceContext with path expansion" test block was added in the
wrong branch (feat/directory-remove instead of feat/skipped-dirs-warning).
It references getSkippedDirectories() which doesn't exist here, and
uses vi.spyOn(os, 'homedir') which fails under ESM. Remove it entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Commit d584b8a incorrectly added PR 3868's test changes to this branch:
- removed debugLogger mock and skipped-directory warn test (PR 3868 feature)
- fixed includeDirectories test to use real temp dirs instead of POSIX
  paths that break on Windows

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

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Missing test coverage & other issues not tied to a single diff line:

  • packages/core/src/utils/workspaceContext.test.tsexpandHomeDir integration in resolveAndValidateDir is untested. No test uses ~/~/path input with addDirectory.
  • packages/core/src/services/fileDiscoveryService.test.ts — 3 edge-case tests removed without replacement: empty file list, relative project root paths, filterFiles with undefined options.
  • packages/cli/src/ui/commands/directoryCommand.test.tscompletion function, config === null branch, and loadServerHierarchicalMemory failure path are all untested.
  • packages/cli/src/ui/commands/directoryCommand.test.tsrealpathSync mock fallback uses vi.importActual without await, making the fallback non-functional (swallowed TypeError).
  • packages/core/src/config/config.test.ts — temp directory cleanup (rmSync) is unreachable on assertion failure, causing resource leaks.
  • Memory-refresh logic (loadServerHierarchicalMemory + setUserMemory + setGeminiMdFileCount) is duplicated between add and remove subcommands. Extract into a shared helper.
  • packages/core/src/utils/workspaceContext.ts:141-146 — The removeDirectory/isInitialDirectory catch-block fallbacks don't call expandHomeDir, unlike the resolveAndValidateDir path now added. For consistency, apply expandHomeDir in fallback paths too.

— deepseek-v4-pro via Qwen Code /review

return;
}

const removed = workspaceContext.removeDirectory(canonicalDirectory);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Non-atomic mutation: workspaceContext.removeDirectory() (line 367) is called before settings.setValue() (line 412-421). If settings persistence fails, the in-memory workspace context is already mutated with no rollback. The next /directory remove attempt in the same session fails with "Directory not found in workspace" — only a restart recovers.

Swap the order: persist to settings first, then call removeDirectory on success. If persistence fails, the in-memory state stays intact and the user can retry.

— deepseek-v4-pro via Qwen Code /review

// directory added at user scope would reappear on restart if we
// only clear the workspace-scoped list.
const targetDir = canonicalDirectory;
let targetScope: SettingScope | null = null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Silent success when targetScope remains null (lines 388-411). If the directory is not found in any settings scope's originalSettings.context.includeDirectories, the if (targetScope !== null) block at line 412 is skipped — setValue is never called. The directory is removed from in-memory WorkspaceContext (line 367) but remains in persisted settings. On restart, it reappears without warning.

This can happen when a directory is added via --include-directories CLI flag (not /directory add) or when the settings key exists but the array is empty. Show a warning when targetScope is null, or iterate all scopes regardless of match.

— deepseek-v4-pro via Qwen Code /review

SettingScope.User,
] as const) {
const scopeDirs =
settings.forScope(scope).originalSettings.context

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Settings matching uses Array.includes() strict equality (lines 396, 403). On case-insensitive filesystems (macOS) or when the stored path spelling differs from fs.realpathSync output (e.g., trailing slashes, .. segments, paths added via the UI dialog), the match fails and targetScope stays null — causing the silent-success bug described above.

Suggested change
settings.forScope(scope).originalSettings.context
// Normalize each scopeDir for comparison:
const normalizedTarget = canonicalDirectory;
if (scopeDirs.some(d => {
try { return fs.realpathSync(d) === normalizedTarget; }
catch { return d === targetDir || d === resolvedDirectory; }
})) {
targetScope = scope;
existingDirs = scopeDirs;
break;
}

— deepseek-v4-pro via Qwen Code /review

settings.forScope(scope).originalSettings.context
?.includeDirectories ?? [];
if (scopeDirs.includes(targetDir)) {
targetScope = scope;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The scope-matching loop breaks on the first match (line 399). If the same directory is stored in both Workspace and User scopes (possible via manual settings editing or migration), only one scope is cleaned. The surviving scope's entry reappears via MergeStrategy.CONCAT on restart.

Remove the break and collect all matching scopes, then call setValue for each one.

— deepseek-v4-pro via Qwen Code /review

})();

if (
workspaceContext.getInitialDirectories().includes(canonicalDirectory)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The CLI manually performs expandHomeDirpath.resolvefs.realpathSync and does a linear scan on getInitialDirectories() (line 352). WorkspaceContext already provides an isInitialDirectory() method that encapsulates this logic and handles error fallbacks. If isInitialDirectory's resolution logic changes later, this CLI guard won't pick up the change, creating inconsistent behavior.

Suggested change
workspaceContext.getInitialDirectories().includes(canonicalDirectory)
if (workspaceContext.isInitialDirectory(resolvedDirectory)) {

— deepseek-v4-pro via Qwen Code /review

);
context.ui.setGeminiMdFileCount(fileCount);
}
} catch (error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] When loadServerHierarchicalMemory throws (catch block at line 456), the error message is displayed but execution falls through to the final addItem that shows "Removed directory" as success. The user sees both an error and a success message simultaneously — contradictory and confusing UX.

Add return; after the error addItem call to prevent the dual messages.

— deepseek-v4-pro via Qwen Code /review

},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
completion: async (context: CommandContext) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The completion function (line 297) declares only (context: CommandContext), omitting the partialArg: string parameter. Tab completion always returns ALL removable directories regardless of what the user has already typed. For example, /directory remove /home/u + Tab still shows /var/www instead of filtering to /home/u*. The add subcommand uses getDirPathCompletions(partialArg) for path-aware filtering — remove should do the same.

Suggested change
completion: async (context: CommandContext) => {
completion: async (context: CommandContext, partialArg: string) => {
// ... existing logic ...
const removable = dirs.filter((d) => !initialDirs.includes(d));
if (!partialArg.trim()) return removable;
return removable.filter((d) => d.startsWith(partialArg.trim()));
},

— deepseek-v4-pro via Qwen Code /review

@B-A-M-N

B-A-M-N commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

Closing — superseded by #3975

This PR was an early implementation of /directory remove that has fallen behind main (currently CONFLICTING/DIRTY). PR #3975 is a clean single-commit rewrite of the same feature that is up-to-date with main and mergeable.

Closing in favor of #3975.

@B-A-M-N B-A-M-N closed this May 8, 2026
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants