diff --git a/.codearbiter/gate-events.log b/.codearbiter/gate-events.log
index fa543968..8f86a694 100644
--- a/.codearbiter/gate-events.log
+++ b/.codearbiter/gate-events.log
@@ -1119,3 +1119,6 @@ Claude-Session: https://claude.ai/code/session_015ZDVU1BzUqnnPVHbZ397bX') stages
[2026-07-29T00:54:38Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/tech-stack.md is governed by ADR-0001-hybrid-adr-living-docs-governance (Adopt a hybrid ADR + living-docs governance model). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-29T01:20:32Z] REMIND [H-12] host=claude hook=post-write-edit.py | .github/workflows/ci.yml is governed by ADR-0014-pi-host-authentication-and-fail-closed-tool-boundary (Treat Pi host authentication as opaque runtime state and fail closed on unknown tools). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-29T01:20:32Z] REMIND [H-15] host=claude hook=post-write-edit.py | CI/CD workflow changed. Dispatch security-reviewer before merging (it reviews workflow/secrets/permissions exposure). Advisory — not a commit block.
+[2026-07-29T01:42:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/tools/farm.ts is governed by ADR-0002-plan-json-trusted-operator-shell-input (Treat plan.json gate commands and FARM_MUTATION_CMD as trusted operator-authored shell input). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
+[2026-07-29T01:42:49Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/tools/farm.ts is governed by ADR-0002-plan-json-trusted-operator-shell-input (Treat plan.json gate commands and FARM_MUTATION_CMD as trusted operator-authored shell input). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
+[2026-07-29T01:45:27Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/tools/farm.ts is governed by ADR-0002-plan-json-trusted-operator-shell-input (Treat plan.json gate commands and FARM_MUTATION_CMD as trusted operator-authored shell input). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
diff --git a/.github/scripts/test_ci_impact.py b/.github/scripts/test_ci_impact.py
index eee0368a..2ad5cbc4 100644
--- a/.github/scripts/test_ci_impact.py
+++ b/.github/scripts/test_ci_impact.py
@@ -1233,6 +1233,32 @@ def test_the_coverage_union_actually_merges_more_than_one_host(self):
self.assertIn("--merge-reports", merge, "the union job stopped merging")
self.assertIn("coverage-blob-ca-*", merge, "the merge job no longer collects the host blobs")
+ def test_the_coverage_union_artifact_path_is_not_a_hidden_directory(self):
+ """The union silently merged NOTHING for every run after it shipped.
+
+ `actions/upload-artifact` defaults to `include-hidden-files: false`, so
+ vitest's default `.vitest-reports/` uploaded zero files and the merge job
+ reported `blobs merged: 0` while still passing — an advisory job that
+ looks healthy and measures nothing is worse than an absent one.
+
+ Asserted structurally rather than by the directory's name: any hidden
+ upload path is acceptable ONLY with the flag that makes it work.
+ """
+ ci = CI_WORKFLOW.read_text(encoding="utf-8")
+ jobs = workflow_jobs(ci)
+ for job_id in ("coverage-union", "coverage-union-merge"):
+ body = jobs[job_id]
+ for match in re.finditer(r"path:\s*(\S+)", body):
+ target = match.group(1)
+ hidden = any(part.startswith(".") and part not in (".", "..")
+ for part in target.replace("\\", "/").split("/"))
+ if hidden:
+ self.assertIn(
+ "include-hidden-files: true", body,
+ f"{job_id} uploads the hidden path {target} without "
+ "include-hidden-files, so it uploads nothing",
+ )
+
def test_the_coverage_union_is_advisory_not_a_required_check(self):
"""DECISION-0031 weighed Available and made this explicitly non-blocking.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 247fad6b..fbb4c5f0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -367,8 +367,13 @@ jobs:
# reporter: the first run of this job failed on Windows and the log
# showed only "exit code 1" with no summary, no failing test name and no
# assertion. A cell that cannot say what broke is not worth having.
- run: npx vitest run --coverage --reporter=blob --reporter=default --outputFile.blob=.vitest-reports/${{ matrix.os }}.json
+ run: npx vitest run --coverage --reporter=blob --reporter=default --outputFile.blob=coverage-blobs/${{ matrix.os }}.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ # NOT vitest's default `.vitest-reports/`: upload-artifact defaults to
+ # `include-hidden-files: false`, so a dot-directory uploads NOTHING and
+ # the merge job silently reported `blobs merged: 0` for every run since
+ # this job shipped. A non-hidden directory needs no flag to be correct.
+ #
# always(): a FAILING host's blob is the one most worth having. Without
# this the upload is skipped exactly when the run needs diagnosing, and
# the merge job silently reports a one-host figure as though the other
@@ -376,7 +381,7 @@ jobs:
if: always()
with:
name: coverage-blob-ca-${{ matrix.os }}
- path: plugins/ca/tools/.vitest-reports/
+ path: plugins/ca/tools/coverage-blobs/
retention-days: 5
coverage-union-merge:
@@ -403,7 +408,7 @@ jobs:
with:
pattern: coverage-blob-ca-*
merge-multiple: true
- path: plugins/ca/tools/.vitest-reports/
+ path: plugins/ca/tools/coverage-blobs/
- name: Merge and report the union
# A missing blob (a cancelled or flaked host cell) degrades to "report
# what we have and name what is missing" rather than failing a lane
@@ -411,9 +416,9 @@ jobs:
# reading a partial local report.
run: |
echo "### Coverage union - plugins/ca/tools" >> "$GITHUB_STEP_SUMMARY"
- blobs=$(ls .vitest-reports/*.json 2>/dev/null | wc -l)
+ blobs=$(ls coverage-blobs/*.json 2>/dev/null | wc -l)
echo "blobs merged: $blobs" | tee -a "$GITHUB_STEP_SUMMARY"
- ls .vitest-reports/ 2>/dev/null | sed 's/^/ - /' >> "$GITHUB_STEP_SUMMARY" || true
+ ls coverage-blobs/ 2>/dev/null | sed 's/^/ - /' >> "$GITHUB_STEP_SUMMARY" || true
if [ "$blobs" -eq 0 ]; then
echo "no host produced a blob - nothing to merge (advisory)" | tee -a "$GITHUB_STEP_SUMMARY"
exit 0
@@ -422,7 +427,7 @@ jobs:
echo "PARTIAL: only $blobs of 2 hosts reported - this is NOT the union figure" | tee -a "$GITHUB_STEP_SUMMARY"
fi
echo '```' >> "$GITHUB_STEP_SUMMARY"
- npx vitest --merge-reports .vitest-reports --coverage --coverage.reporter=text-summary 2>&1 | tee -a "$GITHUB_STEP_SUMMARY" || true
+ npx vitest --merge-reports coverage-blobs --coverage --coverage.reporter=text-summary 2>&1 | tee -a "$GITHUB_STEP_SUMMARY" || true
echo '```' >> "$GITHUB_STEP_SUMMARY"
tools:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a66190fe..9b11706c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,30 @@ predate the plugin rewrite and are grouped by date.
## [Unreleased]
+## [2.10.3] — 2026-07-28
+
+### Fixed
+
+- **The farm worktree containment check canonicalizes both paths before
+ comparing them (#539).** It was lexical, and `path.resolve` neither expands a
+ Windows 8.3 short name nor follows a link — so two spellings of one directory
+ compared as different paths and a worktree root genuinely inside the repo was
+ refused, with a message telling the operator to point it inside the repo where
+ it already was. The only escape was `FARM_ALLOW_EXTERNAL_WORKTREE_ROOT=1`,
+ which disables the guard outright to work around a false positive in it.
+
+ Surfaced by #521's coverage-union job the first time this tree ran on Windows
+ CI: 15 failures, all downstream of one refusal, because the runner's tmpdir is
+ `C:\Users\RUNNER~1\...` while git reports `C:/Users/runneradmin/...`. It does
+ not reproduce on a developer box whose profile path is already 8.3-clean.
+
+ The change **tightens** the guard: resolving links means a junction inside the
+ repo whose target is outside is now refused, where the lexical check accepted
+ it. That was never exploitable — `fs.rm` removes the link rather than
+ recursing through it, measured — but accepting it was still wrong. A missing
+ root is normal and is canonicalized by walking up to the deepest existing
+ ancestor; any realpath failure that is not absence refuses, per #163.
+
## [2.10.2] — 2026-07-28
### Changed
diff --git a/README.md b/README.md
index 89e6b000..d91eeec5 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ Every intent routes through a gated skill or reviewer agent. Nothing commits unt
-
+
diff --git a/plugins/ca/.claude-plugin/plugin.json b/plugins/ca/.claude-plugin/plugin.json
index faa9bf28..f6366ac1 100644
--- a/plugins/ca/.claude-plugin/plugin.json
+++ b/plugins/ca/.claude-plugin/plugin.json
@@ -2,7 +2,7 @@
"name": "ca",
"displayName": "codeArbiter",
"description": "Orchestration layer for Claude Code. Routes every intent through gated skills and reviewer agents, drives spec-driven TDD, mechanically enforces the commit and audit-trail gates, decides via SMARTS, and keeps an append-only audit trail. Requires Python 3 on PATH. Dormant until you opt a repo in; run /ca:init to activate.",
- "version": "2.10.2",
+ "version": "2.10.3",
"author": { "name": "arbiterForge" },
"license": "AGPL-3.0-only",
"homepage": "https://github.com/arbiterForge/codeArbiter",
diff --git a/plugins/ca/tools/farm.js b/plugins/ca/tools/farm.js
index 9383cd33..a2039dc4 100755
--- a/plugins/ca/tools/farm.js
+++ b/plugins/ca/tools/farm.js
@@ -4,6 +4,7 @@
import { readFile as readFile2, writeFile, appendFile, mkdir as mkdir2, rm, stat, rename, open as open2 } from "node:fs/promises";
import { createHash, randomBytes } from "node:crypto";
import { spawnSync } from "node:child_process";
+import { realpathSync } from "node:fs";
import path3 from "node:path";
import { fileURLToPath } from "node:url";
@@ -679,9 +680,30 @@ function repoTopLevel() {
}
return path3.resolve(process.cwd());
}
+function canonicalize(target, label, realpath2 = realpathSync.native) {
+ const resolved = path3.resolve(target);
+ const missing = [];
+ let cursor = resolved;
+ for (; ; ) {
+ try {
+ const real = realpath2(cursor);
+ return missing.length ? path3.join(real, ...missing.slice().reverse()) : real;
+ } catch (error) {
+ const code = error.code;
+ if (code !== "ENOENT" && code !== "ENOTDIR")
+ throw new Error(
+ `${label} '${resolved}' could not be canonicalized (${code ?? "unknown error"}) while checking worktree containment, so it is refused (#163 fail-closed, #539).`
+ );
+ const parent = path3.dirname(cursor);
+ if (parent === cursor) return resolved;
+ missing.push(path3.basename(cursor));
+ cursor = parent;
+ }
+ }
+}
function validateWorktreeRoot(rawRoot, repo, external) {
const root = path3.resolve(rawRoot);
- if (!external && !isInside(path3.resolve(repo), root))
+ if (!external && !isInside(canonicalize(repo, "the repository root"), canonicalize(rawRoot, "FARM_WORKTREE_ROOT")))
throw new Error(
`FARM_WORKTREE_ROOT resolves to '${root}', outside the repository root '${path3.resolve(repo)}'. farm recursively deletes task worktrees under this root, so an out-of-repo root is refused (#163). Point it inside the repo, or set FARM_ALLOW_EXTERNAL_WORKTREE_ROOT=1 to override.`
);
@@ -2194,6 +2216,7 @@ export {
atomicWriteFile,
buildChatBody,
buildPrompt,
+ canonicalize,
captureInScope,
checkDrift,
cleanupFailures,
diff --git a/plugins/ca/tools/farm.ts b/plugins/ca/tools/farm.ts
index 7efd526c..75aa7885 100644
--- a/plugins/ca/tools/farm.ts
+++ b/plugins/ca/tools/farm.ts
@@ -37,6 +37,7 @@
import { readFile, writeFile, appendFile, mkdir, rm, stat, rename, open } from "node:fs/promises";
import { createHash, randomBytes } from "node:crypto";
import { spawnSync } from "node:child_process";
+import { realpathSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// v2.rev.0020 god-module split (architecture-003): the process/shell layer, the
@@ -263,12 +264,62 @@ function repoTopLevel(): string {
return path.resolve(process.cwd());
}
-// Pure, side-effect-free root validation (directly unit-testable): the resolved
-// worktree root must live inside `repo` unless `external` opts out. Throws with a
+// #539 — canonicalize before comparing. `path.resolve` normalizes separators and
+// `.`/`..`, but it does NOT expand a Windows 8.3 short name or follow a symlink,
+// so two spellings of ONE directory compare as different paths and containment
+// refuses a root that is genuinely inside the repo. GitHub's Windows runner hands
+// out `C:\Users\RUNNER~1\...` from tmpdir() while `git rev-parse --show-toplevel`
+// returns `C:/Users/runneradmin/...`; that mismatch failed 15 tests the first time
+// this tree ran on Windows CI, and the message told the operator to point the root
+// inside the repo, where it already was.
+//
+// This TIGHTENS the guard rather than loosening it: resolving links means a
+// junction inside the repo whose target is outside is now refused, where the
+// lexical check accepted it. (That was not exploitable - `fs.rm` removes the link
+// rather than recursing through it, measured - but accepting it was still wrong.)
+//
+// A missing path is NORMAL here: the root is created later. So canonicalize the
+// deepest EXISTING ancestor and re-attach the rest. Any realpath failure that is
+// not "does not exist" is a genuine failure and refuses, per #163's fail-closed
+// stance - never a silent fall-back to the lexical compare this replaces.
+// `realpath` is injectable so the fail-closed branch is reachable from a test:
+// an EACCES or ELOOP cannot be induced portably, and a guard whose refusal path
+// is never executed is the #163 stance asserted rather than proven.
+export function canonicalize(
+ target: string,
+ label: string,
+ realpath: (p: string) => string = realpathSync.native,
+): string {
+ const resolved = path.resolve(target);
+ const missing: string[] = [];
+ let cursor = resolved;
+ for (;;) {
+ try {
+ const real = realpath(cursor);
+ return missing.length ? path.join(real, ...missing.slice().reverse()) : real;
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ if (code !== "ENOENT" && code !== "ENOTDIR")
+ throw new Error(
+ `${label} '${resolved}' could not be canonicalized (${code ?? "unknown error"}) while ` +
+ `checking worktree containment, so it is refused (#163 fail-closed, #539).`,
+ );
+ const parent = path.dirname(cursor);
+ // Root of the volume reached and nothing on the path exists: fall back to
+ // the lexical form. Nothing was resolved, so nothing was weakened.
+ if (parent === cursor) return resolved;
+ missing.push(path.basename(cursor));
+ cursor = parent;
+ }
+ }
+}
+
+// Root validation (directly unit-testable): the CANONICAL worktree root must live
+// inside the canonical `repo` unless `external` opts out. Throws with a
// remediating message otherwise; returns the resolved absolute root.
export function validateWorktreeRoot(rawRoot: string, repo: string, external: boolean): string {
const root = path.resolve(rawRoot);
- if (!external && !isInside(path.resolve(repo), root))
+ if (!external && !isInside(canonicalize(repo, "the repository root"), canonicalize(rawRoot, "FARM_WORKTREE_ROOT")))
throw new Error(
`FARM_WORKTREE_ROOT resolves to '${root}', outside the repository root '${path.resolve(repo)}'. ` +
`farm recursively deletes task worktrees under this root, so an out-of-repo root is ` +
diff --git a/plugins/ca/tools/farm.unit.test.ts b/plugins/ca/tools/farm.unit.test.ts
index 58126292..5670f4f5 100644
--- a/plugins/ca/tools/farm.unit.test.ts
+++ b/plugins/ca/tools/farm.unit.test.ts
@@ -4,11 +4,11 @@
*/
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import path from "node:path";
-import { readFileSync } from "node:fs";
+import { readFileSync, mkdtempSync, mkdirSync, symlinkSync, rmSync, realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { mkdtemp, writeFile as fsWriteFile, mkdir as fsMkdir, readFile as fsReadFile, rm as fsRm, symlink as fsSymlink, readdir as fsReaddir, chmod as fsChmod, stat as fsStat, open as fsOpen } from "node:fs/promises";
import { tmpdir } from "node:os";
-import { extractFileBlocks, extractLiterals, codeLineCount, validate, assertSecureBaseUrl, runTask, httpWorker, DEFAULT_API_BASE_URL, parseChatCompletion, checkDrift, screenEntitlements, makeEntitlementProbe, redactSecrets, run, runGate, mintRunId, parseMutationHookOutput, buildChatBody, readSampling, buildPrompt, captureInScope, createLimiter, validateWorktreeRoot, assertContainedWorktree, allowedWorktreeRoot, _resetAllowedWorktreeRoot, numEnv, atomicWriteFile, assertSafeRunId } from "./farm.ts";
+import { extractFileBlocks, extractLiterals, codeLineCount, validate, assertSecureBaseUrl, runTask, httpWorker, DEFAULT_API_BASE_URL, parseChatCompletion, checkDrift, screenEntitlements, makeEntitlementProbe, redactSecrets, run, runGate, mintRunId, parseMutationHookOutput, buildChatBody, readSampling, buildPrompt, captureInScope, createLimiter, validateWorktreeRoot, canonicalize, assertContainedWorktree, allowedWorktreeRoot, _resetAllowedWorktreeRoot, numEnv, atomicWriteFile, assertSafeRunId } from "./farm.ts";
import type { InjectedFile, Sampling } from "./farm.ts";
import type { Worker, WorkerResult, RunTaskDeps, Task } from "./farm.ts";
import { removeWorktreeVerified, deleteBranchVerified, runExitCode, newRunArtifactHealth, cleanupReportLines, withWorktreeLock, prepareWorktree } from "./farm.ts";
@@ -2197,6 +2197,143 @@ describe("validateWorktreeRoot — out-of-repo root refused (#163)", () => {
});
});
+// #539 — the containment compare was LEXICAL, and `path.resolve` neither expands
+// a Windows 8.3 short name nor follows a link. Two spellings of ONE directory
+// therefore compared as different paths, and a root genuinely inside the repo was
+// refused with a message telling the operator to move it inside the repo.
+//
+// Found when #521's coverage-union job ran this tree on Windows CI for the first
+// time: 15 failures, all downstream of this one refusal, because the runner's
+// tmpdir is `C:\Users\RUNNER~1\...` while git reports `C:/Users/runneradmin/...`.
+// It does not reproduce on a developer box whose profile path is already 8.3-clean.
+//
+// A junction reproduces the same shape on any platform and is what these use: two
+// real, existing spellings of one directory.
+describe("validateWorktreeRoot — two spellings of one directory (#539)", () => {
+ let base: string;
+ let repo: string;
+ let linked: string;
+ let supported = true;
+
+ beforeEach(() => {
+ base = realpathSync.native(mkdtempSync(path.join(tmpdir(), "wt539-")));
+ repo = path.join(base, "real", "repo");
+ mkdirSync(path.join(repo, ".farm", "worktrees"), { recursive: true });
+ linked = path.join(base, "link");
+ try {
+ symlinkSync(path.join(base, "real"), linked, "junction");
+ } catch {
+ // Unprivileged Windows without developer mode cannot create one. Skip
+ // rather than assert nothing: a silently-passing test here would be worse
+ // than an absent one.
+ supported = false;
+ }
+ });
+
+ afterEach(() => rmSync(base, { recursive: true, force: true }));
+
+ it("accepts a root reached through a link when the repo is canonical", () => {
+ if (!supported) return;
+ const viaLink = path.join(linked, "repo", ".farm", "worktrees");
+ // The precondition: a purely lexical check REFUSES this.
+ expect(path.relative(repo, path.resolve(viaLink)).startsWith("..")).toBe(true);
+ expect(() => validateWorktreeRoot(viaLink, repo, false)).not.toThrow();
+ });
+
+ it("accepts a canonical root when the REPO is the side reached through a link", () => {
+ if (!supported) return;
+ const canonicalRoot = path.join(repo, ".farm", "worktrees");
+ expect(() => validateWorktreeRoot(canonicalRoot, path.join(linked, "repo"), false)).not.toThrow();
+ });
+
+ it("still refuses a genuinely outside root", () => {
+ if (!supported) return;
+ const outside = path.join(base, "outside");
+ mkdirSync(outside, { recursive: true });
+ expect(() => validateWorktreeRoot(outside, repo, false)).toThrow(/outside the repository root/);
+ });
+
+ it("now refuses a link INSIDE the repo whose target is outside it", () => {
+ if (!supported) return;
+ // The lexical check accepted this. Not exploitable — `fs.rm` removes the
+ // link rather than recursing through it, measured — but accepting it was
+ // still wrong, and canonicalizing makes the guard strictly tighter.
+ const outside = path.join(base, "outside");
+ mkdirSync(outside, { recursive: true });
+ const escape = path.join(repo, "escape");
+ symlinkSync(outside, escape, "junction");
+ expect(path.relative(repo, path.resolve(escape)).startsWith("..")).toBe(false);
+ expect(() => validateWorktreeRoot(escape, repo, false)).toThrow(/outside the repository root/);
+ });
+
+ it("accepts a not-yet-created root reached THROUGH the link", () => {
+ if (!supported) return;
+ // The root is created later, so a missing path is normal. It must be
+ // canonicalized by walking up to the deepest EXISTING ancestor — giving up
+ // at the first missing segment would leave the link unresolved and refuse
+ // it, which is the bug in a second costume.
+ //
+ // Deliberately routed through the link: a not-yet-created path under the
+ // CANONICAL repo is already lexically inside, so that version of this test
+ // passes whether or not the walk-up works, and proves nothing.
+ const notYet = path.join(linked, "repo", ".farm", "worktrees", "deep", "not", "created");
+ expect(path.relative(repo, path.resolve(notYet)).startsWith("..")).toBe(true);
+ expect(() => validateWorktreeRoot(notYet, repo, false)).not.toThrow();
+ });
+
+ it("keeps refusing an out-of-repo root that does not exist either", () => {
+ if (!supported) return;
+ expect(() => validateWorktreeRoot(path.join(base, "nope", "wt"), repo, false)).toThrow(
+ /outside the repository root/,
+ );
+ });
+});
+
+describe("canonicalize — fail-closed on a real realpath failure (#539/#163)", () => {
+ const err = (code: string) => {
+ const e = new Error(`simulated ${code}`) as NodeJS.ErrnoException;
+ e.code = code;
+ return e;
+ };
+
+ it("REFUSES when realpath fails for a reason other than absence", () => {
+ // EACCES and ELOOP cannot be induced portably, so the failure is injected.
+ // Without this the refusal branch never executes and #163's fail-closed
+ // stance is asserted in a comment rather than proven.
+ expect(() =>
+ canonicalize("/repo/wt", "FARM_WORKTREE_ROOT", () => { throw err("EACCES"); }),
+ ).toThrow(/could not be canonicalized \(EACCES\)/);
+ });
+
+ it("names the path and the label it could not canonicalize", () => {
+ expect(() =>
+ canonicalize("/repo/wt", "the repository root", () => { throw err("ELOOP"); }),
+ ).toThrow(/the repository root '.*' could not be canonicalized \(ELOOP\)/);
+ });
+
+ it("reports an errorless failure rather than passing it off as success", () => {
+ expect(() =>
+ canonicalize("/repo/wt", "FARM_WORKTREE_ROOT", () => { throw new Error("no code"); }),
+ ).toThrow(/could not be canonicalized \(unknown error\)/);
+ });
+
+ it("treats absence as normal and walks up to the deepest existing ancestor", () => {
+ const seen: string[] = [];
+ const out = canonicalize(path.join(path.sep, "a", "b", "c"), "x", (p) => {
+ seen.push(p);
+ if (p === path.resolve(path.sep, "a")) return path.resolve(path.sep, "REAL");
+ throw err("ENOENT");
+ });
+ expect(out).toBe(path.join(path.resolve(path.sep, "REAL"), "b", "c"));
+ expect(seen.length).toBeGreaterThan(1);
+ });
+
+ it("falls back to the lexical form when nothing on the path exists", () => {
+ const target = path.resolve(path.sep, "a", "b");
+ expect(canonicalize(target, "x", () => { throw err("ENOENT"); })).toBe(target);
+ });
+});
+
describe("assertContainedWorktree — no path escapes the root (#163)", () => {
beforeEach(() => _resetAllowedWorktreeRoot());
afterEach(() => _resetAllowedWorktreeRoot());