Skip to content

fix(toolRoots): accept roots nested below ~/.config/<name> - #1017

Merged
jeff-r2026 merged 1 commit into
Tencent:mainfrom
Smilewithoutfalling:fix/toolroots-nested-config-root
Oct 12, 2026
Merged

jeff-r2026 merged 1 commit into
Tencent:mainfrom
Smilewithoutfalling:fix/toolroots-nested-config-root

Conversation

@Smilewithoutfalling

@Smilewithoutfalling Smilewithoutfalling commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1010.

CLAUDE_CONFIG_DIR can point several levels below ~/.config: a per-directory profile manager such as cloak puts it at ~/.config/cloak/profiles/work/claude. teamai init refused to record that root, warned, and left the sync on ~/.claude — so the hooks were written to a settings file the tool never reads, and the session sync silently did nothing.

Why this is more than relaxing one predicate

My first revision only loosened isAddressableRootSegment. That is not enough, and looking at it the way the gate does shows why: the installed-tool gate does not read the recorded root — it derives one from the path's shape.

toolInstallRoot keys a .config/... path on its first two segments, so for the root above it probes .config/cloak. That directory can exist for an unrelated reason while the recorded root itself does not, and then the gate calls the tool installed and the write recreates a root the user had deleted — the very thing the gate exists to prevent.

So the two meanings get separated.

What changed

toolInstallRoot keeps answering from the path's shape. relocateToolPaths matches against it and applyToolRoots re-roots at it, so it must stay independent of any recorded root.

toolGateRoot is new, and is what the gate reads:

export function toolGateRoot(
  tool: string,
  toolPath: string,
  toolRoots?: Record<string, string>,
): string {
  const recorded = toolRoots?.[tool];
  if (recorded) {
    const segment = toolRootSegment(tool, recorded);
    if (segment && (toolPath === segment || toolPath.startsWith(`${segment}/`))) return segment;
  }
  return toolInstallRoot(toolPath);
}

The record is used only when toolPath really hangs off it — the same toolPaths are used unrelocated in project scope, where a HOME-relative member root says nothing — and only after toolRootSegment has vetted it (unregistered tool, rejected root: both fall through). With no recorded root the two functions agree, so every existing setup behaves exactly as before.

isAddressableRootSegment then accepts any depth under .config/:

-  return segments.length === 2 && segments[0] === '.config';
+  return segments[0] === '.config';

Roots nested outside .config (~/configs/claude, ~/.local/share/claude) stay refused.

Gate call sites now thread the record through: injectHooksToAllTools, reconcileHooksToAllTools, hooksRemove, the two user-scope probes in pull.ts and pre-push-sync.ts, and known-agents.ts's seedToolRoots / seedHookInstallRoot — the last two have to agree with the gate, or a self-mode nested root would never get its hooks.

The text that describes the root moves with the code. docs/guide/reference.md and its Chinese pair no longer say the check keys on .config/<name>; it reads the recorded root. skill-data/core/references/troubleshooting.md no longer tells agents a root nested below ~/.config/<name> cannot be synced. And toolRootRejection drops the same stale justification — .config/ is required so the check can tell the tool's root from an unrelated directory, not because that prefix is what it reads.

Verified

Real CLI, single-variable pair — teamai hooks inject with CLAUDE_CONFIG_DIR=$HOME/.config/cloak/profiles/work/claude. The only thing that varies is whether the recorded root exists on disk:

scenario control build (toolGateRoot degrades to toolInstallRoot) this branch
X — only .config/cloak exists ✔ Updated teamai hooks in …\.config\cloak\profiles\work\claude\settings.json, and the whole stale path is created ✔ Hooks injected into all AI tool settings; under .config the tree stays .config, .config/cloak — nothing written
Y — the recorded root exists writes into it writes into it

The control build reproduces the failure mode this PR is about; this branch stops it while still delivering to a root that is really there.

