FLT-120 — Fleet-wide repo registry: scanner contract¶
Status: BUILT (mission-control side) 2026-07-18. Council-approved shape (2026-07-18, unanimous build-first): registry only — no live filesystem browser (a browse-any-path endpoint on the write surface was explicitly rejected as attack surface).
This repo (fleet-mission-control) owns and implements the reader + resolver + pick-by-name UI. It does not implement the scanner that populates the registry. This document is the frozen contract the scanner must satisfy, so it can ship independently (in fleet-dispatch or fleet-harness — wherever the agent-side per-PC tooling already lives) against a target that will not move.
What exists today (this repo)¶
Services/FleetPaths.cs—RepoInventoryDirresolves to%OneDrive%\PC-Bootstrap\_shared\repo-inventoryby default (same parent dir asdispatch/andpeers/), overridable viaFleet:RepoInventoryDirconfig orFLEET_REPO_INVENTORY_DIRenv.Services/RepoRegistryReader.cs— reads every{PC}.jsonin that directory, verifies its signature, and exposesGetSnapshot()(grouped by RepoId, for the UI) andResolve(repoId, targetPc, worktreePath?)(for the launch path). Until a scanner writes anything there, every read returns an empty/unpopulated registry and every resolve misses — this is the correct, conservative default, not a bug.Contracts/RepoRegistryContracts.cs— the wire shapes below.GET /api/repos— read endpoint (open behind the tailnet perimeter, like every other GET), returnsRepoRegistrySnapshot.POST /api/agent-runs— when the request body'srun.repoIdis set, the endpoint resolves(repoId, target)server-side to an absolute path and overwritesrun.repowith it before validation. A miss returns 400"{repo|worktree} not indexed on {target} — request a scan". No fallback to a caller-typedrun.repois offered oncerepoIdis present.- Frontend
command-panel.component.ts— the repo text box is gone. A<select>populated fromGET /api/repos, filtered to repos indexed on the currently selected target PC, plus a worktree sub-select when the resolved entry reports any.
What the scanner must do (NOT built here — the open TODO)¶
For each fleet PC (see FleetTargets.KnownPcs for the live list), on a schedule (or on demand),
scan approved roots only — never the whole disk:
%USERPROFILE%\source\repos- any configured extra roots
- known worktree roots (e.g.
%USERPROFILE%\source\repos\*-wt*,*-FLT-*naming already in use across the fleet)
For each directory that is a git repo (.git present — a real dir for a main checkout, or a
.git file pointing at a worktree's gitdir for a linked worktree), emit one
RepoInventoryEntry:
public sealed record RepoInventoryEntry
{
public required string RepoId; // see "RepoId derivation" below
public required string Name; // display name — repo folder name is a reasonable default
public required string Pc; // COMPUTERNAME — must match FleetTargets.KnownPcs
public required string AbsPath; // absolute path on THIS pc, as the scanner found it
public string? Remote; // `git remote get-url origin`, or null if none
public string? DefaultBranch; // e.g. "master"/"main"
public string? CurrentBranch; // `git branch --show-current`
public bool Dirty; // `git status --porcelain` non-empty
public List<RepoWorktreeEntry> Worktrees; // `git worktree list --porcelain`, EXCLUDING the main checkout
public required string LastSeen; // ISO-8601 UTC timestamp of this scan
}
public sealed record RepoWorktreeEntry
{
public required string Path; // absolute path of the worktree
public string? Branch;
public bool Dirty;
}
RepoId derivation¶
RepoId must be stable across PCs for the same repo (that is the entire point — Andrew picks
one name, the resolver finds the right PC-local path) and stable across worktrees of the same
repo (a worktree is a sub-option under its parent's RepoId, not a separate repo). Recommended
derivation, in priority order:
- If
Remoteis set: normalize the origin URL tohost/owner/repolowercase, strip.git, strip credentials/protocol — e.g.git@github.com:andrewjonesdev-tools/fleet-mission-control.gitandhttps://github.com/andrewjonesdev-tools/fleet-mission-controlboth becomegithub.com/andrewjonesdev-tools/fleet-mission-control. Use that asRepoId. - If there is no remote (a purely local repo), fall back to the folder name of the main
checkout (not a worktree's suffixed name) — e.g. a worktree at
fmc-FLT120whose main checkout isfleet-mission-controlreportsRepoId = "fleet-mission-control",Name = "fleet-mission-control", and listsfmc-FLT120's path underWorktrees, not as its own entry.
A worktree must NEVER be emitted as a top-level RepoInventoryEntry with its own RepoId — it
belongs in its parent's Worktrees list. Distinguishing "is this a worktree" from "is this a
clone": git rev-parse --git-common-dir differs from --git-dir for a worktree and is equal for
a main checkout.
Signing (mandatory — the reader fails closed on anything unsigned)¶
Write one file per PC: repo-inventory\{PC}.json, containing:
public sealed class RepoInventoryFile
{
public string Pc; // MUST equal the filename (minus .json) — RepoRegistryReader
// rejects a file whose Pc claims a different PC than its own
// filename, the same trust-confusion class as FLT-56.
public string GeneratedAt; // ISO-8601 UTC
public List<RepoInventoryEntry> Repos;
public string Sig; // HMAC-SHA256 hex, lowercase — see below
}
Sign with the same fleet-dispatch key every listener already holds
(%USERPROFILE%\.claude\fleet-dispatch.key, base64 text — decode with
Convert.FromBase64String(File.ReadAllText(path).Trim()), NOT ReadAllBytes — see the
fleet-key-is-base64-not-raw lesson baked into FleetApiAuth.TryDecodeKey). Canonical string to
sign (byte-for-byte, mirrors AgentRunDispatcher.CanonicalString / RepoRegistryReader.CanonicalString):
CanonicalString = Pc + "|" + GeneratedAt + "|" + JsonSerializer.Serialize(Repos, JsonSerializerDefaults.Web)
Sig = ToHexString(HMACSHA256(key, UTF8Bytes(CanonicalString))).ToLowerInvariant()
RepoRegistryReader.Sign(RepoInventoryFile, byte[] key) in this repo is the reference
implementation — the scanner's signer must produce byte-identical output for byte-identical input,
so serialize Repos with System.Text.Json + JsonSerializerDefaults.Web exactly as that method
does (property casing/order comes from the type, not manual string-building).
Write atomically (temp file + rename in the same directory), same pattern as
AgentRunDispatcher.WriteJsonAtomic — a reader mid-scan must never see a half-written file.
What happens with zero scanners running¶
Nothing breaks. GET /api/repos returns { repos: [], registryPopulated: false }, the cockpit's
repo dropdown shows "none indexed on {target}", and the dispatch form's submit stays disabled
(no repoId can be picked). This is the intended fail-safe state, not an error condition — it is
exactly why the reader defaults to empty rather than throwing.