Skip to content

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.csRepoInventoryDir resolves to %OneDrive%\PC-Bootstrap\_shared\repo-inventory by default (same parent dir as dispatch/ and peers/), overridable via Fleet:RepoInventoryDir config or FLEET_REPO_INVENTORY_DIR env.
  • Services/RepoRegistryReader.cs — reads every {PC}.json in that directory, verifies its signature, and exposes GetSnapshot() (grouped by RepoId, for the UI) and Resolve(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), returns RepoRegistrySnapshot.
  • POST /api/agent-runs — when the request body's run.repoId is set, the endpoint resolves (repoId, target) server-side to an absolute path and overwrites run.repo with it before validation. A miss returns 400 "{repo|worktree} not indexed on {target} — request a scan". No fallback to a caller-typed run.repo is offered once repoId is present.
  • Frontend command-panel.component.ts — the repo text box is gone. A <select> populated from GET /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:

  1. If Remote is set: normalize the origin URL to host/owner/repo lowercase, strip .git, strip credentials/protocol — e.g. git@github.com:andrewjonesdev-tools/fleet-mission-control.git and https://github.com/andrewjonesdev-tools/fleet-mission-control both become github.com/andrewjonesdev-tools/fleet-mission-control. Use that as RepoId.
  2. 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-FLT120 whose main checkout is fleet-mission-control reports RepoId = "fleet-mission-control", Name = "fleet-mission-control", and lists fmc-FLT120's path under Worktrees, 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.