Unit tests — src/__tests__/tool-roots.test.ts, 42 passed, including six cases for toolGateRoot (reads the record; agrees with the path shape whenever the two can agree; ignores a record the path does not hang off; ignores a record the tool may not relocate; still skips a missing two-segment root) and a single-variable pair on isToolInstalledForConfig.

Mutation-checked: dropping the record branch inside toolGateRoot turns exactly the four intended cases red (4 failed / 38 passed) and leaves the controls green.

tsc --noEmit and oxlint are clean.

Regression check

The branch is rebased onto 2cbf5560: the tree is that commit plus exactly these thirteen files. #1002 (#904) touched the same gate function — the tail of isToolInstalledForConfig in src/resources/base.ts — so the resolution keeps its EDITION_HOME_ROOTS branch first and threads toolGateRoot into the probe call after it; src/types.ts keeps #1002's Trae schema and MCP_MANIFEST_KEY_ALIAS alongside toolGateRoot.

Against a pristine 2cbf5560 checkout, the eight gate-related test files produce identical failure sets — tool-roots, doctor, trae, qoder-cn, hooks, hooks-cmd, init, instruction-targets; every failure is already on the base (Windows path shapes, EBUSY under the runner, the three filterAgents cases).

Against the original base b69f8889 the affected test files behave the same: eleven identical failure sets, and pull-agent-models produces one fewer failure than the base (11 vs 12) — only Test timed out in 15000ms / EBUSY: resource busy or locked, rmdir …\Temp\…, no assertion on either revision.

Not touched

applyToolRoots / relocateToolPaths / resolveToolRootDir are string operations and were already depth-agnostic. The .config/<name> keying in toolInstallRoot is deliberately unchanged. toolRoots for tools other than claude / codex is unchanged.

@jeff-r2026 jeff-r2026 self-assigned this Oct 10, 2026
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/types.ts:2120 — Nested roots are accepted, but the installed-tool gate still checks only .config/<name>. If ~/.config/cloak exists while the recorded profile root ~/.config/cloak/profiles/work/claude was deleted or is mistyped, pull and hook injection treat Claude as installed and recreate that entire stale path. This breaks the gate’s invariant that uninstalled tools must not gain config directories; check the full recorded root rather than its two-segment prefix.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/types.ts:2116 — The new behavior accepts deeply nested .config roots, but skill-data/core/references/troubleshooting.md:70 still says these roots cannot be synced. For ~/.config/cloak/profiles/work/claude, agents will incorrectly tell users to change a valid environment variable. Update the affected skill-data as required by the repository rules.
  • [P2 non-blocking] docs/guide/reference.md:106 — The updated documentation says the installed-tool gate checks .config/<name>, but toolGateRoot now checks the exact recorded root. This gives users the wrong expectation when the recorded directory is deleted. Correct this explanation in both English and Chinese versions.

The earlier stale-root finding is resolved by the exact-root gate and its propagated call sites. The PR description includes a representative real-CLI verification, so its testing record is sufficient.

@Smilewithoutfalling
Smilewithoutfalling force-pushed the fix/toolroots-nested-config-root branch from 0a4b519 to a36cfa1 Compare October 10, 2026 07:09
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] skill-data/core/references/troubleshooting.md:70 — This still tells agents that roots nested below ~/.config/<name> cannot be synced. For ~/.config/cloak/profiles/work/claude, agents will incorrectly instruct users to change a now-valid environment variable. Update the affected skill-data as required by the repository rules.
  • [P2 non-blocking] docs/guide/reference.md:106 — The guide says the installed-tool gate checks .config/<name>, but toolGateRoot now checks the exact recorded root. This gives users the wrong expectation after that recorded directory is deleted. Correct this in both English and Chinese versions.

The earlier stale-root runtime finding is resolved by the exact-root gate and its propagated call sites. The PR description includes a representative real-CLI verification, so the testing record is sufficient.

