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 Claude Code plugin Codex plugin Pi Feature Forge preview -version 2.10.2 +version 2.10.3 commands skills agents 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());