Context
Sub-leaf of #13015 (Fleet Manager MVP) — the repo-provisioning surface the epic names as a future, in-scope leaf: "per-agent clone/worktree management under the hood (create, locate, health-check; path stability honored because downstream memory is path-keyed)." The MVP operator loop is define agents → start/stop → repos managed under the hood. The registry (#13031), lifecycle (#13049), settings pane (#13058), and bridge-token (#13065 / #13121) leaves have all landed — but nothing yet derives where an agent's managed checkout lives.
The operator's recorded reality (2026-06-10→12, captured at the epic): operating the swarm means "one repo clone per peer (auto-memory is checkout-path-keyed, so paths are load-bearing)." That single clause makes the path-derivation a correctness boundary, not a convenience: if an agent's checkout path is not stable across restarts, its path-keyed auto-memory silently forks; if two distinct agents can ever collide onto one path, their memory cross-contaminates.
The Problem
Repo provisioning is two concerns welded together: (1) the pure decision of where an agent's checkout for a given repo belongs (deterministic path math), and (2) the side-effectful act of cloning / locating / health-checking it (git + fs). Building them as one service couples a security-and-correctness-critical pure rule to untestable I/O. The pure rule is the load-bearing half and deserves to be isolated, exhaustively unit-tested, and merged first — the same foundation-first decomposition the Extended-NL enforcement used (#13126 LockRegistry pure → #13135 WriteGuard stateful → wiring).
The pure rule also inherits a security obligation. FleetRegistryService already treats agent ids as untrusted keys — null-prototype credential maps + Object.hasOwn lookups, so an id like __proto__ / toString fails closed rather than aliasing a prototype member (FleetRegistryService.mjs:328, :243). An agent id defaults to the GitHub username but can be an arbitrary explicit string (defineAgent({id}) — "pass an explicit id to register multiple instances per user", FleetRegistryService.mjs:112). The moment that untrusted id is interpolated into a filesystem path, the untrusted-key posture must extend to path-traversal safety: an id like ../../etc or a/b must never escape the managed root.
The Architectural Reality
- Lands at
ai/services/fleet/deriveAgentRepoPath.mjs (Brain side, ai/ — the epic's stated home for fleet machinery; co-located with FleetRegistryService / FleetLifecycleService). A plain exported pure function, no Neo class machinery — the established shape for pure helpers (ai/services/neural-link/resolveCallTarget.mjs, src/ai/deriveSubtreePath.mjs).
- No hidden defaults (the config-is-SSOT contract):
managedRoot is a required argument, resolved by the future side-effectful caller from config — the pure function never derives, defaults, or reads a root from env/fs. It is pure string→string path math (Node path only).
- The side-effectful repo-provisioning service (a later leaf) consumes this to clone / locate / health-check; "the settings pane must not bundle it" (#13015) — this leaf is the first, foundational half.
- Not blocked on #12984 (session-id canonicalization): the path keys on
(agentId, repoSlug), never on a session id, so it sidesteps the multi-universe session-id blocker that gates the identity-env / wake-subscription provisioning leaves.
The Fix
export function deriveAgentRepoPath({managedRoot, agentId, repoSlug}) → an absolute, stable, collision-free, traversal-contained checkout path of shape <managedRoot>/<agentSegment>/<repoSegment>.
Each path segment is safeSegment(raw) = a sanitized, length-bounded, readable prefix (unsafe chars → -; dot-runs collapsed; leading/trailing dots + dashes trimmed — so . / .. can never be a bare segment) plus a deterministic sha256(raw).slice(0,12) suffix. The hash suffix is load-bearing twice over: it guarantees stability (deterministic in the raw id) AND collision-freeness even when sanitization is lossy (two distinct raw ids that sanitize identically still diverge by hash). A final containment assertion (path.resolve result must equal or sit under path.resolve(managedRoot)) is defense-in-depth over the sanitizer — and throws loudly if ever violated, because a silently-wrong path would clone into the wrong place.
Contract: throws on invalid trusted-caller input (non-string / empty managedRoot / agentId / repoSlug; non-absolute managedRoot); safely contains (never throws on) traversal-bearing or unsafe-char ids — producing a safe path is the function's job, not rejecting the id.
Acceptance Criteria
Out of Scope
- The side-effectful clone / worktree creation / health-check (a later repo-provisioning leaf consuming this).
managedRoot resolution / config wiring (the consuming service's concern; never defaulted here).
- Identity-env + wake-subscription provisioning (separate leaves; carry the #12984 blocker).
- Worktree-vs-clone policy selection — this derives a path; the strategy is the caller's.
Avoided Traps
- Pure-hash path (no readable segment) — collision-free but the operator cannot navigate
<root>/a1b2…/c3d4…; rejected for a sanitize + hash hybrid that stays human-navigable.
- Sanitize-only (no hash) — readable but lossy:
a/b and a-b collide → memory cross-contamination; rejected.
- Default the managed root inside the function — violates the config-is-SSOT no-hidden-defaults contract;
managedRoot is required and caller-resolved.
- Reject traversal ids by throwing — turns a safety property into a liveness footgun (a legitimately-odd explicit id would break provisioning); the function sanitizes-and-contains instead.
Decision Record impact
none — no ADR governs fleet path-derivation; this introduces a new pure helper consistent with existing fleet-service conventions.
Related
- Parent: #13015 (Fleet Manager MVP); grandparent #13012.
- Sibling leaves: #13031 (registry / credentials), #13049 (lifecycle), #13058 (settings pane), #13065 / #13121 (bridge-token).
- Pattern precedent:
#13126 (LockRegistry pure module), #13140 (deriveSubtreePath), #13130 (resolveCallTarget) — same pure-module + flattened-spec shape.
Live latest-open sweep: checked latest 20 open issues + a fleet / provision / clone / worktree / checkout scan at 2026-06-13T22:47Z; no equivalent found.
Release classification: post-release (Fleet Manager is a product line; nothing v13.x-blocking).
Origin Session ID: 4c598c8f-d8a7-4288-9420-e825a45d310e
Handoff Retrieval Hint: "Fleet Manager per-agent repo path derivation traversal-safe stable"; commit anchor — the deriveAgentRepoPath.mjs introduction.
Context
Sub-leaf of #13015 (Fleet Manager MVP) — the repo-provisioning surface the epic names as a future, in-scope leaf: "per-agent clone/worktree management under the hood (create, locate, health-check; path stability honored because downstream memory is path-keyed)." The MVP operator loop is define agents → start/stop → repos managed under the hood. The registry (#13031), lifecycle (#13049), settings pane (#13058), and bridge-token (#13065 / #13121) leaves have all landed — but nothing yet derives where an agent's managed checkout lives.
The operator's recorded reality (2026-06-10→12, captured at the epic): operating the swarm means "one repo clone per peer (auto-memory is checkout-path-keyed, so paths are load-bearing)." That single clause makes the path-derivation a correctness boundary, not a convenience: if an agent's checkout path is not stable across restarts, its path-keyed auto-memory silently forks; if two distinct agents can ever collide onto one path, their memory cross-contaminates.
The Problem
Repo provisioning is two concerns welded together: (1) the pure decision of where an agent's checkout for a given repo belongs (deterministic path math), and (2) the side-effectful act of cloning / locating / health-checking it (git + fs). Building them as one service couples a security-and-correctness-critical pure rule to untestable I/O. The pure rule is the load-bearing half and deserves to be isolated, exhaustively unit-tested, and merged first — the same foundation-first decomposition the Extended-NL enforcement used (
#13126LockRegistry pure →#13135WriteGuard stateful → wiring).The pure rule also inherits a security obligation.
FleetRegistryServicealready treats agentids as untrusted keys — null-prototype credential maps +Object.hasOwnlookups, so an id like__proto__/toStringfails closed rather than aliasing a prototype member (FleetRegistryService.mjs:328,:243). An agentiddefaults to the GitHub username but can be an arbitrary explicit string (defineAgent({id})— "pass an explicit id to register multiple instances per user",FleetRegistryService.mjs:112). The moment that untrusted id is interpolated into a filesystem path, the untrusted-key posture must extend to path-traversal safety: an id like../../etcora/bmust never escape the managed root.The Architectural Reality
ai/services/fleet/deriveAgentRepoPath.mjs(Brain side,ai/— the epic's stated home for fleet machinery; co-located withFleetRegistryService/FleetLifecycleService). A plain exported pure function, no Neo class machinery — the established shape for pure helpers (ai/services/neural-link/resolveCallTarget.mjs,src/ai/deriveSubtreePath.mjs).managedRootis a required argument, resolved by the future side-effectful caller from config — the pure function never derives, defaults, or reads a root from env/fs. It is pure string→string path math (Nodepathonly).(agentId, repoSlug), never on a session id, so it sidesteps the multi-universe session-id blocker that gates the identity-env / wake-subscription provisioning leaves.The Fix
export function deriveAgentRepoPath({managedRoot, agentId, repoSlug})→ an absolute, stable, collision-free, traversal-contained checkout path of shape<managedRoot>/<agentSegment>/<repoSegment>.Each path segment is
safeSegment(raw)= a sanitized, length-bounded, readable prefix (unsafe chars →-; dot-runs collapsed; leading/trailing dots + dashes trimmed — so./..can never be a bare segment) plus a deterministicsha256(raw).slice(0,12)suffix. The hash suffix is load-bearing twice over: it guarantees stability (deterministic in the raw id) AND collision-freeness even when sanitization is lossy (two distinct raw ids that sanitize identically still diverge by hash). A final containment assertion (path.resolveresult must equal or sit underpath.resolve(managedRoot)) is defense-in-depth over the sanitizer — and throws loudly if ever violated, because a silently-wrong path would clone into the wrong place.Contract: throws on invalid trusted-caller input (non-string / empty
managedRoot/agentId/repoSlug; non-absolutemanagedRoot); safely contains (never throws on) traversal-bearing or unsafe-char ids — producing a safe path is the function's job, not rejecting the id.Acceptance Criteria
ai/services/fleet/deriveAgentRepoPath.mjsexports a purederiveAgentRepoPath({managedRoot, agentId, repoSlug}); no fs / env / config / Neo access.agentIds (and distinctrepoSlugs) yield distinct paths — including two ids that sanitize to the same readable form (hash-disambiguated).agentId/repoSlugvalues such as../../etc/passwd,..,/abs,a/b/../..,__proto__never resolve outsidemanagedRoot.managedRoot/agentId/repoSlug, and a non-absolutemanagedRoot, throw.test/playwright/unit/ai/deriveAgentRepoPath.spec.mjscovers all of the above and passes under the custom unit config.Out of Scope
managedRootresolution / config wiring (the consuming service's concern; never defaulted here).Avoided Traps
<root>/a1b2…/c3d4…; rejected for a sanitize + hash hybrid that stays human-navigable.a/banda-bcollide → memory cross-contamination; rejected.managedRootis required and caller-resolved.Decision Record impact
none— no ADR governs fleet path-derivation; this introduces a new pure helper consistent with existing fleet-service conventions.Related
#13126(LockRegistry pure module),#13140(deriveSubtreePath),#13130(resolveCallTarget) — same pure-module + flattened-spec shape.Live latest-open sweep: checked latest 20 open issues + a fleet / provision / clone / worktree / checkout scan at 2026-06-13T22:47Z; no equivalent found.
Release classification: post-release (Fleet Manager is a product line; nothing v13.x-blocking).
Origin Session ID: 4c598c8f-d8a7-4288-9420-e825a45d310e
Handoff Retrieval Hint: "Fleet Manager per-agent repo path derivation traversal-safe stable"; commit anchor — the
deriveAgentRepoPath.mjsintroduction.