The gate derived the root from the path's shape, so a root nested below
~/.config/<name> was probed at its two-segment prefix (.config/cloak) —
a directory that may exist for an unrelated reason while the recorded
root itself does not. That let the gate call the tool installed and the
write recreate a root the user had deleted.

Split the two meanings: toolInstallRoot keeps answering from the path
shape (which is what relocation keys on), toolGateRoot answers with the
recorded root when the path really hangs off it and falls back to the
shape otherwise, and isAddressableRootSegment accepts any depth under
.config/.

The docs and the agent-facing skill-data move with it: the guide (both
languages) and troubleshooting no longer say the check reads
.config/<name>, and the root-rejection message drops the same stale
justification — .config/ is required so the check can tell the tool's
root from an unrelated directory, not because that prefix is what it
reads.
@Smilewithoutfalling
Smilewithoutfalling force-pushed the fix/toolroots-nested-config-root branch from a36cfa1 to 3a23aa4 Compare October 10, 2026 07:51
@github-actions

Copy link
Copy Markdown

No findings.

  • Earlier stale-root runtime issue is resolved by the exact recorded-root gate and propagated call sites.
  • Earlier documentation findings are resolved in skill-data/core/references/troubleshooting.md:70, docs/guide/reference.md:106, and docs/guide/zh-CN/reference.md:106.
  • The PR description includes a representative real-CLI verification, so the testing record is sufficient.

@Smilewithoutfalling

Copy link
Copy Markdown
Contributor Author

CI note: the one red check is a pre-existing flake, not from this PR

Lint & Test (Node 20 on macos-latest) is the only non-green check on 3a23aa4b (run 38035856147 / job 114166004859). It is not an assertion failure — the run ends with:

 Test Files  399 passed | 1 skipped (400)
     Tests  8471 passed | 19 skipped (8490)
    Errors  1 error

The single error is an unhandled ENOENT on a lock file:

Error: ENOENT: no such file or directory, open '…/teamai-recall-attr-XXXX/…/votes/tester.yaml.lock'
The latest test that might've caused the error is "11: SubagentStop credits a read locally and pushes nothing mid-turn; the next Stop pushes it".

i.e. a TOCTOU in src/__tests__/recall-attribution.test.ts around the tester.yaml.lock acquisition.

Two cross-checks that it is unrelated to this change:

  1. The same failure hits a docs-only PR. #1016 (feature/docs-guide-paths, 4ac2e728) failed run 38041055150 with the identical signature — same test file, same lock path, same "latest test that might've caused the error" line; only the temp directory name differs. That run is Test Files 399 passed | 1 skipped (400) / Errors 1 error as well, and the PR touches no source file.
  2. The same job passed on this PR's previous head. a36cfa1e had all three macos-latest lanes green (including Node 20, job 114159025924). Comparing the two trees blob by blob: 984 blobs each, and only 4 differ — the three docs updated in the last commit plus src/types.ts, where the sole change is a user-facing error-message string. No functional delta that could reach a lock-file race.

So this PR is mergeable=true with no assertion regressions; a rerun should come back green. The lock subsystem itself is currently in flux (#1027, #999, #1035, #1036), which is where this signature belongs.

@jeff-r2026
jeff-r2026 merged commit 290e6b1 into Tencent:main Oct 12, 2026
12 of 13 checks passed
satan9394 added a commit to satan9394/teamai-cli that referenced this pull request Oct 12, 2026
Resolve the conflict in src/resources/base.ts: upstream Tencent#1017 added
toolGateRoot to the installed-tool gate. The Antigravity probe stays ahead
of the generic call — Antigravity is not in RELOCATABLE_TOOLS, so a
recorded toolRoots entry does not apply to it - and the generic call now
uses upstream's toolGateRoot form.

Switch the Antigravity hooks install gate in hooks.ts to the same
toolGateRoot shape upstream adopted, which is what upstream's import list
expects (toolInstallRoot is no longer imported there).
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.

[feat] Accept CLAUDE_CONFIG_DIR / CODEX_HOME roots deeper than ~/.config/<name>

2 participants