diff --git a/apps/cli/src/format.ts b/apps/cli/src/format.ts index 8e67b67..7cc0285 100644 --- a/apps/cli/src/format.ts +++ b/apps/cli/src/format.ts @@ -2,9 +2,9 @@ * format.ts — pure formatting helpers for CLI output (no network, no I/O). */ -import type { WorkspaceSummary } from "fleet-protocol"; +import type { ArmoryEntry, ArmorySyncState, WorkspaceSummary } from "fleet-protocol"; import type { Repo } from "fleet-protocol"; -import type { ShipInfo, BridgeWorkspaceSummary } from "fleet-bridge/types"; +import type { ShipInfo, BridgeWorkspaceSummary, ShipArmoryState } from "fleet-bridge/types"; /** * Render an aligned, human-readable table: a header row followed by one row per @@ -53,3 +53,78 @@ export function formatRepoTable(rows: readonly Repo[]): string { rows.map((row) => [row.name, row.url, row.provider]), ); } + +/** Placeholder for a column whose value does not exist yet. */ +const MISSING = "-"; + +/** Revisions are 64 hex characters; 12 is plenty to compare two by eye. */ +export function abbreviateRevision(revision: string | null): string { + return revision ? revision.slice(0, 12) : MISSING; +} + +/** Where a ship stands against the bridge's armory. */ +export type ArmoryShipState = "in sync" | "behind" | "never" | "error" | "unknown"; + +/** + * `error` outranks the revision comparison: a ship whose last sync failed is + * holding a revision it could not replace, so reporting it as merely "behind" + * would hide the reason it is stuck. `unknown` is the bridge not having been able + * to ask at all, which is distinct from a ship answering that it holds nothing. + * + * `fleet-client`'s `syncStatus` derives the same thing for the web GUI. The two + * packages do not depend on each other, so the duplication is deliberate; keep + * their precedence identical. + */ +export function armoryShipState(bridgeRevision: string, state: ArmorySyncState | null): ArmoryShipState { + if (!state) return "unknown"; + if (state.lastError) return "error"; + if (!state.revision) return "never"; + return state.revision === bridgeRevision ? "in sync" : "behind"; +} + +/** + * Render a timestamp as ISO-8601, or the placeholder when there is none. + * + * Accepts a `Date` despite `ArmorySyncState.syncedAt` being typed `string`: + * Eden Treaty revives ISO strings in a response body into `Date` objects, so the + * declared type is not what actually arrives. + */ +export function formatTimestamp(at: string | Date | null): string { + if (!at) return MISSING; + const parsed = at instanceof Date ? at : new Date(at); + return Number.isNaN(parsed.getTime()) ? String(at) : parsed.toISOString(); +} + +/** Modes are normalized to `0o644`/`0o755` before they reach the manifest. */ +function formatMode(mode: number): string { + return `0${(mode & 0o777).toString(8).padStart(3, "0")}`; +} + +/** + * Render armory manifest entries as a table. `PATH` keeps its section prefix even + * though `SECTION` repeats it, so a row can be pasted straight into + * `fleet client armory cat`. + */ +export function formatArmoryTable(rows: readonly ArmoryEntry[]): string { + return renderTable( + ["SECTION", "PATH", "SIZE", "MODE"], + rows.map((row) => [row.section, row.path, String(row.size), formatMode(row.mode)]), + ); +} + +/** Render each ship's armory state against the bridge's current `bridgeRevision`. */ +export function formatArmoryShipTable( + bridgeRevision: string, + rows: readonly ShipArmoryState[], +): string { + return renderTable( + ["SHIP", "STATUS", "REVISION", "SYNCED", "STATE"], + rows.map((row) => [ + row.ship, + row.status, + abbreviateRevision(row.state?.revision ?? null), + formatTimestamp(row.state?.syncedAt ?? null), + armoryShipState(bridgeRevision, row.state ?? null), + ]), + ); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 243bced..672f852 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -8,10 +8,22 @@ */ import { Command } from "commander"; -import { DEFAULT_PORT, type Repo, type WorkspaceStatus, type WorkspaceSummary } from "fleet-protocol"; -import type { ShipInfo, BridgeWorkspaceSummary } from "fleet-bridge/types"; +import { + ARMORY_SECTIONS, + DEFAULT_PORT, + type ArmoryFile, + type ArmoryManifest, + type ArmorySection, + type Repo, + type WorkspaceStatus, + type WorkspaceSummary, +} from "fleet-protocol"; +import type { ShipInfo, BridgeWorkspaceSummary, ShipArmoryState } from "fleet-bridge/types"; import { makeBridgeClient, makeClient, normalizeUrl, unwrap } from "./client"; import { + abbreviateRevision, + formatArmoryShipTable, + formatArmoryTable, formatFleetWorkspaceTable, formatRepoTable, formatShipTable, @@ -255,6 +267,94 @@ reposCommand clientCommand.addCommand(reposCommand); +const armoryCommand = new Command() + .name("armory") + .description("inspect the fleet's armory (via the bridge); read-only"); + +armoryCommand + .command("ls") + .description("list the files the bridge's armory holds") + .option("--json", "output as JSON") + .option("--section
", `only files in one section (${ARMORY_SECTIONS.join(", ")})`) + .action(async (options: { json?: boolean; section?: string }) => { + const section = options.section; + if (section !== undefined && !ARMORY_SECTIONS.includes(section as ArmorySection)) { + console.error(`fleet: unknown section "${section}"; expected one of: ${ARMORY_SECTIONS.join(", ")}`); + process.exit(1); + } + + const manifest = unwrap(await bridgeClient().armory.get()) as ArmoryManifest; + const entries = section + ? manifest.entries.filter((entry) => entry.section === section) + : manifest.entries; + + if (options.json) { + console.log(JSON.stringify({ ...manifest, entries }, null, 2)); + } else if (entries.length === 0) { + console.log("no armory files"); + } else { + console.log( + `revision ${abbreviateRevision(manifest.revision)} (${entries.length} file${entries.length === 1 ? "" : "s"})`, + ); + console.log(formatArmoryTable(entries)); + } + }); + +armoryCommand + .command("cat") + .description("print an armory file's contents") + .argument("", "armory-relative path, e.g. skills/my-skill/SKILL.md") + .action(async (path: string) => { + const file = unwrap(await bridgeClient().armory.file.get({ query: { path } })) as ArmoryFile; + + // Binary bytes re-encoded through stdout would arrive mangled, and a + // redirect would capture that silently — refuse rather than hand back a + // corrupt file. + if (file.encoding === "base64") { + console.error( + `fleet: ${file.path} is binary (${file.size} bytes, sha256 ${file.sha256}); not writing it to stdout`, + ); + process.exit(1); + } + + process.stdout.write(file.contents); + }); + +armoryCommand + .command("ships") + .description("show what each ship has pulled and installed from the armory") + .option("--json", "output as JSON") + .action(async (options: { json?: boolean }) => { + const rows = unwrap(await bridgeClient().armory.ships.get()) as ShipArmoryState[]; + if (options.json) { + console.log(JSON.stringify(rows, null, 2)); + return; + } + if (rows.length === 0) { + console.log("no ships"); + return; + } + + const manifest = unwrap(await bridgeClient().armory.get()) as ArmoryManifest; + console.log(formatArmoryShipTable(manifest.revision, rows)); + + for (const row of rows) { + const install = row.state?.install; + const problems = [ + ...(row.state?.lastError ? [`error: ${row.state.lastError}`] : []), + ...(install?.conflicts ?? []).map((conflict) => `conflict: ${conflict}`), + ...(install?.warnings ?? []).map((warning) => `warning: ${warning}`), + ]; + if (problems.length === 0) continue; + + console.log(""); + console.log(`${row.ship}:`); + for (const problem of problems) console.log(` ${problem}`); + } + }); + +clientCommand.addCommand(armoryCommand); + clientCommand .command("serve") .description("Serve the client web ui") diff --git a/apps/cli/src/launch-command.ts b/apps/cli/src/launch-command.ts index 35afdf7..247611f 100644 --- a/apps/cli/src/launch-command.ts +++ b/apps/cli/src/launch-command.ts @@ -11,21 +11,49 @@ import { startBridge } from "fleet-bridge"; import { startShip } from "fleet-ship"; import { startClientServer } from "fleet-client"; import { normalizeUrl } from "./client"; -import { CONFIG_TEMPLATE, loadLaunchConfig } from "./launch-config"; +import { CONFIG_TEMPLATE, loadLaunchConfig, publicUrlWarning } from "./launch-config"; const DEFAULT_CONFIG_PATH = "./fleet-config.yaml"; async function runLaunch(configPath: string): Promise { const config = await loadLaunchConfig(configPath); + const warning = publicUrlWarning(config); + if (warning) { + console.warn(`fleet launch: ${warning}`); + } + let manager: Awaited>["manager"] | undefined; if (config.bridge) { ({ manager } = await startBridge(config.bridge)); } + // A launch knows both sides, so it can pin each ship it spawns to the bridge + // it just started rather than leaving it to trust whoever pushes first. The + // value must be the one the bridge pushes with, not the one this process would + // dial, hence `publicUrl` and the same fallback the bridge uses. + const launchedBridgeUrl = config.bridge + ? (config.bridge.publicUrl ?? `http://localhost:${config.bridge.port}`) + : undefined; + if (launchedBridgeUrl && !isHttpUrl(launchedBridgeUrl)) { + // A ship refuses a pin that is not an http(s) URL. Failing the whole launch + // over a `bridge.publicUrl` that previously only broke the armory would be a + // worse trade than starting unpinned and saying so. + console.warn( + `fleet launch: bridge.publicUrl "${launchedBridgeUrl}" is not an http(s) URL, so ships are ` + + "started unpinned and will accept the first armory push they receive", + ); + } + const shipBridgeUrl = launchedBridgeUrl && isHttpUrl(launchedBridgeUrl) ? launchedBridgeUrl : undefined; + for (const ship of config.ships) { if (ship.source === "local") { - await startShip({ fleetDirectory: ship.fleetDirectory, port: ship.port, name: ship.name }); + await startShip({ + fleetDirectory: ship.fleetDirectory, + port: ship.port, + name: ship.name, + bridgeUrl: shipBridgeUrl, + }); } const url = ship.source === "local" ? `http://localhost:${ship.port}` : ship.url; @@ -48,6 +76,15 @@ async function runLaunch(configPath: string): Promise { } } +function isHttpUrl(value: string): boolean { + try { + const { protocol } = new URL(value); + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +} + async function runInit(configPath: string, force: boolean): Promise { const file = Bun.file(configPath); if (!force && (await file.exists())) { diff --git a/apps/cli/src/launch-config.ts b/apps/cli/src/launch-config.ts index 5f1ed2d..29019ed 100644 --- a/apps/cli/src/launch-config.ts +++ b/apps/cli/src/launch-config.ts @@ -24,6 +24,14 @@ const BridgeSectionSchema = z.object({ dataDirectory: z.string().min(1).default(DEFAULT_BRIDGE_DATA_DIRECTORY), port: z.number().int().default(DEFAULT_BRIDGE_PORT), name: z.string().min(1).default(DEFAULT_BRIDGE_NAME), + /** + * URL *ships* use to reach this bridge — it is handed to each ship so it can + * pull the armory, so it must resolve from the ships' hosts, not only from the + * one running the launch. Omitted, the bridge falls back to + * `http://localhost:`, which is right for a single-host fleet and wrong + * for any ship on another machine. + */ + publicUrl: z.string().min(1).optional(), }); const GuiSectionSchema = z.object({ @@ -79,6 +87,7 @@ export interface NormalizedBridge { dataDirectory: string; port: number; name: string; + publicUrl?: string; } export interface NormalizedLocalShip { @@ -147,6 +156,30 @@ export function parseLaunchConfig(raw: unknown): NormalizedLaunchConfig { return { bridge, gui: parsed.gui, ships }; } +/** + * The warning a config earns by registering ships on other hosts without telling + * them how to reach this bridge, or `null` when there is nothing to say. + * + * Deliberately not an error: a `source: remote` ship can be on this very host + * (behind a tunnel, in a container publishing a port), where the + * `http://localhost:` fallback resolves fine. But when it is wrong it fails + * silently — the ship is registered, workspaces work, and only the armory never + * arrives — so it is worth saying out loud. + */ +export function publicUrlWarning(config: NormalizedLaunchConfig): string | null { + if (!config.bridge || config.bridge.publicUrl) return null; + + const remote = config.ships.filter((ship) => ship.source === "remote"); + if (remote.length === 0) return null; + + const names = remote.map((ship) => `"${ship.key}"`).join(", "); + return ( + `bridge.publicUrl is not set, so remote ${remote.length === 1 ? "ship" : "ships"} ${names} will be ` + + `told this bridge is at http://localhost:${config.bridge.port}, which on their hosts is themselves; ` + + `set bridge.publicUrl to a URL those hosts can reach` + ); +} + /** Standard scaffold written by `fleet launch init` (commented for humans to edit). */ export const CONFIG_TEMPLATE = `# fleet-config.yaml — configuration for \`fleet launch\`. # Every section is optional; only the sections present are started. @@ -156,6 +189,7 @@ bridge: dataDirectory: ./.fleet/bridge port: 4800 name: my-fleet-bridge + # publicUrl: http://this-host:4800 # how ships reach this bridge; required if any ship is on another host # The web gui. Proxies to the bridge above by default. gui: diff --git a/apps/cli/tests/format.test.ts b/apps/cli/tests/format.test.ts index 27294f6..6b682b4 100644 --- a/apps/cli/tests/format.test.ts +++ b/apps/cli/tests/format.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; +import type { ArmorySyncState } from "fleet-protocol"; import { + abbreviateRevision, + armoryShipState, + formatArmoryShipTable, + formatArmoryTable, + formatTimestamp, formatFleetWorkspaceTable, formatRepoTable, formatShipTable, @@ -80,3 +86,121 @@ describe("formatRepoTable", () => { expect(lines[2]).toBe("x u custom"); }); }); + +const REVISION_A = "a".repeat(64); +const REVISION_B = "b".repeat(64); + +function syncState(overrides: Partial = {}): ArmorySyncState { + return { + revision: REVISION_A, + bridgeUrl: "http://bridge:4800", + syncedAt: "2026-07-26T10:00:00.000Z", + fileCount: 3, + install: null, + lastError: null, + ...overrides, + }; +} + +describe("abbreviateRevision", () => { + test("takes the first 12 characters", () => { + expect(abbreviateRevision(REVISION_A)).toBe("aaaaaaaaaaaa"); + }); + + test("renders a missing revision as a placeholder", () => { + expect(abbreviateRevision(null)).toBe("-"); + }); +}); + +describe("formatTimestamp", () => { + test("renders an ISO string unchanged", () => { + expect(formatTimestamp("2026-07-26T10:00:00.000Z")).toBe("2026-07-26T10:00:00.000Z"); + }); + + test("normalizes the Date that Eden revives a timestamp into", () => { + expect(formatTimestamp(new Date("2026-07-26T10:00:00.000Z"))).toBe("2026-07-26T10:00:00.000Z"); + }); + + test("renders a missing timestamp as a placeholder", () => { + expect(formatTimestamp(null)).toBe("-"); + }); + + test("passes an unparseable value through rather than printing Invalid Date", () => { + expect(formatTimestamp("not a date")).toBe("not a date"); + }); +}); + +describe("armoryShipState", () => { + test("is unknown when the bridge could not reach the ship", () => { + expect(armoryShipState(REVISION_A, null)).toBe("unknown"); + }); + + test("is never when the ship has not applied a revision", () => { + expect(armoryShipState(REVISION_A, syncState({ revision: null }))).toBe("never"); + }); + + test("compares the applied revision against the bridge's", () => { + expect(armoryShipState(REVISION_A, syncState())).toBe("in sync"); + expect(armoryShipState(REVISION_B, syncState())).toBe("behind"); + }); + + test("error outranks both the revision comparison and a null revision", () => { + expect(armoryShipState(REVISION_A, syncState({ lastError: "pull failed" }))).toBe("error"); + expect(armoryShipState(REVISION_B, syncState({ lastError: "pull failed" }))).toBe("error"); + expect(armoryShipState(REVISION_A, syncState({ revision: null, lastError: "pull failed" }))).toBe( + "error", + ); + }); +}); + +describe("formatArmoryTable", () => { + test("renders headers only for an empty list", () => { + expect(formatArmoryTable([])).toBe("SECTION PATH SIZE MODE"); + }); + + test("keeps the section prefix on PATH and renders the mode in octal", () => { + const out = formatArmoryTable([ + { + path: "dotfiles/gitconfig", + section: "dotfiles", + size: 42, + sha256: "0".repeat(64), + mode: 0o644, + }, + { + path: "skills/reviewer/SKILL.md", + section: "skills", + size: 1024, + sha256: "1".repeat(64), + mode: 0o755, + }, + ]); + + const lines = out.split("\n"); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe("SECTION PATH SIZE MODE"); + expect(lines[1]).toBe("dotfiles dotfiles/gitconfig 42 0644"); + expect(lines[2]).toBe("skills skills/reviewer/SKILL.md 1024 0755"); + }); +}); + +describe("formatArmoryShipTable", () => { + test("renders headers only for an empty list", () => { + expect(formatArmoryShipTable(REVISION_A, [])).toBe("SHIP STATUS REVISION SYNCED STATE"); + }); + + test("derives STATE per ship and blanks an unreachable ship's columns", () => { + const out = formatArmoryShipTable(REVISION_A, [ + { ship: "orca", status: "online", state: syncState() }, + { ship: "krill", status: "online", state: syncState({ revision: REVISION_B }) }, + { ship: "a", status: "offline", state: null }, + ]); + + const lines = out.split("\n"); + expect(lines).toHaveLength(4); + expect(lines[0]).toBe("SHIP STATUS REVISION SYNCED STATE"); + expect(lines[1]).toBe("orca online aaaaaaaaaaaa 2026-07-26T10:00:00.000Z in sync"); + expect(lines[2]).toBe("krill online bbbbbbbbbbbb 2026-07-26T10:00:00.000Z behind"); + expect(lines[3]).toBe("a offline - - unknown"); + }); +}); diff --git a/apps/cli/tests/launch-config.test.ts b/apps/cli/tests/launch-config.test.ts index e728303..bd9b695 100644 --- a/apps/cli/tests/launch-config.test.ts +++ b/apps/cli/tests/launch-config.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { parse } from "yaml"; import { resolve } from "node:path"; -import { CONFIG_TEMPLATE, parseLaunchConfig } from "../src/launch-config"; +import { CONFIG_TEMPLATE, parseLaunchConfig, publicUrlWarning } from "../src/launch-config"; describe("parseLaunchConfig", () => { test("normalizes a full config (bridge + gui + local & remote ships)", () => { @@ -71,6 +71,16 @@ describe("parseLaunchConfig", () => { expect(config.gui).toEqual({ bridgeUrl: "http://host:4800" }); }); + test("bridge carries an explicit publicUrl through unchanged", () => { + const config = parseLaunchConfig({ bridge: { publicUrl: "http://control.internal:4800" } }); + expect(config.bridge).toEqual({ + dataDirectory: resolve("./.fleet/bridge"), + port: 4800, + name: "bridge", + publicUrl: "http://control.internal:4800", + }); + }); + test("the init scaffold is a valid config", () => { const config = parseLaunchConfig(parse(CONFIG_TEMPLATE)); expect(config.bridge?.name).toBe("my-fleet-bridge"); @@ -80,3 +90,43 @@ describe("parseLaunchConfig", () => { ]); }); }); + +describe("publicUrlWarning", () => { + const remoteConfig = (bridge: Record) => + parseLaunchConfig({ + bridge, + ships: { + "ship-a": { source: "remote", url: "http://a:4700" }, + "ship-b": { source: "remote", url: "http://b:4700" }, + }, + }); + + test("warns when remote ships are registered with no publicUrl", () => { + const warning = publicUrlWarning(remoteConfig({ port: 4800 })); + expect(warning).toContain('remote ships "ship-a", "ship-b"'); + expect(warning).toContain("http://localhost:4800"); + expect(warning).toContain("set bridge.publicUrl"); + }); + + test("uses the singular for one remote ship", () => { + const config = parseLaunchConfig({ + bridge: {}, + ships: { "ship-a": { source: "remote", url: "http://a:4700" } }, + }); + expect(publicUrlWarning(config)).toContain('remote ship "ship-a"'); + }); + + test("stays quiet once publicUrl is set", () => { + expect(publicUrlWarning(remoteConfig({ publicUrl: "http://control:4800" }))).toBeNull(); + }); + + test("stays quiet with only local ships", () => { + const config = parseLaunchConfig({ bridge: {}, ships: { "ship-a": {} } }); + expect(publicUrlWarning(config)).toBeNull(); + }); + + test("stays quiet with no bridge to reach", () => { + const config = parseLaunchConfig({ ships: { "ship-a": { source: "remote", url: "http://a:4700" } } }); + expect(publicUrlWarning(config)).toBeNull(); + }); +}); diff --git a/apps/docs/src/content/docs/guides/agent-integrations.md b/apps/docs/src/content/docs/guides/agent-integrations.md index a863826..d8ca8da 100644 --- a/apps/docs/src/content/docs/guides/agent-integrations.md +++ b/apps/docs/src/content/docs/guides/agent-integrations.md @@ -14,6 +14,17 @@ small **startup plugin** per provider detects a fleet workspace at session start and injects a mandatory "activate the fleet-agent skill" instruction. A ship installs both into the agent's home directory every time it starts. +:::note +This page is about the integrations **Fleet owns**: `fleet-agent` and its startup +plugins ship inside the `fleet` binary, are installed at ship startup, are +repaired with `fleet ship plugin`, and are the same on every fleet. + +Skills and plugins **you** write are a different mechanism — put them in [the +armory](/guides/the-armory/), a directory on the bridge that every ship pulls and +installs. Fleet never writes to the armory, and `fleet ship plugin doctor` does +not report on it; use `fleet client armory ships` for that. +::: + ## What happens at ship startup `fleet ship` installs the skill and the plugins before it begins serving. The @@ -168,6 +179,8 @@ overridden. ## Related +- [The Armory](/guides/the-armory/) — distributing skills, plugins, and dotfiles + you write to every ship. - [Running agents](/guides/running-agents/) — the workflow the skill teaches. - [Agents](/concepts/agents/) — the status model. - [CLI reference](/reference/cli/) — `fleet ship plugin` flags. diff --git a/apps/docs/src/content/docs/guides/multi-host.md b/apps/docs/src/content/docs/guides/multi-host.md index 2375ff4..a1049be 100644 --- a/apps/docs/src/content/docs/guides/multi-host.md +++ b/apps/docs/src/content/docs/guides/multi-host.md @@ -46,6 +46,7 @@ bridge: dataDirectory: ./.fleet/bridge port: 4800 name: control + publicUrl: http://control:4800 gui: port: 3000 @@ -69,6 +70,42 @@ ships](/guides/managing-ships/). Either way the bridge discovers each ship's name from its first event sync, and persists the roster so it reconnects to all of them on restart. +## Tell the ships where the bridge is + +Registration is one direction only — the bridge learns each ship's URL. For the +[armory](/guides/the-armory/), traffic goes the other way: the bridge hands each +ship a URL to pull from. That URL is `bridge.publicUrl` (or +`fleet bridge --public-url`), and it defaults to `http://localhost:`. + +On a single host that default is correct. **On a multi-host fleet it is always +wrong**, because on `build-box.internal`, `localhost` is `build-box.internal`. +Set it to an address your ships can reach: + +```yaml +bridge: + port: 4800 + publicUrl: http://control:4800 +``` + +```bash +fleet bridge --port 4800 --public-url http://control:4800 +``` + +Nothing else depends on it, which is exactly why it is easy to miss: ships +register, workspaces work, terminals work, and only the armory silently never +arrives. `fleet launch` warns when a config declares `source: remote` ships and +no `publicUrl`, but it is a warning, not an error — a remote ship reached through +a tunnel on this host is legitimate. + +To confirm it took, ask the bridge what each ship has applied: + +```bash +fleet client --bridge-url http://control:4800 armory ships +``` + +A ship stuck at `never` or `error` after a change to the armory is the symptom of +an unreachable `publicUrl`. + ## The `/` uniqueness constraint Within a ship, `(repo, name)` identifies a workspace. **Across a fleet, @@ -153,11 +190,14 @@ host. ## Reachability, end to end -Three hops have to work: +Four hops have to work: 1. Browser → GUI server. The GUI serves the app and proxies `/bridge/*`. 2. GUI server → bridge, over the `bridgeUrl` you configured. 3. Bridge → each ship, over the URL you registered, for both HTTP and WebSockets. +4. Each ship → bridge, over `publicUrl`, to pull the armory. This is the only hop + that runs ship-to-bridge, and the only one a firewall rule allowing just + "bridge to ships" will block. A terminal in the browser is piped browser → GUI → bridge → ship's tmux session, so the WebSocket path must be open at every hop, not just HTTP. @@ -185,3 +225,4 @@ definition. See [Running agents](/guides/running-agents/). - [The bridge](/concepts/bridge/) — the ownership index and routing. - [Managing ships](/guides/managing-ships/) — registration and offline behaviour in detail. +- [The Armory](/guides/the-armory/) — the one thing that needs `publicUrl`. diff --git a/apps/docs/src/content/docs/guides/the-armory.md b/apps/docs/src/content/docs/guides/the-armory.md new file mode 100644 index 0000000..71ec477 --- /dev/null +++ b/apps/docs/src/content/docs/guides/the-armory.md @@ -0,0 +1,323 @@ +--- +title: The Armory +description: One bridge-owned directory of skills, plugins, and dotfiles, installed on every ship in the fleet. +sidebar: + order: 9 +--- + +Every agent on every ship wants the same things: the skills you have written, the +provider config you have settled on, the dotfiles you cannot work without. The +Armory is where you keep them once. It is a single directory on the bridge host; +every ship pulls it and installs it, and re-installs it whenever it changes. + +It is deliberately one-way. You edit the armory on the bridge — by hand, or by +pointing a git checkout at it — and ships converge on what you wrote. Nothing in +Fleet writes to it, and there is no upload, edit, or delete affordance in the CLI +or the GUI. + +## Layout + +The armory lives at `/armory/`. With the default +`fleet bridge -d ./.fleet-bridge`, that is `./.fleet-bridge/armory/`. + +``` +armory/ + skills/ + reviewer/ + SKILL.md + checklist.md + plugins/ + claude-code/ + commands/ + lint.md + opencode/ + plugins/ + notify.js + dotfiles/ + gitconfig + nvim/ + init.lua + dotfile-map.json +``` + +Only `skills/`, `plugins/`, and `dotfiles/` are scanned. Anything else at the +armory root is ignored, except `dotfile-map.json`, which is read as +configuration rather than content. + +A ship's copy is not the armory — it is a cache at +`~/.config/autosmith/fleet-ship/armory/files/`, mirroring the tree above. Do not +edit it; the next sync overwrites it. + +## How a change reaches a ship + +1. The bridge watches the armory directory recursively and collapses a burst of + writes (a `git pull`, say) into one event. +2. It re-scans the tree into a manifest and pushes + `POST /armory/sync` to every online ship, carrying its own URL and the new + revision. +3. Each ship pulls the manifest from `GET /armory`, then fetches only the files + whose hash it does not already hold, verifying every one against the + manifest's `sha256` before it lands. +4. It installs the cache into place and records what it did. + +The revision is a content address: it changes when and only when a file's +contents, mode, or path changes, or the dotfile map changes. Two scans of an +unchanged armory produce the same revision, so a re-push costs a ship nothing. + +The bridge also pushes on **ship registration** and on **every arrival at +online**, so a ship that was down, restarted, or newly added catches up on its +own. There is no polling and no schedule — if nothing changes, nothing happens. + +A pull is all-or-nothing. One file that fails its hash check, or that the bridge +will not serve, fails the whole sync: the ship keeps the revision it already had +and records the reason rather than applying half an armory. + +## Which bridge a ship pulls from + +The push names the bridge to pull from, and what is pulled gets installed into +the agent config of whoever runs the ship. So a ship accepts pushes from **one** +bridge and refuses the rest with `403`, having fetched nothing: + +- `fleet ship --bridge-url ` pins it explicitly. `fleet launch` sets this + for every ship it spawns, from `bridge.publicUrl`. +- Unset, the ship pins whichever bridge pushes to it first and holds that from + then on. The pin lives in + `~/.config/autosmith/fleet-ship/armory/state.json`. + +The URL is compared as an origin — scheme, host, port, and path — so +`http://Bridge:4800/` and `http://bridge:4800` are the same bridge. Query strings +and case are ignored; a different port or host is a different bridge. + +:::caution +This is defence in depth, **not** authentication. A ship's API has no +authentication at all: anyone who can reach its port can start workspaces and run +commands on it. Pinning only removes the armory's own contribution to that — an +unpinned ship would let any caller choose the server it installs skills, plugins, +and dotfiles from. It does not stop a caller from making a ship re-pull from its +real bridge, which changes nothing. **Do not expose a ship's port to a network you +do not trust**; put ships on a private network or behind a tunnel. +::: + +## Skills fan out to every provider + +`skills//` is a standard skill directory — a `SKILL.md` plus whatever else +it needs. Each one is installed into every agent provider whose config directory +already exists on that ship: + +| Provider | Skills directory | +| --- | --- | +| `claude-code` | `~/.claude/skills//` | +| `opencode` | `~/.config/opencode/skills//` | +| `copilot` | `~/.copilot/skills//` | +| `codex` | `~/.codex/skills//` and `~/.agents/skills//` | + +So `skills/reviewer/SKILL.md` on a host with all four providers becomes five +files, `reviewer/SKILL.md` under each root above. Fleet never creates a config +root — a provider you have not installed is skipped, not conjured. + +Codex gets the skill twice: in its own directory and in the shared `~/.agents` +location. That mirrors what a ship already does for its built-in `fleet-agent` +skill. + +## Plugins pass straight through + +Skills fan out because every provider understands the same skill format. Nothing +else does, so `plugins/` does not try. The path after the provider name is used +verbatim, relative to that provider's **config root**: + +| Armory path | Lands at | +| --- | --- | +| `plugins/claude-code/commands/lint.md` | `~/.claude/commands/lint.md` | +| `plugins/opencode/plugins/notify.js` | `~/.config/opencode/plugins/notify.js` | +| `plugins/codex/config.toml` | `~/.codex/config.toml` | + +You control the layout, which means you can install anything a provider reads, +not just the shapes Fleet knows about. It also means the path is your +responsibility: Fleet does not validate that `~/.claude/commands/` is a thing +claude-code reads. + +The first segment must name a provider — `claude-code`, `opencode`, `copilot`, +or `codex`. Anything else is skipped with a warning: + +``` +ignored armory plugins/vscode: not a directory named after a known provider (claude-code, opencode, copilot, codex) +``` + +A *known* provider that simply isn't installed on that host is skipped silently. +That is the normal case, not a problem: one armory serves hosts with different +tools on them. + +## Dotfiles are symlinked + +`dotfiles/` holds files and directories; `dotfile-map.json` says where each one +goes. Sources are relative to `dotfiles/`; destinations are `~/`-rooted or +absolute: + +```json +{ + "gitconfig": "~/.gitconfig", + "nvim": "~/.config/nvim", + "tmux.conf": "~/.tmux.conf" +} +``` + +Each mapping becomes a **symlink** at the destination pointing into the ship's +armory cache. Nothing is copied. A directory source is one symlink to the whole +directory — `nvim` above produces a single `~/.config/nvim` link, not a file per +entry — so adding a file to `dotfiles/nvim/` on the bridge makes it visible on +every ship without re-linking anything. + +Symlinks are why edits propagate at all. The trade-off is that a tool which +rewrites its config in place is writing into the ship's cache, and the next sync +will overwrite it. + +## Conflicts + +A destination that already holds a real file, a real directory, or a symlink +pointing anywhere other than the armory cache is a **conflict**. Fleet leaves it +exactly as it is and reports the path: + +``` +orca: + conflict: /home/you/.vimrc +``` + +The same rule covers skills and plugins: a file Fleet does not own, or one it +owned and you have since edited, is preserved and reported rather than replaced. + +The ship also says so on its own console at startup, once per conflicting path: + +``` +Fleet startup preserved a conflicting dotfile: /home/you/.vimrc. Move or delete it to let the armory's symlink take that path on the next sync or ship restart. +``` + +A symlink that already points *into* the cache is not a conflict — Fleet made +it, so it is re-pointed without ceremony when the source moves. + +:::caution +There is currently no way to force past an armory conflict. The installer has a +`force` option internally, but nothing user-facing sets it, and no CLI flag +exposes it. To resolve one, **move or delete** what is at the destination on that +ship. The next sync — or the ship's next restart, which re-applies the cache +without waiting for the bridge — links or writes over the now-empty path. + +Note that `fleet ship plugin install --force` is a different subsystem — it +covers the built-in `fleet-agent` skill and startup plugins, not the armory. +::: + +## Removal never clobbers your work + +Delete something from the armory and the ships uninstall it — but only where +Fleet can still prove the file is the one it wrote. + +For skills and plugins, proof is a content hash: Fleet records each installed +path's `sha256` and mode in `~/.config/autosmith/fleet-ship/managed-files-v1.json` +and re-checks both immediately before unlinking. Edit an installed file and it no +longer matches, so it stays: + +``` +warning: left /home/you/.claude/skills/reviewer/SKILL.md in place: it no longer matches what Fleet installed there +``` + +For dotfiles, proof is the link itself: the target must still be a symlink +pointing into the ship's armory cache. A target you have replaced with a real +file, or re-pointed elsewhere, is left alone and reported the same way. + +Empty directories left behind by a removal are pruned, but never above the +provider's own root. + +## Check what happened + +From the CLI, against the bridge: + +```bash +fleet client armory ls +fleet client armory cat skills/reviewer/SKILL.md +fleet client armory ships +``` + +`ls` lists what the bridge holds and the revision it is serving; `cat` prints one +file; `ships` is the one that answers "did it land?": + +``` +SHIP STATUS REVISION SYNCED STATE +orca online 59a0c6b293b4 2026-07-27T14:51:20.318Z in sync + +orca: + conflict: /home/you/.vimrc + warning: skipped dotfile bashrc: destination "/etc/bashrc" is outside /home/you +``` + +`STATE` is `in sync` when the ship holds the bridge's current revision, `behind` +when it holds an older one, `never` when it has never synced, `error` when its +last attempt failed, and `unknown` when the bridge could not reach it at all. +`error` outranks the revision comparison: a ship whose sync failed is stuck on a +revision it could not replace, and that is more useful to know than "behind". + +The same view is in the GUI's [Armory page](/guides/web-gui/), which adds a file +viewer and the dotfile map. See the [CLI reference](/reference/cli/) for the full +flag list. + +## Troubleshooting + +**Nothing syncs at all, and no ship reports an error.** The bridge tells each +ship where to pull from, using `bridge.publicUrl` (or +`fleet bridge --public-url`). Unset, it defaults to `http://localhost:` — +which on another host means that host. Set it to a URL your ships can reach. See +[multi-host](/guides/multi-host/). + +**`fleet client armory ls` returns a 400 naming `dotfile-map.json`.** A malformed +map fails the whole manifest, so nothing is served and nothing is pushed — ships +keep the last good revision. Every bad entry is listed at once, keyed by source: + +``` +fleet: request failed (400): invalid /srv/.fleet-bridge/armory/dotfile-map.json: + "vimrc": destination "relative/path" must start with "~/" or be absolute + "../evil": source "../evil" must be a relative path under dotfiles/ with no "..", "." or "\" segments +``` + +Sources must be relative paths under `dotfiles/` with no `.` or `..` segments; +destinations must start with `~/` or be absolute. A missing `dotfile-map.json` is +not an error — it means nothing is linked. + +**A dotfile is reported as skipped rather than linked.** Destinations are +confined to the ship's home directory: + +``` +warning: skipped dotfile bashrc: destination "/etc/bashrc" is outside /home/you +``` + +The mapping is dropped, not attempted. Use a destination under the ship user's +home. + +**A ship answers the push with `403` and never syncs.** It is pinned to a +different bridge, so it refused the push without fetching anything: + +``` +fleet-bridge: could not push the armory to ship "orca": armory push refused: this ship is pinned to bridge http://10.0.0.2:4800 but the push named http://10.0.0.9:4800; the pin is this ship's configured --bridge-url +``` + +The message names both URLs and where the pin came from. If the push is the +legitimate one, the two are out of step — usually `bridge.publicUrl` (or +`fleet bridge --public-url`) changed after the ship was pinned. Fix it by making +them agree: restart the ship with a matching `--bridge-url`, or, for a ship +pinned by first use rather than configuration, delete +`~/.config/autosmith/fleet-ship/armory/state.json` on that ship and let the next +push re-pin it. If the push is *not* one you sent, something else on the network +is pushing at your ships; see the caution above. + +**A large file breaks the sync.** The bridge refuses to serve any single file +over 10 MiB. It still appears in the manifest, but fetching it answers `413`, +which fails that ship's whole sync and shows up as `error` in +`fleet client armory ships`. Keep binaries out of the armory. + +## Related + +- [Agent integrations](/guides/agent-integrations/) — the built-in `fleet-agent` + skill, which is Fleet's own and separate from anything you put here. +- [Running across several machines](/guides/multi-host/) — why a multi-host fleet + needs `bridge.publicUrl`. +- [Bridge API](/reference/bridge-api/) and [ship API](/reference/ship-api/) — the + routes behind all of this. + + diff --git a/apps/docs/src/content/docs/guides/web-gui.md b/apps/docs/src/content/docs/guides/web-gui.md index d3ec59b..113b1b1 100644 --- a/apps/docs/src/content/docs/guides/web-gui.md +++ b/apps/docs/src/content/docs/guides/web-gui.md @@ -92,6 +92,18 @@ resources. Offline ships show `offline` in place of the blurb. **New Ship** take just a URL; the bridge discovers the ship's name itself. See [Managing ships](/guides/managing-ships/). +### Armory + +Everything the bridge distributes to its ships, in three parts: a **SHIPS** table +giving each ship's applied revision, when it last synced, and whether it is in +sync, behind, or erroring, with any install conflicts and warnings beneath it; a +**FILES** browser grouped by section, where selecting a file opens its contents; +and the **DOTFILE MAP**, source next to destination. + +The page is read-only, like [`fleet client armory`](/reference/cli/) — armory +content is edited in the bridge's data directory on the bridge host. See [The +Armory](/guides/the-armory/). + ### Repo detail Every workspace for one repo, in a wide table: workspace, branch, ship, session diff --git a/apps/docs/src/content/docs/reference/bridge-api.md b/apps/docs/src/content/docs/reference/bridge-api.md index 83f5958..9a1817f 100644 --- a/apps/docs/src/content/docs/reference/bridge-api.md +++ b/apps/docs/src/content/docs/reference/bridge-api.md @@ -31,9 +31,12 @@ adds ship management, a repo registry, and an aggregate system-resources view. | `POST /workspaces/:repo/:name/agent/init` | **Not present.** | | `GET`/`POST /workspaces/:repo/:name/agent/status` | **Not present.** Agent status still reaches the bridge through each ship's `/events` stream, as the `agent` field on every workspace. | +| `POST /armory/sync` | **Not present.** The bridge is the pusher, not a target. | +| `GET /armory` | Same path, **different shape**: the bridge serves the armory manifest it owns; a ship serves the sync state it has applied. | + Bridge-only routes: `GET`/`POST /ships`, `DELETE /ships/:name`, `GET /ships/:ship/system-resources`, `GET`/`POST /repos`, -`DELETE /repos/:name`. +`DELETE /repos/:name`, `GET /armory/file`, `GET /armory/ships`. ## Routes at a glance @@ -47,6 +50,9 @@ Bridge-only routes: `GET`/`POST /ships`, `DELETE /ships/:name`, | GET | `/repos` | 200 | `Repo[]` | | POST | `/repos` | 201 | `Repo` | | DELETE | `/repos/:name` | 200 | `{ ok: true }` | +| GET | `/armory` | 200 | `ArmoryManifest` | +| GET | `/armory/file` | 200 | `ArmoryFile` | +| GET | `/armory/ships` | 200 | `ShipArmoryState[]` | | GET | `/workspaces` | 200 | `BridgeWorkspaceSummary[]` | | GET | `/workspaces/:repo/:name` | 200 | `BridgeWorkspaceStatus` | | GET | `/workspaces/:repo/:name/diff` | 200 | raw diff text | @@ -212,6 +218,113 @@ Responds `{ ok: true }`. Deleting a repo does not touch any workspace already cloned from it. +## Armory + +The read side of the [armory](/guides/the-armory/): the manifest of the bridge's +`/armory/` directory, the contents of any file it lists, and what +each ship has applied. Ships use the first two to pull; the third is for +operators. All three are read-only — armory content is edited on the bridge host, +never through the API. + +### `GET /armory` + +```ts +{ + revision: string; // lowercase hex sha256 of the whole armory + entries: { + path: string; // POSIX, armory-relative, e.g. "skills/reviewer/SKILL.md" + section: "skills" | "plugins" | "dotfiles"; + size: number; + sha256: string; // lowercase hex + mode: number; // normalized to 0o755 or 0o644 + }[]; + dotfileMap: Record; // dotfiles/-relative source → "~/"-rooted or absolute destination +} +``` + +`entries` is sorted by `path`. `revision` is a content address: it changes when +and only when a file's contents, mode, or path changes, or `dotfileMap` changes, +so a ship can compare revisions to decide whether to re-pull. + +A missing `armory/` directory is not an error — it yields an empty manifest. + +| Status | Cause | +| --- | --- | +| `400` | `invalid /dotfile-map.json:` followed by one indented line per offending entry — a malformed map fails the whole manifest. | + +### `GET /armory/file` + +| Query | Type | Required | Meaning | +| --- | --- | --- | --- | +| `path` | string | yes | An `entries[].path` from the manifest. | + +```ts +{ + path: string; + section: "skills" | "plugins" | "dotfiles"; + size: number; + sha256: string; + mode: number; + encoding: "utf8" | "base64"; + contents: string; +} +``` + +The facts repeat the manifest's so a caller can verify what it fetched without +holding the manifest. `encoding` is `utf8` when the bytes decode as text and +`base64` otherwise. + +| Status | Cause | +| --- | --- | +| `400` | An unsafe `path` (absolute, containing `..`, or with a `\` segment); an invalid `dotfile-map.json`. | +| `404` | `armory file not found: ` — not listed in the manifest, or gone since the scan. | +| `413` | `armory file too large ( bytes, limit 10485760): `. Oversized files are still listed in the manifest; only serving them is refused. | +| `422` | `path` query parameter missing. | + +### `GET /armory/ships` + +```ts +{ + ship: string; + status: "online" | "offline"; + state: { // null for an offline ship, or one whose call failed + revision: string | null; // applied revision; null until the first successful sync + bridgeUrl: string | null; + syncedAt: string | null; // ISO timestamp + fileCount: number; + install: { // null until an install has run + skillCount: number; + pluginCount: number; + dotfileCount: number; + removedCount: number; + conflicts: string[]; // destinations left alone + warnings: string[]; + installedAt: string | null; + } | null; + lastError: string | null; // cleared by the next success + } | null; +}[] +``` + +Always `200`. A `state` of `null` means the bridge could not ask — a single +unreachable ship never fails the aggregate, and is deliberately distinct from a +ship answering that it holds nothing. + +Counts are files, not skills or plugins: a skill is a directory and a plugin is +an arbitrary tree, so files are the only unit both share. + +### Pushing to ships + +There is no route that triggers a sync. The bridge pushes +[`POST /armory/sync`](/reference/ship-api/) to every online ship on three +occasions: when the armory directory changes on disk, when a ship registers, and +whenever a ship arrives at `online` (so a restarted or reconnected ship catches +up). Each push carries `{ bridgeUrl, revision }`, where `bridgeUrl` is the +bridge's `--public-url`, defaulting to `http://localhost:`. + +Pushes are fire-and-forget: a ship that fails is logged and skipped, never +retried inline, and a registration never waits on the armory. + ## Workspaces ### `GET /workspaces` diff --git a/apps/docs/src/content/docs/reference/cli.md b/apps/docs/src/content/docs/reference/cli.md index 990a749..0a6b037 100644 --- a/apps/docs/src/content/docs/reference/cli.md +++ b/apps/docs/src/content/docs/reference/cli.md @@ -48,7 +48,7 @@ Which endpoint a subcommand talks to is fixed per subcommand: | Talks to `--url` (ship) | Talks to `--bridge-url` (bridge) | | --- | --- | -| `ls` (without `--wide`), `status`, `create`, `branch`, `activate`, `deactivate`, `rm` | `ls --wide`, `ships …`, `repos …` | +| `ls` (without `--wide`), `status`, `create`, `branch`, `activate`, `deactivate`, `rm` | `ls --wide`, `ships …`, `repos …`, `armory …` | ### `fleet client ls` @@ -239,6 +239,98 @@ fleet client repos rm Prints `removed repo `. +### `fleet client armory` + +Read-only inspection of the [armory](/guides/the-armory/), always via the bridge +(`--bridge-url`). There is no command to add, change, or delete armory content — +it is edited in the bridge's data directory. + +#### `fleet client armory ls` + +```bash +fleet client armory ls [--json] [--section
] +``` + +| Option | Argument | Default | Meaning | +| --- | --- | --- | --- | +| `--json` | — | off | Print the manifest as JSON — revision, entries, and `dotfileMap`. | +| `--section` | `
` | none | Only files in one section: `skills`, `plugins`, or `dotfiles`. | + +Without `--json`, prints a header line +`revision ( file(s))` followed by a table with columns +`SECTION PATH SIZE MODE`. `PATH` keeps its section prefix, so a row can be +pasted straight into `armory cat`; `SIZE` is in bytes and `MODE` is octal +(`0644` or `0755`). + +`--section` filters both the table and the JSON `entries`. An unrecognized value +prints +`fleet: unknown section ""; expected one of: skills, plugins, dotfiles` +and exits 1. + +With no matching files and no `--json`, prints `no armory files`. + +#### `fleet client armory cat` + +```bash +fleet client armory cat +``` + +| Argument | Meaning | +| --- | --- | +| `` | Armory-relative path as shown by `armory ls`, e.g. `skills/reviewer/SKILL.md`. | + +Takes no options. Writes the file's contents to stdout with no trailing newline +added, so `fleet client armory cat > file` reproduces it exactly. + +A binary file is refused rather than printed, because a terminal (or a redirect) +would capture mangled bytes without saying so: + +``` +fleet: dotfiles/blob.bin is binary (12 bytes, sha256 2a44e2e6…); not writing it to stdout +``` + +That goes to stderr and exits 1. An unknown path is a `404` from the bridge: +`fleet: request failed (404): armory file not found: `. + +#### `fleet client armory ships` + +```bash +fleet client armory ships [--json] +``` + +| Option | Default | Meaning | +| --- | --- | --- | +| `--json` | off | Print the raw `ShipArmoryState[]` array instead of a table. | + +Table columns are `SHIP STATUS REVISION SYNCED STATE`, where `STATUS` is the +ship's connection state (`online`/`offline`), `REVISION` is the ship's applied +revision abbreviated to 12 characters, and `SYNCED` is an ISO-8601 timestamp. +A value the ship has not reported renders as `-`. + +`STATE` compares the ship against the bridge's current revision: + +| State | Meaning | +| --- | --- | +| `in sync` | The ship holds the bridge's current revision. | +| `behind` | The ship holds an older revision. | +| `never` | The ship has never applied a revision. | +| `error` | The ship's last sync or install failed. | +| `unknown` | The bridge could not reach the ship. | + +`error` takes precedence over the revision comparison: a ship whose sync failed +is stuck on a revision it could not replace. + +After the table, each ship's `lastError`, install conflicts, and install warnings +are printed under its name: + +``` +orca: + conflict: /home/you/.vimrc + warning: skipped dotfile bashrc: destination "/etc/bashrc" is outside /home/you +``` + +With no ships registered and no `--json`, prints `no ships`. + ### `fleet client serve` ```bash @@ -278,9 +370,19 @@ and writes `atlas.json` into the fleet directory root. | `-p, --port` | `` | `4700` | Port the HTTP + WebSocket API listens on. Must parse as an integer. | | `-n, --name` | `` | `ship` | Human-facing name of this ship. Must be a valid [fleet identifier](/reference/protocol/). | | `-f, --fleet-directory` | `` | `./fleet` | Directory holding all workspaces, laid out as `//`. Resolved to an absolute path. | +| `--bridge-url` | `` | none — the first bridge to push wins | The only bridge whose armory pushes this ship accepts. Must be a URL. | + +`--bridge-url` pins the ship: a `POST /armory/sync` naming any other bridge is +refused with `403` and nothing is fetched. Set it to the same URL the bridge +pushes with — its `--public-url` / `bridge.publicUrl`, or `http://localhost:` +when that is unset. Comparison is on scheme, host, port, and path, so a trailing +slash or a difference in case does not matter. Left unset, the ship pins whichever +bridge pushes to it first. `fleet launch` sets this for every ship it spawns. See +[the Armory](/guides/the-armory/). A non-integer `--port` is rejected by Commander with `must be an integer`. Any -other startup failure prints `fleet-ship: ` and exits 1. +other startup failure prints `fleet-ship: ` and exits 1, which includes +a `--bridge-url` that is not a URL. On success it prints `fleet-ship "" listening on http://localhost:`. @@ -344,7 +446,8 @@ loads the persisted ship roster, connects to every ship, and serves the API. | --- | --- | --- | --- | | `-p, --port` | `` | `4800` | Port the HTTP + WebSocket API listens on. Must parse as an integer. | | `-n, --name` | `` | `bridge` | Human-facing name of this bridge. Any non-empty string. | -| `-d, --data-directory` | `` | `./.fleet-bridge` | Directory the bridge persists `ships.json` and `repos.json` to. Resolved to an absolute path. | +| `-d, --data-directory` | `` | `./.fleet-bridge` | Directory the bridge persists `ships.json` and `repos.json` to, and holds the `armory/` it distributes. Resolved to an absolute path. | +| `--public-url` | `` | `http://localhost:` | URL ships should use to reach this bridge. Handed to each ship so it can pull the [armory](/guides/the-armory/), so it must resolve from the ships' hosts. | If two reachable ships hold the same `/` at startup, the bridge prints the conflicting keys and exits 1. Any other startup failure prints diff --git a/apps/docs/src/content/docs/reference/fleet-config.md b/apps/docs/src/content/docs/reference/fleet-config.md index 34b1f4f..d4ad567 100644 --- a/apps/docs/src/content/docs/reference/fleet-config.md +++ b/apps/docs/src/content/docs/reference/fleet-config.md @@ -26,6 +26,7 @@ bridge: dataDirectory: ./.fleet/bridge port: 4800 name: my-fleet-bridge + # publicUrl: http://this-host:4800 # how ships reach this bridge; required if any ship is on another host # The web gui. Proxies to the bridge above by default. gui: @@ -65,9 +66,10 @@ Every field has a default, so `bridge: {}` is valid. | Field | Type | Required | Default | Meaning | | --- | --- | --- | --- | --- | -| `dataDirectory` | string (non-empty) | no | `./.fleet/bridge` | Where the bridge persists `ships.json` and `repos.json`. Resolved to an absolute path. | +| `dataDirectory` | string (non-empty) | no | `./.fleet/bridge` | Where the bridge persists `ships.json` and `repos.json`, and where its `armory/` directory lives. Resolved to an absolute path. | | `port` | integer | no | `4800` | Port the bridge's HTTP + WebSocket API listens on. | | `name` | string (non-empty) | no | `bridge` | Human-facing name of the bridge. | +| `publicUrl` | string (non-empty) | no | `http://localhost:` | URL **ships** use to reach this bridge. | :::note The `dataDirectory` default here (`./.fleet/bridge`) is *not* the same as the @@ -75,6 +77,43 @@ The `dataDirectory` default here (`./.fleet/bridge`) is *not* the same as the separate code paths. ::: +### `publicUrl` + +`publicUrl` is handed to each ship so it can pull the +[armory](/guides/the-armory/), so it has to resolve **from the ships' hosts**, +not from the machine running the launch. The default, +`http://localhost:`, is correct for a single-host fleet and wrong +the moment a `source: remote` ship is on another machine — there, `localhost` is +that machine. + +Getting it wrong fails quietly: the ship registers, its workspaces work, and only +the armory never arrives. So `fleet launch` warns when a config declares one or +more `source: remote` ships and sets no `publicUrl`: + +``` +fleet launch: bridge.publicUrl is not set, so remote ships "build-box", "gpu-box" will be told this bridge is at http://localhost:4800, which on their hosts is themselves; set bridge.publicUrl to a URL those hosts can reach +``` + +It is a warning on stderr, not an error — a `source: remote` ship can legitimately +be on this same host behind a tunnel or a published container port, where +`localhost` still resolves. Local ships never trigger it. + +The value is used verbatim; it is validated as a non-empty string, not parsed or +normalized like `gui.bridgeUrl`, so write a full URL with its scheme. The +equivalent flag on a standalone bridge is `fleet bridge --public-url`. + +It is also what every `source: local` ship is pinned to (`fleet ship +--bridge-url`), so those ships refuse an armory push from anywhere else. A value +that is not an http(s) URL cannot be a pin; rather than fail the launch, it warns +and starts the ships unpinned: + +``` +fleet launch: bridge.publicUrl "bridge:4800" is not an http(s) URL, so ships are started unpinned and will accept the first armory push they receive +``` + +Ships registered with `source: remote` are pinned by whatever they were started +with — `fleet launch` does not configure a ship it did not spawn. + ## `gui` Both fields are optional, so `gui: {}` is valid — as long as a bridge exists to @@ -151,9 +190,10 @@ duplicate-port check, then the gui/bridge check. 1. Loads and normalizes the config. 2. If `bridge` is present, starts the bridge and keeps its manager. -3. For each ship in map order: starts it if `source: local`, then registers it - with the bridge at `http://localhost:` (local) or its `url` (remote), - printing `registered ship "" () with the bridge`. +3. For each ship in map order: starts it if `source: local` — pinned to the + launched bridge's `publicUrl` — then registers it with the bridge at + `http://localhost:` (local) or its `url` (remote), printing + `registered ship "" () with the bridge`. 4. If `gui` is present, serves the GUI against `gui.bridgeUrl` or the local bridge. @@ -187,6 +227,7 @@ Two local ships plus one already-running remote ship: ```yaml bridge: port: 4800 + publicUrl: http://10.0.0.2:4800 gui: port: 3000 ships: @@ -200,6 +241,10 @@ ships: url: http://10.0.0.7:4700 ``` +`publicUrl` is set here because `builder` is on another host: without it, that +ship would be told to pull the armory from `http://localhost:4800`, which on +`10.0.0.7` is `10.0.0.7`. + A GUI-only process pointed at a bridge on another host: ```yaml diff --git a/apps/docs/src/content/docs/reference/ship-api.md b/apps/docs/src/content/docs/reference/ship-api.md index 5417d68..b6a5ccf 100644 --- a/apps/docs/src/content/docs/reference/ship-api.md +++ b/apps/docs/src/content/docs/reference/ship-api.md @@ -7,9 +7,9 @@ sidebar: A ship serves an [Elysia](https://elysiajs.com) app on the port given by `fleet ship --port` (default `4700`). There is no authentication and no route -prefix: paths are absolute from the origin. The app is composed of three -plugins — workspaces (including the terminal WebSocket), events, and system -resources. +prefix: paths are absolute from the origin. The app is composed of four +plugins — workspaces (including the terminal WebSocket), events, system +resources, and the armory. ## Routes at a glance @@ -27,6 +27,8 @@ resources. | GET | `/workspaces/:repo/:name/agent/status` | 200 | `AgentStatus` or `null` | | POST | `/workspaces/:repo/:name/agent/status` | 200 | `AgentStatus` | | GET | `/system-resources` | 200 | `SystemResources` | +| POST | `/armory/sync` | 200 | `ArmorySyncState` | +| GET | `/armory` | 200 | `ArmorySyncState` | | WS | `/workspaces/:repo/:name/terminal` | — | webterm protocol | | WS | `/events` | — | `FleetEvent` stream | @@ -266,6 +268,71 @@ sampled over a 100 ms window, so this route takes at least that long to respond. This route has no error mapping — it always returns `200` on a healthy host. +## `POST /armory/sync` + +The bridge's push telling this ship to re-pull and re-install the +[armory](/guides/the-armory/). A ship holds no bridge address of its own, so +`bridgeUrl` is how it learns where to pull from — but only the bridge it is +pinned to: `fleet ship --bridge-url`, or, unset, whichever bridge pushed to it +first. Any other origin is refused with `403` before anything is fetched (see +[the Armory](/guides/the-armory/)). + +```ts +{ bridgeUrl: string; revision: string } // request +``` + +`revision` is a hint that something changed, not an instruction: the ship applies +whatever revision the manifest it then fetches reports, because the armory may +change again between the push and the fetch. + +The ship pulls `GET /armory` and `GET /armory/file` from `bridgeUrl`, verifying +every file's `sha256` against the manifest before writing it into +`~/.config/autosmith/fleet-ship/armory/files/`, then installs. Responds with the +resulting `ArmorySyncState` (below). The call is synchronous — it returns after +the install, not when the pull is queued. + +A single bad file fails the whole sync: the ship keeps the revision it already +had and records `lastError`, rather than recording a revision that promises an +armory it only half applied. + +| Status | Cause | +| --- | --- | +| `400` | `bridge url must be http(s): `; `invalid bridge url: `. | +| `403` | `armory push refused: this ship is pinned to bridge but the push named ` — nothing was fetched and the applied state is untouched. | +| `422` | `bridgeUrl` or `revision` missing. | +| `500` | `armory install failed: ` — the pull succeeded, the install did not. | +| `502` | The pull failed: `bridge answered for the armory file `, a manifest or file that did not validate, an unsafe path, or a file whose bytes did not match the manifest hash. | + +## `GET /armory` + +What this ship has pulled and applied. Read-only; it triggers nothing. + +```ts +{ + revision: string | null; // applied revision; null until the first successful sync + bridgeUrl: string | null; // the bridge it last pulled from + syncedAt: string | null; // ISO timestamp of the last successful sync + fileCount: number; + install: { // the last install applied from the cache; null until one has run + skillCount: number; + pluginCount: number; + dotfileCount: number; // symlinks in place; a conflicted or skipped mapping is not one + removedCount: number; // files uninstalled because the armory no longer carries them + conflicts: string[]; // destinations left alone because something unmanaged was there + warnings: string[]; + installedAt: string | null; + } | null; + lastError: string | null; // most recent failed sync or install, cleared by the next success +} +``` + +A ship that has never synced answers with `revision`, `bridgeUrl`, `syncedAt`, +`install`, and `lastError` all `null`, and `fileCount` `0` — a cold cache is not +an error. + +The bridge aggregates this across the fleet as +[`GET /armory/ships`](/reference/bridge-api/). + ## `WS /events` A read-only broadcast of workspace and agent state changes. Anything the client diff --git a/packages/fleet-bridge/src/api/armory.ts b/packages/fleet-bridge/src/api/armory.ts new file mode 100644 index 0000000..9e356f5 --- /dev/null +++ b/packages/fleet-bridge/src/api/armory.ts @@ -0,0 +1,38 @@ +/** + * api/armory.ts — the read side of the Armory: the manifest of the bridge's + * `armory/` directory, the contents of any file it lists, and what each ship has + * applied. Ships poll the first two to decide whether to re-pull; the last is for + * operators watching the fleet converge. One Elysia chain so route types stay + * inferable for Eden. + */ + +import { Elysia, t } from "elysia"; +import type { FleetManager } from "../fleet-manager"; +import { mapError } from "./http"; + +export function armoryPlugin(manager: FleetManager) { + return new Elysia({ name: "bridge-armory" }) + .get("/armory", async ({ set }) => { + try { + return await manager.armoryManifest(); + } catch (err) { + const mapped = mapError(err); + set.status = mapped.status; + return mapped.body; + } + }) + .get( + "/armory/file", + async ({ query, set }) => { + try { + return await manager.armoryFile(query.path); + } catch (err) { + const mapped = mapError(err); + set.status = mapped.status; + return mapped.body; + } + }, + { query: t.Object({ path: t.String() }) }, + ) + .get("/armory/ships", () => manager.armoryShipStates()); +} diff --git a/packages/fleet-bridge/src/api/index.ts b/packages/fleet-bridge/src/api/index.ts index f03bde2..aff7253 100644 --- a/packages/fleet-bridge/src/api/index.ts +++ b/packages/fleet-bridge/src/api/index.ts @@ -1,7 +1,7 @@ /** - * api/index.ts — composes the bridge's Elysia app from its two plugins. + * api/index.ts — composes the bridge's Elysia app from its route plugins. * - * Both plugins are single Elysia chains, so `.use()` merges their route types + * Each plugin is a single Elysia chain, so `.use()` merges its route types * into the parent and `App = ReturnType` carries the full * merged surface for a future Eden `treaty` client. */ @@ -14,6 +14,7 @@ import { workspacesPlugin } from "./workspaces"; import { shipsPlugin } from "./ships"; import { systemResourcesPlugin } from "./system-resources"; import { reposPlugin } from "./repos"; +import { armoryPlugin } from "./armory"; import { eventsPlugin } from "./events"; import { Logestic } from "logestic"; @@ -24,6 +25,7 @@ export function createApp(manager: FleetManager, _config: BridgeConfig) { .use(shipsPlugin(manager)) .use(systemResourcesPlugin(manager)) .use(reposPlugin(manager)) + .use(armoryPlugin(manager)) .use(eventsPlugin(manager)); } diff --git a/packages/fleet-bridge/src/armory/armory-service.ts b/packages/fleet-bridge/src/armory/armory-service.ts new file mode 100644 index 0000000..b4c8e32 --- /dev/null +++ b/packages/fleet-bridge/src/armory/armory-service.ts @@ -0,0 +1,332 @@ +/** + * armory/armory-service.ts — scans `/armory` into a content-addressed + * `ArmoryManifest` and serves individual files out of it. + * + * Read-only and human-authored: the directory is hand-edited or git-synced, so the + * scan is defensive rather than trusting. Symlinks at or below the section level + * are skipped outright (never followed, never listed) — including a section + * directory that is itself a symlink — because such a link would let a manifest + * consumer pull a file from anywhere on the bridge host, and the rest of this + * codebase refuses symlinks for the same reason. The one exception is the armory + * root, which the operator may point wherever they like; see `scan`. + * + * The manifest gates which paths `readFile` will serve, but it does not by itself + * confine reads: it is cached, so it describes the tree as of the last scan, and a + * directory that was real then may be a symlink now. `readFile` therefore + * re-resolves the file against the armory root before reading a byte. + * + * A scan is cached until `invalidate()` (a filesystem watcher calls it) and + * serialized through a promise queue, mirroring `store.ts`, so concurrent + * requests never walk the tree simultaneously. + */ + +import { lstat, readdir, realpath } from "node:fs/promises"; +import { join, relative, resolve, sep } from "node:path"; +import { + ARMORY_SECTIONS, + ArmoryManifestSchema, + DOTFILE_MAP_FILENAME, + DotfileMapSchema, + isSafeArmoryPath, + type ArmoryEntry, + type ArmoryFile, + type ArmoryManifest, + type ArmorySection, + type DotfileMap, +} from "fleet-protocol"; + +/** Ceiling on a single `readFile`; oversized files are still listed in the manifest. */ +export const MAX_ARMORY_FILE_BYTES = 10 * 1024 * 1024; + +/** Names never worth shipping, skipped wherever they appear in the tree. */ +const IGNORED_NAMES = new Set([".git", ".DS_Store"]); + +export class ArmoryPathError extends Error { + constructor(readonly path: string) { + super(`unsafe armory path: ${path}`); + this.name = "ArmoryPathError"; + } +} + +export class ArmoryNotFoundError extends Error { + constructor(readonly path: string) { + super(`armory file not found: ${path}`); + this.name = "ArmoryNotFoundError"; + } +} + +export class ArmoryTooLargeError extends Error { + constructor( + readonly path: string, + readonly size: number, + ) { + super(`armory file too large (${size} bytes, limit ${MAX_ARMORY_FILE_BYTES}): ${path}`); + this.name = "ArmoryTooLargeError"; + } +} + +/** + * A `dotfile-map.json` a human has to go and fix. `message` names the absolute + * file and every offending entry, because it travels to the HTTP client through + * `BridgeError` and is the whole of what a `curl` or CLI user gets to debug with. + */ +export class ArmoryMapError extends Error { + constructor( + /** Absolute path of the offending file — the operator may not know where `dataDirectory` resolved to. */ + readonly file: string, + readonly problems: string[], + ) { + super(`invalid ${file}:\n ${problems.join("\n ")}`); + this.name = "ArmoryMapError"; + } +} + +export class ArmoryService { + private cached: ArmoryManifest | undefined; + private queue: Promise = Promise.resolve(); + private readonly root: string; + + constructor(armoryDirectory: string) { + this.root = resolve(armoryDirectory); + } + + private serialized(operation: () => Promise | T): Promise { + const result = this.queue.then(operation, operation); + this.queue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + /** The current manifest, scanning only when the cache is cold. */ + async manifest(): Promise { + return this.serialized(async () => { + if (this.cached) return this.cached; + this.cached = await this.scan(); + return this.cached; + }); + } + + /** Drop the cached manifest so the next `manifest()` rescans. */ + invalidate(): void { + this.cached = undefined; + } + + /** + * One file's contents plus the facts the manifest reports for it. The size, + * hash, and mode come from the manifest rather than a fresh stat so a consumer + * that verifies against the manifest sees a consistent pair; an edit made + * between scans is picked up once the manifest is invalidated. + */ + async readFile(path: string): Promise { + if (!isSafeArmoryPath(path)) throw new ArmoryPathError(path); + + const manifest = await this.manifest(); + const entry = manifest.entries.find((candidate) => candidate.path === path); + if (!entry) throw new ArmoryNotFoundError(path); + if (entry.size > MAX_ARMORY_FILE_BYTES) throw new ArmoryTooLargeError(path, entry.size); + + const target = resolve(this.root, path); + if (!isStrictDescendant(this.root, target)) throw new ArmoryPathError(path); + + // A path the manifest lists can still vanish before it is read. + const vanished = (error: NodeJS.ErrnoException): never => { + if (error.code === "ENOENT" || error.code === "ENOTDIR") throw new ArmoryNotFoundError(path); + throw error; + }; + + const info = await lstat(target).catch(vanished); + if (!info.isFile()) throw new ArmoryNotFoundError(path); + if (info.size > MAX_ARMORY_FILE_BYTES) throw new ArmoryTooLargeError(path, info.size); + + // `lstat` refuses a symlink at the final component only; it follows every + // directory above it. The manifest cannot name a path through a symlinked + // directory, but it is cached, so `skills/pack` may have been a real + // directory at scan time and be a link to `/etc` by the time this runs. + // Resolving both ends is what actually confines the read to the armory — + // and it resolves the root's own symlink too, so a symlinked root still + // contains its own files. + const realRoot = await realpath(this.root).catch(vanished); + const realTarget = await realpath(target).catch(vanished); + if (!isStrictDescendant(realRoot, realTarget)) throw new ArmoryPathError(path); + + const bytes = new Uint8Array(await Bun.file(target).arrayBuffer()); + const text = decodeUtf8(bytes); + const { size, sha256, mode, section } = entry; + return text === undefined + ? { path, section, size, sha256, mode, encoding: "base64", contents: toBase64(bytes) } + : { path, section, size, sha256, mode, encoding: "utf8", contents: text }; + } + + // --- scanning ------------------------------------------------------------- + + /** + * The root and the sections are trusted differently, which is worth stating + * because the asymmetry looks arbitrary. The root is the operator's own choice + * about their own data directory — pointing it at a git checkout elsewhere on + * the host is a supported way to manage the armory — so a symlink there is + * allowed. A section is content, and content in that checkout is whatever a + * pull request put there; `skills -> ../../../.ssh` would put host secrets into + * a manifest that every ship in the fleet then installs. So a section is + * lstat'ed before it is walked: `readdir` follows a symlink handed to it as a + * path, where it never follows one it finds in a listing. + * + * A refused section contributes nothing and the other two still scan — the + * bridge has to keep serving `GET /armory` with the sections it can trust. + */ + private async scan(): Promise { + const entries: ArmoryEntry[] = []; + for (const section of ARMORY_SECTIONS) { + const directory = join(this.root, section); + if (await isSymlink(directory)) { + console.warn(`armory: skipping section "${section}" — ${directory} is a symlink`); + continue; + } + await this.walk(directory, section, section, entries); + } + entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const dotfileMap = await this.readDotfileMap(); + return ArmoryManifestSchema.parse({ revision: revisionOf(entries, dotfileMap), entries, dotfileMap }); + } + + private async walk( + directory: string, + section: ArmorySection, + prefix: string, + entries: ArmoryEntry[], + ): Promise { + let contents; + try { + contents = await readdir(directory, { withFileTypes: true }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return; + throw error; + } + + for (const item of contents) { + if (IGNORED_NAMES.has(item.name)) continue; + if (!isSafeArmoryPath(item.name)) continue; + // Neither followed nor listed: a manifest must never name a path that + // resolves outside the armory root on whichever host installs it. + if (item.isSymbolicLink()) continue; + + const path = `${prefix}/${item.name}`; + const target = join(directory, item.name); + if (item.isDirectory()) { + await this.walk(target, section, path, entries); + continue; + } + if (!item.isFile()) continue; + + const info = await lstat(target).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined; + throw error; + }); + // Re-checked against the dirent because the entry may have been replaced + // between `readdir` and here. + if (!info?.isFile()) continue; + + entries.push({ + path, + section, + size: info.size, + sha256: await hashFile(target), + mode: info.mode & 0o100 ? 0o755 : 0o644, + }); + } + } + + private async readDotfileMap(): Promise { + const target = join(this.root, DOTFILE_MAP_FILENAME); + try { + const info = await lstat(target); + if (!info.isFile()) throw new ArmoryMapError(target, ["not a regular file"]); + } catch (error) { + if (error instanceof ArmoryMapError) throw error; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw error; + } + + let raw: unknown; + try { + // `JSON.parse`, not `Bun.file().json()`: the engine's own syntax error names + // what it choked on, where Bun's wrapper flattens it to "Failed to parse JSON". + raw = JSON.parse(await Bun.file(target).text()); + } catch (error) { + throw new ArmoryMapError(target, [`not valid JSON: ${(error as Error).message}`]); + } + + const parsed = DotfileMapSchema.safeParse(raw); + if (!parsed.success) { + // Every entry is reported, not just the first: a human fixing the file by + // hand should not have to rescan it once per bad line. + const problems = parsed.error.issues.map((issue) => { + const key = issue.path.map(String).join("."); + return key === "" ? issue.message : `"${key}": ${issue.message}`; + }); + throw new ArmoryMapError(target, problems); + } + return parsed.data; + } +} + +/** + * The manifest's content address. Hashes only what a consumer installs — path, + * content hash, mode, and the dotfile map — so a rescan of unchanged content + * reproduces it exactly. The map's keys are sorted because JSON object order + * follows however the human wrote the file. + */ +function revisionOf(entries: ArmoryEntry[], dotfileMap: DotfileMap): string { + const body = JSON.stringify({ + entries: entries.map(({ path, sha256, mode }) => ({ path, sha256, mode })), + dotfileMap: Object.fromEntries( + Object.entries(dotfileMap).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ), + }); + return new Bun.CryptoHasher("sha256").update(body).digest("hex"); +} + +async function hashFile(target: string): Promise { + const hasher = new Bun.CryptoHasher("sha256"); + // Streamed rather than read whole: armory files run to megabytes. Driven + // through the reader instead of `for await`, because the DOM lib's + // `ReadableStream` declares no `[Symbol.asyncIterator]` and this module is + // compiled under that lib by the web client. + const reader = Bun.file(target).stream().getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + hasher.update(value); + } + return hasher.digest("hex"); +} + +/** False when `target` does not exist, so a missing section reads as an ordinary absent one. */ +async function isSymlink(target: string): Promise { + const info = await lstat(target).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ENOTDIR") return undefined; + throw error; + }); + return info?.isSymbolicLink() ?? false; +} + +function isStrictDescendant(root: string, target: string): boolean { + const within = relative(root, target); + return within !== "" && !within.startsWith("..") && !within.startsWith(sep) && !/^[A-Za-z]:/.test(within); +} + +/** The decoded text, or `undefined` when the bytes are not NUL-free valid UTF-8. */ +function decodeUtf8(bytes: Uint8Array): string | undefined { + if (bytes.includes(0)) return undefined; + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return undefined; + } +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64"); +} diff --git a/packages/fleet-bridge/src/armory/armory-watcher.ts b/packages/fleet-bridge/src/armory/armory-watcher.ts new file mode 100644 index 0000000..10e7c46 --- /dev/null +++ b/packages/fleet-bridge/src/armory/armory-watcher.ts @@ -0,0 +1,78 @@ +/** + * armory/armory-watcher.ts — notices that the bridge's `armory/` directory + * changed and says so, once. + * + * The armory is hand-edited or `git pull`ed, so a single logical change arrives + * as a burst of filesystem events; the debounce collapses that burst into one + * callback, which is what keeps a `git pull` from fanning a push per file out + * to every ship in the fleet. + * + * Nothing here may take the bridge down. The armory is optional, so a missing + * directory yields a silent no-op handle, and a watch error is logged and + * swallowed — a bridge that cannot watch its armory still routes workspaces. + */ + +import { watch } from "node:fs"; + +const DEFAULT_DEBOUNCE_MS = 250; + +export interface ArmoryWatcher { + close(): void; +} + +export function watchArmory( + directory: string, + onChange: () => void, + options?: { debounceMs?: number; watch?: typeof watch }, +): ArmoryWatcher { + const debounceMs = options?.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const watchImpl = options?.watch ?? watch; + + let timer: ReturnType | undefined; + let closed = false; + + let watcher: ReturnType; + try { + watcher = watchImpl(directory, { recursive: true }, () => { + if (closed) return; + clearTimeout(timer); + timer = setTimeout(() => { + try { + onChange(); + } catch (error) { + console.warn(`fleet-bridge: armory change handler failed: ${message(error)}`); + } + }, debounceMs); + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + console.warn(`fleet-bridge: not watching the armory at ${directory}: ${message(error)}`); + } + return { close: () => {} }; + } + + let reported = false; + watcher.on("error", (error) => { + // Once: an unwatchable directory can emit an error per event. + if (reported) return; + reported = true; + console.warn(`fleet-bridge: armory watch on ${directory} failed: ${message(error)}`); + }); + + return { + close() { + closed = true; + clearTimeout(timer); + try { + watcher.close(); + } catch { + // already closed + } + }, + }; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/fleet-bridge/src/config.ts b/packages/fleet-bridge/src/config.ts index 4814605..d827ef3 100644 --- a/packages/fleet-bridge/src/config.ts +++ b/packages/fleet-bridge/src/config.ts @@ -19,13 +19,29 @@ export const BridgeConfigSchema = z.object({ port: z.number().int(), /** Human-facing name of this bridge. */ name: z.string().min(1), + /** + * URL *ships* use to reach this bridge — it is handed to each ship so it can + * pull the armory, so it has to resolve from the ships' hosts, not just from + * the bridge's own machine. Optional here because a config assembled outside + * `resolveBridgeConfig` may omit it; `defaultPublicUrl` fills the gap. + */ + publicUrl: z.string().min(1).optional(), }); /** The bridge configuration, inferred from the schema. */ export type BridgeConfig = z.infer; +/** Where ships are told to reach a bridge that was not given a `publicUrl`. */ +export function defaultPublicUrl(port: number): string { + return `http://localhost:${port}`; +} + /** Validate a raw (flag-assembled) config, resolving `dataDirectory` to an absolute path. */ export function resolveBridgeConfig(raw: unknown): BridgeConfig { const config = BridgeConfigSchema.parse(raw); - return { ...config, dataDirectory: resolve(config.dataDirectory) }; + return { + ...config, + dataDirectory: resolve(config.dataDirectory), + publicUrl: config.publicUrl ?? defaultPublicUrl(config.port), + }; } diff --git a/packages/fleet-bridge/src/fleet-manager.ts b/packages/fleet-bridge/src/fleet-manager.ts index 782d6e6..cd3aba8 100644 --- a/packages/fleet-bridge/src/fleet-manager.ts +++ b/packages/fleet-bridge/src/fleet-manager.ts @@ -12,13 +12,18 @@ * routing and duplicate detection. */ +import { join } from "node:path"; import { + ARMORY_DIRECTORY, CreateRepoInputSchema, FleetIdentifierSchema, ShipSchema, WorkspaceRefsSchema, WorkspaceSummarySchema, WorkspaceStatusSchema, + type ArmoryFile, + type ArmoryManifest, + type ArmorySyncState, type CreateRepoInput, type FleetEvent, type Repo, @@ -30,16 +35,24 @@ import { import type { DiffOptions } from "git-bun"; import { TERMINAL_TAKEOVER_QUERY } from "webterm/protocol"; import { ShipConnection, toWsUrl, type ShipConnectionDeps } from "./ship-connection"; -import type { BridgeConfig } from "./config"; +import { defaultPublicUrl, type BridgeConfig } from "./config"; import { workspaceKey, type BridgeWorkspaceEvent, type BridgeWorkspaceStatus, type BridgeWorkspaceSummary, + type ShipArmoryState, type ShipInfo, type ShipSystemResources, } from "./types"; import { RepoAlreadyExistsError, Store } from "./store/store"; +import { + ArmoryMapError, + ArmoryNotFoundError, + ArmoryPathError, + ArmoryService, + ArmoryTooLargeError, +} from "./armory/armory-service"; import { providerFor, type CheckRun, @@ -101,16 +114,24 @@ export class FleetManager { private readonly store: Store; /** Builds a `RepoProvider` for a registered repo; overridable in tests. */ private readonly makeProvider: (repo: Repo) => RepoProvider; + /** The bridge-owned file factory served from `/armory`. */ + private readonly armory: ArmoryService; constructor( private readonly config: BridgeConfig, deps?: Partial, - opts?: { syncTimeoutMs?: number; store?: Store; providerFor?: (repo: Repo) => RepoProvider }, + opts?: { + syncTimeoutMs?: number; + store?: Store; + providerFor?: (repo: Repo) => RepoProvider; + armory?: ArmoryService; + }, ) { this.deps = deps; this.syncTimeoutMs = opts?.syncTimeoutMs ?? SYNC_TIMEOUT_MS; this.store = opts?.store ?? new Store(config.dataDirectory); this.makeProvider = opts?.providerFor ?? providerFor; + this.armory = opts?.armory ?? new ArmoryService(join(config.dataDirectory, ARMORY_DIRECTORY)); } /** @@ -232,6 +253,9 @@ export class FleetManager { await this.persist(); this.publishSnapshot(); + // Fire-and-forget: registering a ship must not fail, or wait, on the armory. + void this.pushArmoryTo(probe); + return { name, url, status: probe.status }; } @@ -407,6 +431,111 @@ export class FleetManager { throw new BridgeError("checks require a ref or pr", 400); } + // --- armory (bridge-owned file factory) ----------------------------------- + + /** `GET /armory` — the content-addressed manifest of `/armory`. */ + async armoryManifest(): Promise { + return this.mapArmoryErrors(() => this.armory.manifest()); + } + + /** `GET /armory/file?path=…` — one file the manifest lists. */ + async armoryFile(path: string): Promise { + return this.mapArmoryErrors(() => this.armory.readFile(path)); + } + + /** + * `GET /armory/ships` — what each member ship reports having applied. An + * offline ship, or one whose call fails, reports `state: null` rather than + * failing the aggregate: one unreachable ship must not blank the page. + */ + async armoryShipStates(): Promise { + return Promise.all( + [...this.connections.values()] + .filter((conn) => conn.member) + .map(async (conn) => { + if (conn.status !== "online") return { ship: conn.name, status: conn.status, state: null }; + try { + const state = await this.call( + conn, + () => conn.client.armory.get() as Promise>, + ); + return { ship: conn.name, status: conn.status, state }; + } catch { + // `call` flips the connection offline on a network failure, so the + // status is read back afterwards rather than captured above. + return { ship: conn.name, status: conn.status, state: null }; + } + }), + ); + } + + /** Drop the cached scan — called when the armory directory changes on disk. */ + invalidateArmory(): void { + this.armory.invalidate(); + } + + /** + * Tell every online ship to re-pull the armory. Never throws and never waits + * on one ship for another: a push is a notification, not a transaction, and a + * ship that is offline or that fails its pull must not break the bridge or + * hold up the rest of the fleet. Failures are warned about and dropped — + * whatever caused one will still be there at the next push or reconnect. + */ + async pushArmory(): Promise { + const revision = await this.currentArmoryRevision(); + if (revision === undefined) return; + const online = [...this.connections.values()].filter( + (conn) => conn.member && conn.status === "online", + ); + await Promise.allSettled(online.map((conn) => this.syncArmoryOn(conn, revision))); + } + + /** The one-ship push, used when a ship joins the fleet or comes back online. */ + private async pushArmoryTo(conn: ShipConnection): Promise { + if (!conn.member || conn.status !== "online") return; + const revision = await this.currentArmoryRevision(); + if (revision === undefined) return; + // The ship may have dropped while the armory was being scanned. + if (conn.status !== "online") return; + await this.syncArmoryOn(conn, revision); + } + + /** The current revision, or `undefined` when the armory cannot be scanned. */ + private async currentArmoryRevision(): Promise { + try { + return (await this.armoryManifest()).revision; + } catch (error) { + console.warn(`fleet-bridge: could not read the armory to push it: ${(error as Error).message}`); + return undefined; + } + } + + private async syncArmoryOn(conn: ShipConnection, revision: string): Promise { + const bridgeUrl = this.config.publicUrl ?? defaultPublicUrl(this.config.port); + try { + await this.call(conn, () => + conn.client.armory.sync.post({ bridgeUrl, revision }) as Promise>, + ); + } catch (error) { + console.warn( + `fleet-bridge: could not push the armory to ship "${conn.name}": ${(error as Error).message}`, + ); + } + } + + private async mapArmoryErrors(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + if (error instanceof ArmoryPathError || error instanceof ArmoryMapError) { + throw new BridgeError(error.message, 400); + } + if (error instanceof ArmoryNotFoundError) throw new BridgeError(error.message, 404); + if (error instanceof ArmoryTooLargeError) throw new BridgeError(error.message, 413); + throw error; + } + } + // --- workspace API (superset of the ship's) ------------------------------- /** `GET /workspaces` — merged, deduped, annotated with the owning ship. */ @@ -614,7 +743,11 @@ export class FleetManager { const conn = new ShipConnection({ url, name, deps: this.deps }); conn.setHandlers({ onEvent: (c, event) => this.onEvent(c, event), - onStatusChange: () => {}, + // A ship that restarts or reconnects may have missed pushes, so every + // arrival at "online" re-syncs it. Fire-and-forget; `pushArmoryTo` warns. + onStatusChange: (c, status) => { + if (status === "online") void this.pushArmoryTo(c); + }, }); return conn; } diff --git a/packages/fleet-bridge/src/index.ts b/packages/fleet-bridge/src/index.ts index 45eed79..d60763e 100755 --- a/packages/fleet-bridge/src/index.ts +++ b/packages/fleet-bridge/src/index.ts @@ -1,8 +1,11 @@ import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; import { Command, InvalidArgumentError } from "commander"; +import { ARMORY_DIRECTORY } from "fleet-protocol"; import { type BridgeConfig, resolveBridgeConfig } from "./config"; import { FleetManager } from "./fleet-manager"; +import { watchArmory, type ArmoryWatcher } from "./armory/armory-watcher"; import { createApp } from "./api"; export type { BridgeConfig } from "./config"; @@ -18,10 +21,13 @@ function parsePort(value: string): number { /** * Bring up a bridge: init the manager (loads the persisted ship roster and - * connects to each ship), then serve the API. Returns the manager so callers - * (e.g. `fleet launch`) can register additional ships. Throws on failure. + * connects to each ship), watch the armory, then serve the API. Returns the + * manager so callers (e.g. `fleet launch`) can register additional ships, and + * the armory watcher so they can close it. Throws on failure. */ -export async function startBridge(config: BridgeConfig): Promise<{ manager: FleetManager }> { +export async function startBridge( + config: BridgeConfig, +): Promise<{ manager: FleetManager; watcher: ArmoryWatcher }> { // The store persists ships.json/repos.json here; create it up front so a // first run against a fresh (default) data directory can persist its roster. await mkdir(config.dataDirectory, { recursive: true }); @@ -29,10 +35,17 @@ export async function startBridge(config: BridgeConfig): Promise<{ manager: Flee const manager = new FleetManager(config); await manager.init(); + // Started after `init` — the ships that come online during it push themselves + // through the connection's status handler. + const watcher = watchArmory(join(config.dataDirectory, ARMORY_DIRECTORY), () => { + manager.invalidateArmory(); + void manager.pushArmory(); + }); + const app = createApp(manager, config); app.listen(config.port); console.log(`fleet-bridge "${config.name}" listening on http://localhost:${config.port}`); - return { manager }; + return { manager, watcher }; } export const bridge = new Command() @@ -41,12 +54,14 @@ export const bridge = new Command() .option("-p, --port ", "port the HTTP + WebSocket API listens on", parsePort, DEFAULT_BRIDGE_PORT) .option("-n, --name ", "human-facing name of this bridge", "bridge") .option("-d, --data-directory ", "directory the bridge persists its ship roster to", "./.fleet-bridge") - .action(async (options: { port: number; name: string; dataDirectory: string }) => { + .option("--public-url ", "URL ships should use to reach this bridge") + .action(async (options: { port: number; name: string; dataDirectory: string; publicUrl?: string }) => { try { const config = resolveBridgeConfig({ dataDirectory: options.dataDirectory, port: options.port, name: options.name, + publicUrl: options.publicUrl, }); await startBridge(config); } catch (err) { diff --git a/packages/fleet-bridge/src/types.ts b/packages/fleet-bridge/src/types.ts index 3809f10..94e4f8c 100644 --- a/packages/fleet-bridge/src/types.ts +++ b/packages/fleet-bridge/src/types.ts @@ -8,7 +8,7 @@ * they are plain types, not zod schemas. */ -import type { SystemResources, WorkspaceStatus, WorkspaceSummary } from "fleet-protocol"; +import type { ArmorySyncState, SystemResources, WorkspaceStatus, WorkspaceSummary } from "fleet-protocol"; /** Whether the bridge currently has a live `/events` connection to a ship. */ export type ShipStatus = "online" | "offline"; @@ -56,6 +56,18 @@ export interface ShipSystemResources { readonly error: string | null; } +/** + * One ship's entry in the aggregate `GET /armory/ships`. `state` is what the + * ship reports it has pulled and installed; it is `null` for an offline ship and + * for one whose call failed, so a single unreachable ship never fails the whole + * aggregate. + */ +export interface ShipArmoryState { + readonly ship: string; + readonly status: ShipStatus; + readonly state: ArmorySyncState | null; +} + /** Fleet-wide identity of a workspace: `/` (unique across all ships). */ export function workspaceKey(repoName: string, name: string): string { return `${repoName}/${name}`; diff --git a/packages/fleet-bridge/tests/armory-push.test.ts b/packages/fleet-bridge/tests/armory-push.test.ts new file mode 100644 index 0000000..99d6f0c --- /dev/null +++ b/packages/fleet-bridge/tests/armory-push.test.ts @@ -0,0 +1,202 @@ +/** + * armory-push.test.ts — the write side of the armory: the watcher that notices a + * change and the `FleetManager` push that tells each ship to re-pull. + * + * The watcher is driven through an injected `fs.watch` so the debounce is tested + * against timers rather than real filesystem event timing; the push runs against + * the shared fake ships, whose Eden client records what it was asked to sync. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { FSWatcher, WatchListener } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveBridgeConfig } from "../src/config"; +import { watchArmory } from "../src/armory/armory-watcher"; +import { FleetManager } from "../src/fleet-manager"; +import { Store } from "../src/store/store"; +import { FakeSocket, makeDeps, ws, type FakeShip } from "./helpers"; + +const PUBLIC_URL = "http://bridge.example:4800"; + +describe("resolveBridgeConfig publicUrl", () => { + test("defaults to localhost on the configured port", () => { + const config = resolveBridgeConfig({ dataDirectory: ".", port: 4900, name: "bridge" }); + expect(config.publicUrl).toBe("http://localhost:4900"); + }); + + test("keeps an explicit value", () => { + const config = resolveBridgeConfig({ dataDirectory: ".", port: 4900, name: "bridge", publicUrl: PUBLIC_URL }); + expect(config.publicUrl).toBe(PUBLIC_URL); + }); +}); + +/** An `fs.watch` stand-in that hands the change listener back to the test. */ +function fakeWatch() { + const watcher = new EventEmitter() as EventEmitter & FSWatcher; + watcher.close = () => {}; + let listener: WatchListener | undefined; + const watch = ((_directory: unknown, _options: unknown, given: WatchListener) => { + listener = given; + return watcher; + }) as unknown as typeof import("node:fs").watch; + return { watch, watcher, fire: () => listener?.("change", "skills/one/SKILL.md") }; +} + +describe("watchArmory", () => { + test("collapses a burst of events into one callback", async () => { + const { watch, fire } = fakeWatch(); + let changes = 0; + const handle = watchArmory("/armory", () => changes++, { watch, debounceMs: 20 }); + + for (let i = 0; i < 5; i++) fire(); + await Bun.sleep(60); + + expect(changes).toBe(1); + handle.close(); + }); + + test("stops calling back once closed", async () => { + const { watch, fire } = fakeWatch(); + let changes = 0; + const handle = watchArmory("/armory", () => changes++, { watch, debounceMs: 20 }); + + fire(); + handle.close(); + await Bun.sleep(60); + + expect(changes).toBe(0); + }); + + test("a missing directory is a no-op handle, not a throw", async () => { + const directory = await mkdtemp(join(tmpdir(), "fleet-bridge-watch-")); + try { + const handle = watchArmory(join(directory, "armory"), () => { + throw new Error("should never fire"); + }); + handle.close(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +describe("FleetManager armory push", () => { + let dir: string; + let store: Store; + let manager: FleetManager | undefined; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "fleet-bridge-push-")); + FakeSocket.byBase.clear(); + store = new Store(dir); + await store.load(); + await mkdir(join(dir, "armory", "skills", "one"), { recursive: true }); + await writeFile(join(dir, "armory", "skills", "one", "SKILL.md"), "# one\n"); + }); + afterEach(async () => { + manager?.shutdown(); + manager = undefined; + await rm(dir, { recursive: true, force: true }); + }); + + function build(ships: Map): FleetManager { + manager = new FleetManager( + { dataDirectory: dir, port: 4800, name: "bridge", publicUrl: PUBLIC_URL }, + makeDeps(ships), + { syncTimeoutMs: 1000, store }, + ); + return manager; + } + + async function boot(ships: Map): Promise { + for (const [url, ship] of ships) await store.createShip({ name: ship.name, url }); + const mgr = build(ships); + await mgr.init(); + return mgr; + } + + /** Let the pushes that `init`/`addShip` fire off land, then forget them. */ + async function settle(ships: Map): Promise { + await Bun.sleep(20); + for (const ship of ships.values()) ship.armorySyncs = []; + } + + test("pushes the current revision and the configured publicUrl to every online ship", async () => { + const ships = new Map([ + ["http://ship-a", { name: "ship-a", workspaces: [ws("repo1", "one")] }], + ["http://ship-b", { name: "ship-b", workspaces: [] }], + ]); + const mgr = await boot(ships); + await settle(ships); + + await mgr.pushArmory(); + + const revision = (await mgr.armoryManifest()).revision; + expect(ships.get("http://ship-a")!.armorySyncs).toEqual([{ bridgeUrl: PUBLIC_URL, revision }]); + expect(ships.get("http://ship-b")!.armorySyncs).toEqual([{ bridgeUrl: PUBLIC_URL, revision }]); + }); + + test("skips offline ships", async () => { + const ships = new Map([ + ["http://ship-a", { name: "ship-a", workspaces: [] }], + ["http://ship-b", { name: "ship-b", workspaces: [] }], + ]); + const mgr = await boot(ships); + await settle(ships); + FakeSocket.byBase.get("http://ship-b")!.close(); + + await mgr.pushArmory(); + + expect(ships.get("http://ship-a")!.armorySyncs).toHaveLength(1); + expect(ships.get("http://ship-b")!.armorySyncs).toEqual([]); + }); + + test("a ship coming online is pushed to without anyone asking", async () => { + const ships = new Map([["http://ship-a", { name: "ship-a", workspaces: [] }]]); + const mgr = await boot(ships); + await settle(ships); + + // Drive the socket directly rather than waiting out the reconnect backoff: + // the connection reacts to its socket closing and reopening, which is the + // transition the push hangs off. + const socket = FakeSocket.byBase.get("http://ship-a")!; + socket.onclose?.({}); + socket.onopen?.({}); + await Bun.sleep(20); + + const revision = (await mgr.armoryManifest()).revision; + expect(ships.get("http://ship-a")!.armorySyncs).toEqual([{ bridgeUrl: PUBLIC_URL, revision }]); + }); + + test("an adopted ship is pushed to", async () => { + const ships = new Map([ + ["http://ship-a", { name: "ship-a", workspaces: [] }], + ["http://ship-b", { name: "ship-b", workspaces: [] }], + ]); + await store.createShip({ name: "ship-a", url: "http://ship-a" }); + const mgr = build(ships); + await mgr.init(); + await settle(ships); + + await mgr.addShip("http://ship-b"); + await Bun.sleep(20); + + const revision = (await mgr.armoryManifest()).revision; + expect(ships.get("http://ship-b")!.armorySyncs).toEqual([{ bridgeUrl: PUBLIC_URL, revision }]); + }); + + test("a ship whose sync fails does not make the push throw", async () => { + const ships = new Map([ + ["http://ship-a", { name: "ship-a", workspaces: [], errorResponse: { status: 502, message: "bridge unreachable" } }], + ["http://ship-b", { name: "ship-b", workspaces: [] }], + ]); + const mgr = await boot(ships); + await settle(ships); + + expect(await mgr.pushArmory()).toBeUndefined(); + expect(ships.get("http://ship-b")!.armorySyncs).toHaveLength(1); + }); +}); diff --git a/packages/fleet-bridge/tests/armory-ships.test.ts b/packages/fleet-bridge/tests/armory-ships.test.ts new file mode 100644 index 0000000..cf8c131 --- /dev/null +++ b/packages/fleet-bridge/tests/armory-ships.test.ts @@ -0,0 +1,127 @@ +/** + * armory-ships.test.ts — the aggregate `GET /armory/ships`, which reports what + * every member ship has pulled and installed. + * + * The point of the aggregate is that it degrades per ship rather than as a + * whole, so the cases here are a healthy ship, an offline one, and one whose + * call fails — all in the same response. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ArmorySyncState } from "fleet-protocol"; +import { createApp } from "../src/api"; +import { FleetManager } from "../src/fleet-manager"; +import { Store } from "../src/store/store"; +import { FakeSocket, makeDeps, type FakeShip } from "./helpers"; + +const SYNCED: ArmorySyncState = { + revision: "a".repeat(64), + bridgeUrl: "http://bridge.example:4800", + syncedAt: "2026-01-01T00:00:00.000Z", + fileCount: 3, + install: { + skillCount: 2, + pluginCount: 1, + dotfileCount: 1, + removedCount: 0, + conflicts: [], + warnings: [], + installedAt: "2026-01-01T00:00:01.000Z", + }, + lastError: null, +}; + +describe("FleetManager armoryShipStates", () => { + let dir: string; + let store: Store; + let manager: FleetManager | undefined; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "fleet-bridge-armory-ships-")); + FakeSocket.byBase.clear(); + store = new Store(dir); + await store.load(); + }); + afterEach(async () => { + manager?.shutdown(); + manager = undefined; + await rm(dir, { recursive: true, force: true }); + }); + + async function boot(ships: Map): Promise { + for (const [url, ship] of ships) await store.createShip({ name: ship.name, url }); + manager = new FleetManager({ dataDirectory: dir, port: 4800, name: "bridge" }, makeDeps(ships), { + syncTimeoutMs: 1000, + store, + }); + await manager.init(); + return manager; + } + + test("reports each ship's state, and null for one that is offline or failing", async () => { + const ships = new Map([ + ["http://ship-a", { name: "ship-a", workspaces: [], armoryState: SYNCED }], + ["http://ship-b", { name: "ship-b", workspaces: [] }], + [ + "http://ship-c", + { name: "ship-c", workspaces: [], errorResponse: { status: 500, message: "cache unreadable" } }, + ], + ]); + const mgr = await boot(ships); + FakeSocket.byBase.get("http://ship-b")!.close(); + + const states = await mgr.armoryShipStates(); + + expect(states).toEqual([ + { ship: "ship-a", status: "online", state: SYNCED }, + { ship: "ship-b", status: "offline", state: null }, + { ship: "ship-c", status: "online", state: null }, + ]); + }); + + test("a ship that has never synced reports a state rather than null", async () => { + const ships = new Map([["http://ship-a", { name: "ship-a", workspaces: [] }]]); + const mgr = await boot(ships); + + const states = await mgr.armoryShipStates(); + + expect(states).toEqual([ + { + ship: "ship-a", + status: "online", + state: { revision: null, bridgeUrl: null, syncedAt: null, fileCount: 0, install: null, lastError: null }, + }, + ]); + }); + + test("an unreachable ship is reported offline, not thrown", async () => { + const ships = new Map([ + ["http://ship-a", { name: "ship-a", workspaces: [], armoryState: SYNCED }], + ["http://ship-b", { name: "ship-b", workspaces: [], throws: true }], + ]); + const mgr = await boot(ships); + + const states = await mgr.armoryShipStates(); + + expect(states).toEqual([ + { ship: "ship-a", status: "online", state: SYNCED }, + { ship: "ship-b", status: "offline", state: null }, + ]); + }); + + test("GET /armory/ships serves the aggregate", async () => { + const ships = new Map([ + ["http://ship-a", { name: "ship-a", workspaces: [], armoryState: SYNCED }], + ]); + const mgr = await boot(ships); + const app = createApp(mgr, { dataDirectory: dir, port: 4800, name: "bridge" }); + + const response = await app.handle(new Request("http://bridge/armory/ships")); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual([{ ship: "ship-a", status: "online", state: SYNCED }]); + }); +}); diff --git a/packages/fleet-bridge/tests/armory.test.ts b/packages/fleet-bridge/tests/armory.test.ts new file mode 100644 index 0000000..a0b1abd --- /dev/null +++ b/packages/fleet-bridge/tests/armory.test.ts @@ -0,0 +1,424 @@ +/** + * armory.test.ts — exercises `ArmoryService` against real temp directories (the + * scan is all filesystem behaviour, so there is nothing worth faking) plus the + * `/armory` routes through the composed Elysia app. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { ArmoryManifestSchema } from "fleet-protocol"; +import { + ArmoryMapError, + ArmoryNotFoundError, + ArmoryPathError, + ArmoryService, +} from "../src/armory/armory-service"; +import { FleetManager } from "../src/fleet-manager"; +import { createApp } from "../src/api"; +import { Store } from "../src/store/store"; +import { makeDeps, type FakeShip } from "./helpers"; + +const directories: string[] = []; + +async function armoryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "fleet-bridge-armory-")); + directories.push(directory); + return join(directory, "armory"); +} + +async function write(root: string, path: string, contents: string | Uint8Array): Promise { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, contents); + return target; +} + +const sha256 = (contents: string | Uint8Array): string => + new Bun.CryptoHasher("sha256").update(contents).digest("hex"); + +/** The error `promise` rejected with, failing the test if it resolved instead. */ +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error as Error; + } + throw new Error("expected the promise to reject"); +} + +afterEach(async () => { + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); +}); + +describe("ArmoryService", () => { + test("a missing armory directory yields an empty, stable manifest", async () => { + const manifest = await new ArmoryService(await armoryDirectory()).manifest(); + + expect(manifest.entries).toEqual([]); + expect(manifest.dotfileMap).toEqual({}); + expect(manifest.revision).toMatch(/^[0-9a-f]{64}$/); + + const other = await new ArmoryService(await armoryDirectory()).manifest(); + expect(other.revision).toBe(manifest.revision); + + // An armory that exists but holds nothing is the same content as none at all. + const empty = await armoryDirectory(); + await mkdir(join(empty, "skills"), { recursive: true }); + expect((await new ArmoryService(empty).manifest()).revision).toBe(manifest.revision); + }); + + test("scans the three sections into sorted, hashed, section-tagged entries", async () => { + const root = await armoryDirectory(); + await write(root, "skills/my-skill/SKILL.md", "# skill"); + await write(root, "skills/my-skill/helper.py", "print(1)"); + await write(root, "plugins/claude-code/plugin.json", "{}"); + await write(root, "dotfiles/.tmux.conf", "set -g mouse on"); + await write(root, "dotfile-map.json", JSON.stringify({ ".tmux.conf": "~/.tmux.conf" })); + await write(root, "README.md", "ignored: not in a section"); + await write(root, "notes/scratch.txt", "ignored: not a section"); + await chmod(join(root, "skills/my-skill/helper.py"), 0o700); + + const manifest = await new ArmoryService(root).manifest(); + + expect(manifest.entries.map((entry) => entry.path)).toEqual([ + "dotfiles/.tmux.conf", + "plugins/claude-code/plugin.json", + "skills/my-skill/SKILL.md", + "skills/my-skill/helper.py", + ]); + expect(manifest.entries.map((entry) => entry.section)).toEqual([ + "dotfiles", + "plugins", + "skills", + "skills", + ]); + expect(manifest.entries[2]).toEqual({ + path: "skills/my-skill/SKILL.md", + section: "skills", + size: 7, + sha256: sha256("# skill"), + mode: 0o644, + }); + expect(manifest.entries[3]?.mode).toBe(0o755); + expect(manifest.dotfileMap).toEqual({ ".tmux.conf": "~/.tmux.conf" }); + expect(ArmoryManifestSchema.safeParse(manifest).success).toBe(true); + }); + + test("the revision tracks content, mode, and the dotfile map — and nothing else", async () => { + const root = await armoryDirectory(); + await write(root, "skills/one/SKILL.md", "original"); + const first = (await new ArmoryService(root).manifest()).revision; + + expect((await new ArmoryService(root).manifest()).revision).toBe(first); + + await write(root, "skills/one/SKILL.md", "changed"); + const afterContent = (await new ArmoryService(root).manifest()).revision; + expect(afterContent).not.toBe(first); + + await write(root, "dotfile-map.json", JSON.stringify({ "x.conf": "~/x.conf" })); + const afterMap = (await new ArmoryService(root).manifest()).revision; + expect(afterMap).not.toBe(afterContent); + + // Key order in the map is the human's; the revision must not follow it. + await write(root, "dotfile-map.json", JSON.stringify({ "b.conf": "~/b", "a.conf": "~/a" })); + const one = (await new ArmoryService(root).manifest()).revision; + await write(root, "dotfile-map.json", JSON.stringify({ "a.conf": "~/a", "b.conf": "~/b" })); + expect((await new ArmoryService(root).manifest()).revision).toBe(one); + }); + + test("the scan is cached until invalidate()", async () => { + const root = await armoryDirectory(); + await write(root, "skills/one/SKILL.md", "one"); + const service = new ArmoryService(root); + const before = await service.manifest(); + + await write(root, "skills/two/SKILL.md", "two"); + expect((await service.manifest()).entries).toHaveLength(1); + expect((await service.manifest()).revision).toBe(before.revision); + + service.invalidate(); + const after = await service.manifest(); + expect(after.entries.map((entry) => entry.path)).toEqual([ + "skills/one/SKILL.md", + "skills/two/SKILL.md", + ]); + expect(after.revision).not.toBe(before.revision); + }); + + test("concurrent manifest() calls share one scan result", async () => { + const root = await armoryDirectory(); + await write(root, "skills/one/SKILL.md", "one"); + const service = new ArmoryService(root); + + const [a, b, c] = await Promise.all([service.manifest(), service.manifest(), service.manifest()]); + expect(a).toEqual(b); + expect(b).toEqual(c); + }); + + test("symlinks are skipped, not followed", async () => { + const root = await armoryDirectory(); + await write(root, "skills/real/SKILL.md", "real"); + const outside = await write(root, "../outside-secret.txt", "secret"); + await mkdir(join(root, "plugins"), { recursive: true }); + await symlink(outside, join(root, "skills/real/leak.txt")); + await symlink(dirname(outside), join(root, "plugins/leak-dir")); + + const manifest = await new ArmoryService(root).manifest(); + + expect(manifest.entries.map((entry) => entry.path)).toEqual(["skills/real/SKILL.md"]); + }); + + test("a section directory that is itself a symlink contributes nothing and is unreadable", async () => { + const root = await armoryDirectory(); + const outside = join(dirname(root), "secrets"); + await write(outside, "id_rsa", "PRIVATE KEY MATERIAL"); + await write(outside, "nested/deeper.txt", "also outside"); + await mkdir(root, { recursive: true }); + // `readdir` follows a symlink handed to it as a path, so `skills` itself + // being a link is the case a per-dirent check cannot see. + await symlink(outside, join(root, "skills")); + + const service = new ArmoryService(root); + const manifest = await service.manifest(); + + expect(manifest.entries.map((entry) => entry.path)).toEqual([]); + expect(manifest.entries.map((entry) => entry.path)).not.toContain("skills/id_rsa"); + expect(manifest.entries.map((entry) => entry.path)).not.toContain("skills/nested/deeper.txt"); + await expect(service.readFile("skills/id_rsa")).rejects.toBeInstanceOf(ArmoryNotFoundError); + await expect(service.readFile("skills/nested/deeper.txt")).rejects.toBeInstanceOf( + ArmoryNotFoundError, + ); + }); + + test("a symlinked section does not disturb the sections beside it", async () => { + const outside = join(dirname(await armoryDirectory()), "secrets"); + await write(outside, "id_rsa", "PRIVATE KEY MATERIAL"); + + const poisoned = await armoryDirectory(); + await write(poisoned, "plugins/claude-code/plugin.json", "{}"); + await write(poisoned, "dotfiles/.tmux.conf", "set -g mouse on"); + await symlink(outside, join(poisoned, "skills")); + + const clean = await armoryDirectory(); + await write(clean, "plugins/claude-code/plugin.json", "{}"); + await write(clean, "dotfiles/.tmux.conf", "set -g mouse on"); + + const withSymlink = await new ArmoryService(poisoned).manifest(); + const without = await new ArmoryService(clean).manifest(); + + expect(withSymlink.entries.map((entry) => entry.path)).toEqual([ + "dotfiles/.tmux.conf", + "plugins/claude-code/plugin.json", + ]); + expect(withSymlink.revision).toBe(without.revision); + }); + + test("an armory root that is a symlink scans normally", async () => { + // The documented workflow: the operator points their data directory's armory + // at a checkout elsewhere on the host. + const checkout = join(dirname(await armoryDirectory()), "checkout"); + await write(checkout, "skills/my-skill/SKILL.md", "# skill"); + await write(checkout, "dotfile-map.json", JSON.stringify({ ".tmux.conf": "~/.tmux.conf" })); + + const root = await armoryDirectory(); + await mkdir(dirname(root), { recursive: true }); + await symlink(checkout, root); + + const service = new ArmoryService(root); + const manifest = await service.manifest(); + + expect(manifest.entries.map((entry) => entry.path)).toEqual(["skills/my-skill/SKILL.md"]); + expect(manifest.dotfileMap).toEqual({ ".tmux.conf": "~/.tmux.conf" }); + expect((await service.readFile("skills/my-skill/SKILL.md")).contents).toBe("# skill"); + }); + + test("a section that is a regular file is skipped without throwing", async () => { + const root = await armoryDirectory(); + await write(root, "skills", "not a directory"); + await write(root, "plugins/claude-code/plugin.json", "{}"); + + const manifest = await new ArmoryService(root).manifest(); + + expect(manifest.entries.map((entry) => entry.path)).toEqual(["plugins/claude-code/plugin.json"]); + }); + + test("readFile refuses a path whose directory became a symlink after the scan", async () => { + const root = await armoryDirectory(); + const outside = join(dirname(root), "secrets"); + await write(outside, "passwd", "OUTSIDE THE ARMORY"); + await write(root, "skills/pack/passwd", "harmless"); + + const service = new ArmoryService(root); + expect((await service.manifest()).entries.map((entry) => entry.path)).toEqual([ + "skills/pack/passwd", + ]); + + // The manifest stays cached and clean while the tree beneath it changes. + await rm(join(root, "skills/pack"), { recursive: true, force: true }); + await symlink(outside, join(root, "skills/pack")); + + await expect(service.readFile("skills/pack/passwd")).rejects.toBeInstanceOf(ArmoryPathError); + }); + + test("readFile round-trips utf8 and falls back to base64 for binary", async () => { + const root = await armoryDirectory(); + await write(root, "skills/one/SKILL.md", "héllo ✅"); + const binary = new Uint8Array([0x00, 0xff, 0xfe, 0x41]); + await write(root, "plugins/p/blob.bin", binary); + const service = new ArmoryService(root); + + const text = await service.readFile("skills/one/SKILL.md"); + expect(text).toEqual({ + path: "skills/one/SKILL.md", + section: "skills", + size: Buffer.byteLength("héllo ✅"), + sha256: sha256("héllo ✅"), + mode: 0o644, + encoding: "utf8", + contents: "héllo ✅", + }); + + const blob = await service.readFile("plugins/p/blob.bin"); + expect(blob.encoding).toBe("base64"); + expect(new Uint8Array(Buffer.from(blob.contents, "base64"))).toEqual(binary); + }); + + test("readFile rejects traversal, absolute paths, and anything absent from the manifest", async () => { + const root = await armoryDirectory(); + await write(root, "skills/one/SKILL.md", "one"); + await write(root, "README.md", "outside the sections"); + const service = new ArmoryService(root); + + await expect(service.readFile("../../etc/passwd")).rejects.toBeInstanceOf(ArmoryPathError); + await expect(service.readFile("/etc/passwd")).rejects.toBeInstanceOf(ArmoryPathError); + await expect(service.readFile("skills/../../etc/passwd")).rejects.toBeInstanceOf(ArmoryPathError); + await expect(service.readFile("skills\\one\\SKILL.md")).rejects.toBeInstanceOf(ArmoryPathError); + await expect(service.readFile("")).rejects.toBeInstanceOf(ArmoryPathError); + // Real files outside the three sections are unreachable: the manifest gates reads. + await expect(service.readFile("README.md")).rejects.toBeInstanceOf(ArmoryNotFoundError); + await expect(service.readFile("skills/one/missing.md")).rejects.toBeInstanceOf(ArmoryNotFoundError); + }); + + test("a broken dotfile-map.json surfaces as an error rather than an empty map", async () => { + const badDestination = await armoryDirectory(); + await write(badDestination, "dotfile-map.json", JSON.stringify({ ".tmux.conf": "relative/dest" })); + await expect(new ArmoryService(badDestination).manifest()).rejects.toBeInstanceOf(ArmoryMapError); + + const badSource = await armoryDirectory(); + await write(badSource, "dotfile-map.json", JSON.stringify({ "../escape": "~/escape" })); + await expect(new ArmoryService(badSource).manifest()).rejects.toBeInstanceOf(ArmoryMapError); + + const badShape = await armoryDirectory(); + await write(badShape, "dotfile-map.json", JSON.stringify({ "a.conf": 42 })); + await expect(new ArmoryService(badShape).manifest()).rejects.toBeInstanceOf(ArmoryMapError); + + const notAFile = await armoryDirectory(); + await mkdir(join(notAFile, "dotfile-map.json"), { recursive: true }); + await expect(new ArmoryService(notAFile).manifest()).rejects.toBeInstanceOf(ArmoryMapError); + }); + + test("the dotfile-map error names the file on disk and every offending entry", async () => { + const badJson = await armoryDirectory(); + const jsonTarget = await write(badJson, "dotfile-map.json", "{ not json"); + const jsonError = await rejection(new ArmoryService(badJson).manifest()); + expect(jsonError.message).toContain(jsonTarget); + expect(jsonError.message).toContain("not valid JSON"); + + const badValue = await armoryDirectory(); + const valueTarget = await write( + badValue, + "dotfile-map.json", + JSON.stringify({ ".tmux.conf": "tmux.conf" }), + ); + const valueError = await rejection(new ArmoryService(badValue).manifest()); + expect(valueError.message).toContain(valueTarget); + expect(valueError.message).toContain('".tmux.conf"'); + expect(valueError.message).toContain('destination "tmux.conf"'); + + // Every bad entry is reported, and a bad key reads differently from a bad + // value. The mistyped third entry must not hide the other two. + const bothSides = await armoryDirectory(); + await write( + bothSides, + "dotfile-map.json", + JSON.stringify({ "../escape": "~/escape", "nvim": "config/nvim", "a.conf": 42 }), + ); + const bothError = await rejection(new ArmoryService(bothSides).manifest()); + expect(bothError.message).toContain('source "../escape"'); + expect(bothError.message).toContain('"nvim": destination "config/nvim"'); + expect(bothError.message).toContain('"a.conf": destination must be a non-empty string'); + }); +}); + +describe("armory API", () => { + let manager: FleetManager | undefined; + + afterEach(() => { + manager?.shutdown(); + manager = undefined; + }); + + async function app() { + const directory = await mkdtemp(join(tmpdir(), "fleet-bridge-armory-api-")); + directories.push(directory); + const config = { dataDirectory: directory, port: 4800, name: "bridge" }; + const store = new Store(directory); + await store.load(); + manager = new FleetManager(config, makeDeps(new Map()), { + syncTimeoutMs: 50, + store, + }); + await manager.init(); + return { root: join(directory, "armory"), app: createApp(manager, config) }; + } + + async function call(handler: ReturnType, path: string) { + const response = await handler.handle(new Request(`http://bridge${path}`)); + const text = await response.text(); + return { status: response.status, body: text ? JSON.parse(text) : undefined }; + } + + test("GET /armory returns an empty manifest when no armory directory exists", async () => { + const { app: handler } = await app(); + + const { status, body } = await call(handler, "/armory"); + expect(status).toBe(200); + expect(body).toMatchObject({ entries: [], dotfileMap: {} }); + expect(body.revision).toMatch(/^[0-9a-f]{64}$/); + }); + + test("GET /armory and /armory/file serve a populated armory", async () => { + const { root, app: handler } = await app(); + await write(root, "skills/my-skill/SKILL.md", "# skill"); + await write(root, "dotfile-map.json", JSON.stringify({ ".tmux.conf": "~/.tmux.conf" })); + + const manifest = await call(handler, "/armory"); + expect(manifest.status).toBe(200); + expect(manifest.body.entries).toEqual([ + { + path: "skills/my-skill/SKILL.md", + section: "skills", + size: 7, + sha256: sha256("# skill"), + mode: 0o644, + }, + ]); + expect(manifest.body.dotfileMap).toEqual({ ".tmux.conf": "~/.tmux.conf" }); + + const file = await call(handler, "/armory/file?path=skills/my-skill/SKILL.md"); + expect(file.status).toBe(200); + expect(file.body).toMatchObject({ encoding: "utf8", contents: "# skill" }); + }); + + test("GET /armory/file rejects traversal (400) and unknown paths (404)", async () => { + const { root, app: handler } = await app(); + await write(root, "skills/my-skill/SKILL.md", "# skill"); + + expect((await call(handler, "/armory/file?path=../../etc/passwd")).status).toBe(400); + expect((await call(handler, "/armory/file?path=/etc/passwd")).status).toBe(400); + expect((await call(handler, "/armory/file?path=skills/nope.md")).status).toBe(404); + expect((await call(handler, "/armory/file")).status).toBe(422); + }); +}); diff --git a/packages/fleet-bridge/tests/helpers.ts b/packages/fleet-bridge/tests/helpers.ts index be83094..95f26df 100644 --- a/packages/fleet-bridge/tests/helpers.ts +++ b/packages/fleet-bridge/tests/helpers.ts @@ -8,7 +8,7 @@ * sync, to return an Eden error, or to throw (network failure). */ -import type { FleetEvent, SystemResources, WorkspaceSummary } from "fleet-protocol"; +import type { ArmorySyncState, FleetEvent, SystemResources, WorkspaceSummary } from "fleet-protocol"; import type { ShipConnectionDeps, SocketLike } from "../src/ship-connection"; /** A ship the fakes pretend exists at a given base URL. */ @@ -23,6 +23,10 @@ export interface FakeShip { workspaceSnapshot?: unknown; /** Socket opens but never sends a `sync` (for waitForSync timeout tests). */ neverSync?: boolean; + /** Every `POST /armory/sync` this ship received, in order. */ + armorySyncs?: { bridgeUrl: string; revision: string }[]; + /** What `GET /armory` reports; defaults to a ship that has never synced. */ + armoryState?: ArmorySyncState; /** All Eden calls resolve to this error `{status, value:{error}}`. */ errorResponse?: { status: number; message: string }; /** All Eden calls throw (simulated network failure). */ @@ -190,6 +194,35 @@ export function makeFakeClient(httpUrl: string, ships: Map) { return { workspaces: workspacesFn, "system-resources": { get: () => wrap(() => fakeResources(ship()?.name ?? "unknown")) }, + armory: { + get: () => + wrap( + () => + ship()?.armoryState ?? { + revision: null, + bridgeUrl: null, + syncedAt: null, + fileCount: 0, + install: null, + lastError: null, + }, + ), + sync: { + post: (body: { bridgeUrl: string; revision: string }) => { + const s = ship(); + // Recorded before `wrap`, so a ship configured to error or throw still + // shows what the bridge tried to push. + if (s) (s.armorySyncs ??= []).push(body); + return wrap(() => ({ + revision: body.revision, + bridgeUrl: body.bridgeUrl, + syncedAt: "2026-01-01T00:00:00.000Z", + fileCount: 0, + lastError: null, + })); + }, + }, + }, }; } diff --git a/packages/fleet-client/package.json b/packages/fleet-client/package.json index 2f3c85e..2691982 100644 --- a/packages/fleet-client/package.json +++ b/packages/fleet-client/package.json @@ -10,7 +10,8 @@ "scripts": { "dev": "bun --hot dev.ts", "start": "NODE_ENV=production bun src/index.ts", - "test": "bun test tests" + "test": "bun test tests", + "typecheck": "tsc --noEmit" }, "dependencies": { "@elysiajs/eden": "latest", diff --git a/packages/fleet-client/src/App.tsx b/packages/fleet-client/src/App.tsx index 9c2e193..6d1bc66 100644 --- a/packages/fleet-client/src/App.tsx +++ b/packages/fleet-client/src/App.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; import { FleetProvider } from "./data/FleetContext"; import { Shell } from "./layouts/Shell"; +import { ArmoryRoute } from "./routes/ArmoryRoute"; import { BridgeRoute } from "./routes/BridgeRoute"; import { ReposRoute } from "./routes/ReposRoute"; import { RepoRoute } from "./routes/RepoRoute"; @@ -24,6 +25,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/packages/fleet-client/src/data/FleetContext.tsx b/packages/fleet-client/src/data/FleetContext.tsx index f7224f8..f759937 100644 --- a/packages/fleet-client/src/data/FleetContext.tsx +++ b/packages/fleet-client/src/data/FleetContext.tsx @@ -2,7 +2,15 @@ import { createContext, useCallback, useContext, useEffect, useState, type React import type { WorkspaceRefs } from "fleet-protocol"; import type { DiffQuery } from "@/lib/diff/diff-target"; import { bridge } from "./bridge"; -import type { Repo, Ship, Workspace, WorkspaceDetail } from "./types"; +import type { + ArmoryFile, + ArmoryManifest, + ArmoryShipState, + Repo, + Ship, + Workspace, + WorkspaceDetail, +} from "./types"; import { applyWorkspaceEvent } from "./workspace-events"; interface FleetValue { @@ -35,6 +43,12 @@ interface FleetValue { createShip: (url: string) => Promise; /** Deregister a ship, then refresh the ship list. Rejects on failure. */ deleteShip: (name: string) => Promise; + /** The bridge's armory manifest. Fetched on demand — the armory is not part of the boot snapshot. */ + getArmory: () => Promise; + /** One armory file's contents. */ + getArmoryFile: (path: string) => Promise; + /** What each ship has pulled and installed from the armory. */ + listArmoryShips: () => Promise; } const FleetContext = createContext(null); @@ -120,6 +134,12 @@ export function FleetProvider({ children }: { children: ReactNode }) { const getWorkspaceRefs = useCallback((repo: string, name: string) => bridge.getWorkspaceRefs(repo, name), []); + // The armory is only ever read by its own page, so it stays out of the boot + // snapshot: no eager state, no entry in the mount `Promise.all`. + const getArmory = useCallback(() => bridge.getArmory(), []); + const getArmoryFile = useCallback((path: string) => bridge.getArmoryFile(path), []); + const listArmoryShips = useCallback(() => bridge.listArmoryShips(), []); + // Repo/ship mutations rethrow so the calling modal can show the failure inline, // rather than swallowing it into the global banner like activate/deactivate. const refreshRepos = useCallback(async () => setRepos(await bridge.listRepos()), []); @@ -202,6 +222,9 @@ export function FleetProvider({ children }: { children: ReactNode }) { createShip, deleteShip, createWorkspace, + getArmory, + getArmoryFile, + listArmoryShips, }; return {children}; diff --git a/packages/fleet-client/src/data/eden.ts b/packages/fleet-client/src/data/eden.ts index 98900cc..d1050c6 100644 --- a/packages/fleet-client/src/data/eden.ts +++ b/packages/fleet-client/src/data/eden.ts @@ -2,7 +2,16 @@ import { WorkspaceRefsSchema, WorkspaceSummarySchema, type SystemResources, type import type { DiffQuery } from "@/lib/diff/diff-target"; import { makeBridgeClient, wsBridgeUrl, type BridgeClient } from "./client"; import type { FleetBridge } from "./provider"; -import type { Repo, Ship, Workspace, WorkspaceDetail, WorkspaceEvent } from "./types"; +import type { + ArmoryFile, + ArmoryManifest, + ArmoryShipState, + Repo, + Ship, + Workspace, + WorkspaceDetail, + WorkspaceEvent, +} from "./types"; const CHANGE_TYPES = new Set([ "workspace.created", @@ -204,4 +213,26 @@ export class EdenFleetBridge implements FleetBridge { const { error } = await this.client.workspaces({ repo })({ name }).delete(); if (error) throw edenError(error); } + + async getArmory(): Promise { + const { data, error } = await this.client.armory.get(); + if (error) throw edenError(error); + // The handler can also surface an in-band `{ error }` body on a 200. + if (!data || "error" in data) throw edenError({ value: data }); + return data; + } + + async getArmoryFile(path: string): Promise { + const { data, error } = await this.client.armory.file.get({ query: { path } }); + if (error) throw edenError(error); + if (!data || "error" in data) throw edenError({ value: data }); + return data; + } + + async listArmoryShips(): Promise { + const { data, error } = await this.client.armory.ships.get(); + if (error) throw edenError(error); + if (!Array.isArray(data)) throw edenError({ value: data }); + return data; + } } diff --git a/packages/fleet-client/src/data/mock.ts b/packages/fleet-client/src/data/mock.ts index b9436b3..dbbbc1e 100644 --- a/packages/fleet-client/src/data/mock.ts +++ b/packages/fleet-client/src/data/mock.ts @@ -1,7 +1,18 @@ import type { AgentState, AgentStatus, WorkspaceDiff, WorkspaceRefs } from "fleet-protocol"; import type { DiffQuery } from "@/lib/diff/diff-target"; import type { FleetBridge } from "./provider"; -import type { Repo, Ship, Workspace, WorkspaceDetail, WorkspaceEvent } from "./types"; +import type { + ArmoryFile, + ArmoryManifest, + ArmorySection, + ArmoryShipState, + ArmorySyncState, + Repo, + Ship, + Workspace, + WorkspaceDetail, + WorkspaceEvent, +} from "./types"; /** * In-memory implementation of {@link FleetBridge}. Seed data is ported from the @@ -118,6 +129,212 @@ const MOCK_COMMITS: WorkspaceRefs["commits"] = [ { sha: "6c8970ab1c2d3e4f50617283a4b5c6d7e8f90122", shortSha: "6c8970a", subject: "Wire up the config loader" }, ]; +/** + * A seed armory file. `contents` is what the viewer renders; everything the + * manifest reports about the file (size, hash, section) is derived from it, so + * the mock manifest and the mock file reads can never drift apart. + */ +interface SeedArmoryFile { + readonly path: string; + readonly contents: string; + readonly encoding?: "utf8" | "base64"; + readonly mode?: number; +} + +const SEED_ARMORY_FILES: SeedArmoryFile[] = [ + { + path: "skills/pr-review/SKILL.md", + contents: `--- +name: pr-review +description: Review a pull request against the fleet's checklist. +--- + +Read the diff, then walk the checklist in \`checklist.md\` top to bottom. +Report findings as a list; do not push commits. +`, + }, + { + path: "skills/pr-review/checklist.md", + contents: `- [ ] tests cover the new branch of behaviour +- [ ] no secrets or hostnames committed +- [ ] error paths return a mapped status, not a bare 500 +`, + }, + { + path: "skills/deploy/SKILL.md", + contents: `--- +name: deploy +description: Roll a service out to the fleet. +--- + +Run \`scripts/rollout.sh \`. It is idempotent and safe to re-run. +`, + }, + { + path: "skills/deploy/scripts/rollout.sh", + contents: `#!/usr/bin/env bash +set -euo pipefail +service="\${1:?usage: rollout.sh }" +echo "rolling out \${service}" +`, + mode: 0o755, + }, + { + path: "plugins/opencode/plugin.json", + contents: `{ + "name": "fleet-opencode", + "version": "0.4.1", + "entry": "src/index.ts" +} +`, + }, + { + path: "plugins/opencode/src/index.ts", + contents: `export default { + name: "fleet-opencode", + hooks: { + "session.start": () => console.log("fleet armory plugin loaded"), + }, +}; +`, + }, + { + // Binary on purpose: the viewer must show a placeholder, never the bytes. + path: "plugins/opencode/assets/icon.png", + contents: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + encoding: "base64", + }, + { + path: "plugins/claude-code/settings.json", + contents: `{ + "permissions": { "allow": ["Bash(bun test)", "Bash(bun run typecheck)"] } +} +`, + }, + { + path: "dotfiles/tmux.conf", + contents: `set -g mouse on +set -g history-limit 50000 +set -g status-style bg=default +`, + }, + { + path: "dotfiles/gitconfig", + contents: `[user] + name = fleet agent +[pull] + rebase = true +`, + }, + { + path: "dotfiles/nvim/init.lua", + contents: `vim.opt.number = true +vim.opt.expandtab = true +vim.opt.shiftwidth = 2 +`, + }, +]; + +const SEED_DOTFILE_MAP: Record = { + "tmux.conf": "~/.tmux.conf", + gitconfig: "~/.gitconfig", + "nvim/init.lua": "~/.config/nvim/init.lua", +}; + +/** + * A deterministic stand-in for a content hash. The mock never sees real bytes on + * a real filesystem, and the UI only ever displays or compares these, so the one + * property that matters is that the same input always yields the same 64 hex + * characters. + */ +function fakeSha256(seed: string): string { + let hash = 0x811c9dc5; + let out = ""; + for (let round = 0; out.length < 64; round++) { + for (const char of `${seed}#${round}`) { + hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193) >>> 0; + } + out += hash.toString(16).padStart(8, "0"); + } + return out.slice(0, 64); +} + +function armoryFile(seed: SeedArmoryFile): ArmoryFile { + const encoding = seed.encoding ?? "utf8"; + return { + path: seed.path, + section: seed.path.split("/")[0] as ArmorySection, + size: encoding === "base64" ? atob(seed.contents).length : new TextEncoder().encode(seed.contents).length, + sha256: fakeSha256(seed.path), + mode: seed.mode ?? 0o644, + encoding, + contents: seed.contents, + }; +} + +/** The manifest revision, and the value an "in sync" ship reports. */ +const ARMORY_REVISION = fakeSha256("armory-revision"); +/** A revision from before the last edit, so a ship holding it reads as behind. */ +const STALE_ARMORY_REVISION = fakeSha256("armory-revision-previous"); + +/** + * Per-ship armory state, keyed by ship name: one in sync, one behind with an + * install that hit a conflict, one that has never synced, and one whose last + * sync failed. A ship added during the session has no seed and reports `null`, + * which is also what the bridge returns for a ship it could not reach. + */ +const SEED_ARMORY_SHIP_STATES: Record = { + "forge-01": { + revision: ARMORY_REVISION, + bridgeUrl: "http://bridge.local:4800", + syncedAt: "2026-07-26T09:14:02.000Z", + fileCount: SEED_ARMORY_FILES.length, + install: { + skillCount: 4, + pluginCount: 4, + dotfileCount: 3, + removedCount: 0, + conflicts: [], + warnings: [], + installedAt: "2026-07-26T09:14:03.000Z", + }, + lastError: null, + }, + "forge-02": { + revision: STALE_ARMORY_REVISION, + bridgeUrl: "http://bridge.local:4800", + syncedAt: "2026-07-24T18:02:41.000Z", + fileCount: SEED_ARMORY_FILES.length - 1, + install: { + skillCount: 4, + pluginCount: 3, + dotfileCount: 2, + removedCount: 1, + conflicts: ["~/.gitconfig"], + warnings: ["plugins/opencode/assets/icon.png: skipped, unreadable on this host"], + installedAt: "2026-07-24T18:02:44.000Z", + }, + lastError: null, + }, + "atlas-7": { + revision: null, + bridgeUrl: null, + syncedAt: null, + fileCount: 0, + install: null, + lastError: null, + }, + nimbus: { + revision: STALE_ARMORY_REVISION, + bridgeUrl: "http://bridge.local:4800", + syncedAt: "2026-07-20T11:47:12.000Z", + fileCount: SEED_ARMORY_FILES.length - 1, + install: null, + lastError: "armory pull failed: bridge unreachable (502)", + }, +}; + /** Seed the repo registry from the distinct repo names in the seed workspaces. */ function seedRepos(): Repo[] { const names: string[] = []; @@ -135,6 +352,7 @@ export class MockFleetBridge implements FleetBridge { private readonly workspaces: Workspace[] = SEED_WORKSPACES.map((w) => ({ ...w })); private readonly ships: Ship[] = SHIPS.map((s) => ({ ...s })); private readonly repos: Repo[] = seedRepos(); + private readonly armory: ArmoryFile[] = SEED_ARMORY_FILES.map(armoryFile); private readonly workspaceListeners = new Set<(event: WorkspaceEvent) => void>(); private emit(event: WorkspaceEvent): void { @@ -285,4 +503,29 @@ export class MockFleetBridge implements FleetBridge { this.workspaces.splice(i, 1); this.emit({ type: "workspace.removed", at: new Date().toISOString(), workspace: { ...workspace } }); } + + async getArmory(): Promise { + return { + revision: ARMORY_REVISION, + entries: this.armory + .map((f) => ({ path: f.path, section: f.section, size: f.size, sha256: f.sha256, mode: f.mode })) + // Codepoint order, matching how the bridge's scanner sorts a manifest. + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)), + dotfileMap: { ...SEED_DOTFILE_MAP }, + }; + } + + async getArmoryFile(path: string): Promise { + const file = this.armory.find((f) => f.path === path); + if (!file) throw new Error(`armory file not found: ${path}`); + return { ...file }; + } + + async listArmoryShips(): Promise { + return this.ships.map((s) => ({ + ship: s.name, + status: s.status, + state: SEED_ARMORY_SHIP_STATES[s.name] ?? null, + })); + } } diff --git a/packages/fleet-client/src/data/provider.ts b/packages/fleet-client/src/data/provider.ts index 7076b21..e628c3d 100644 --- a/packages/fleet-client/src/data/provider.ts +++ b/packages/fleet-client/src/data/provider.ts @@ -1,6 +1,15 @@ import type { WorkspaceRefs } from "fleet-protocol"; import type { DiffQuery } from "@/lib/diff/diff-target"; -import type { Repo, Ship, Workspace, WorkspaceDetail, WorkspaceEvent } from "./types"; +import type { + ArmoryFile, + ArmoryManifest, + ArmoryShipState, + Repo, + Ship, + Workspace, + WorkspaceDetail, + WorkspaceEvent, +} from "./types"; /** * The data our UI needs from the fleet bridge, expressed as one async surface. @@ -50,4 +59,10 @@ export interface FleetBridge { switchBranch(repo: string, name: string, branch: string): Promise; /** `DELETE /workspaces/:repo/:name` — remove a workspace. */ deleteWorkspace(repo: string, name: string): Promise; + /** `GET /armory` — the bridge's armory manifest. */ + getArmory(): Promise; + /** `GET /armory/file?path=` — one armory file's contents. */ + getArmoryFile(path: string): Promise; + /** `GET /armory/ships` — what each ship has applied. */ + listArmoryShips(): Promise; } diff --git a/packages/fleet-client/src/data/types.ts b/packages/fleet-client/src/data/types.ts index 8d9ead8..9161710 100644 --- a/packages/fleet-client/src/data/types.ts +++ b/packages/fleet-client/src/data/types.ts @@ -7,9 +7,17 @@ * instead read them straight off `treaty`'s inferred types. */ -import type { WorkspaceSummary, WorkspaceStatus } from "fleet-protocol"; +import type { ArmorySyncState, WorkspaceSummary, WorkspaceStatus } from "fleet-protocol"; export type { Repo } from "fleet-protocol"; +export type { + ArmoryEntry, + ArmoryFile, + ArmoryInstallSummary, + ArmoryManifest, + ArmorySection, + ArmorySyncState, +} from "fleet-protocol"; /** Whether the bridge currently has a live connection to a ship. */ export type ShipStatus = "online" | "offline"; @@ -43,3 +51,13 @@ export type WorkspaceEvent = /** Detail: `WorkspaceStatus` with `ship` guaranteed on both variants. */ export type WorkspaceDetail = WorkspaceStatus & { readonly ship: string }; + +/** + * A row of `GET /armory/ships`: what one ship has pulled and installed. `state` + * is null when the bridge could not ask the ship — offline, or the call failed. + */ +export interface ArmoryShipState { + readonly ship: string; + readonly status: ShipStatus; + readonly state: ArmorySyncState | null; +} diff --git a/packages/fleet-client/src/layouts/Sidebar.tsx b/packages/fleet-client/src/layouts/Sidebar.tsx index f58508c..da161fb 100644 --- a/packages/fleet-client/src/layouts/Sidebar.tsx +++ b/packages/fleet-client/src/layouts/Sidebar.tsx @@ -58,6 +58,16 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void )} + + {({ isActive }) => ( + <> + {isActive && } + + Armory + + )} + +
REPOS
diff --git a/packages/fleet-client/src/layouts/TopBar.tsx b/packages/fleet-client/src/layouts/TopBar.tsx index b4d8e82..5554ef7 100644 --- a/packages/fleet-client/src/layouts/TopBar.tsx +++ b/packages/fleet-client/src/layouts/TopBar.tsx @@ -2,9 +2,10 @@ import { useLocation } from "react-router-dom"; import { Menu } from "lucide-react"; import type { Theme } from "@/App"; -/** `bridge` / `bridge / {repo}` / `bridge / {repo} / {name}` from the URL. */ +/** `bridge` / `bridge / armory` / `bridge / {repo}` / `bridge / {repo} / {name}` from the URL. */ function breadcrumb(pathname: string): string { const parts = pathname.split("/").filter(Boolean).map(decodeURIComponent); + if (parts[0] === "armory") return "bridge / armory"; if (parts[0] === "repos" && parts[1]) { if (parts[2] === "workspaces" && parts[3]) return `bridge / ${parts[1]} / ${parts[3]}`; return `bridge / ${parts[1]}`; diff --git a/packages/fleet-client/src/lib/armory.ts b/packages/fleet-client/src/lib/armory.ts new file mode 100644 index 0000000..b92660b --- /dev/null +++ b/packages/fleet-client/src/lib/armory.ts @@ -0,0 +1,55 @@ +/** + * lib/armory.ts — pure helpers behind the Armory page. + * + * They live outside the route so the derivations an operator actually reads — + * above all "is this ship in sync?" — can be unit-tested without rendering. + */ + +import { ARMORY_SECTIONS } from "fleet-protocol"; +import type { ArmorySection, ArmorySyncState } from "@/data/types"; + +/** Group order for the file browser, straight from the protocol's own order. */ +export const SECTION_ORDER: readonly ArmorySection[] = ARMORY_SECTIONS; + +/** + * Where a ship stands against the bridge's armory. `unknown` is the bridge not + * having been able to ask (offline ship); it is deliberately distinct from + * `never synced`, which is the ship answering that it holds nothing. + */ +export type ArmorySyncStatus = "in sync" | "behind" | "never synced" | "error" | "unknown"; + +/** + * `error` outranks the revision comparison: a ship whose last sync failed is + * holding a revision it could not replace, so reporting it as merely "behind" + * would hide the reason it is stuck. + */ +export function syncStatus(bridgeRevision: string, state: ArmorySyncState | null): ArmorySyncStatus { + if (!state) return "unknown"; + if (state.lastError) return "error"; + if (!state.revision) return "never synced"; + return state.revision === bridgeRevision ? "in sync" : "behind"; +} + +/** Revisions are 64 hex characters; 12 is plenty to compare two by eye. */ +export function abbreviateRevision(revision: string | null): string { + if (!revision) return "—"; + return revision.slice(0, 12); +} + +/** The manifest path with its section prefix removed, since the section is the group heading. */ +export function stripSection(path: string, section: string): string { + return path.startsWith(`${section}/`) ? path.slice(section.length + 1) : path; +} + +export function formatBytes(size: number): string { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +/** An ISO timestamp as a local, human-readable string; `—` when there is none. */ +export function formatTimestamp(iso: string | null): string { + if (!iso) return "—"; + const at = new Date(iso); + return Number.isNaN(at.getTime()) ? iso : at.toLocaleString(); +} diff --git a/packages/fleet-client/src/routes/ArmoryRoute.tsx b/packages/fleet-client/src/routes/ArmoryRoute.tsx new file mode 100644 index 0000000..c8c7088 --- /dev/null +++ b/packages/fleet-client/src/routes/ArmoryRoute.tsx @@ -0,0 +1,356 @@ +/** + * ArmoryRoute — a read-only view of the bridge's armory: the files it hands out, + * the map that says where dotfiles land, and how far each ship has got applying + * them. + * + * The armory is edited on the bridge host, not here, so this page has no + * mutations of any kind. It is also the only page that fetches its own data — + * the armory is deliberately absent from the boot snapshot, since most sessions + * never open it. + */ + +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { cn } from "@/lib/utils"; +import { useFleet } from "@/data/FleetContext"; +import type { ArmoryEntry, ArmoryFile, ArmoryManifest, ArmoryShipState } from "@/data/types"; +import { + abbreviateRevision, + formatBytes, + formatTimestamp, + SECTION_ORDER, + stripSection, + syncStatus, + type ArmorySyncStatus, +} from "@/lib/armory"; +import { RowLabel } from "./ReposRoute"; + +const SHIP_COLS = "1fr 140px 1.4fr 130px"; +const FILE_COLS = "1fr 90px 70px"; + +const STATUS_DOT: Record = { + "in sync": "bg-accent", + behind: "bg-status-awaiting", + "never synced": "bg-dim2", + error: "bg-red-400", + unknown: "bg-dim2", +}; + +export function ArmoryRoute() { + const { getArmory, getArmoryFile, listArmoryShips } = useFleet(); + const [manifest, setManifest] = useState(null); + const [ships, setShips] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [selected, setSelected] = useState(null); + const [file, setFile] = useState(null); + const [fileLoading, setFileLoading] = useState(false); + const [fileError, setFileError] = useState(null); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const [loadedManifest, loadedShips] = await Promise.all([getArmory(), listArmoryShips()]); + if (cancelled) return; + setManifest(loadedManifest); + setShips(loadedShips); + } catch (e) { + if (!cancelled) setError((e as Error).message); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [getArmory, listArmoryShips]); + + useEffect(() => { + if (!selected) return; + let cancelled = false; + setFile(null); + setFileError(null); + setFileLoading(true); + void (async () => { + try { + const loaded = await getArmoryFile(selected); + if (!cancelled) setFile(loaded); + } catch (e) { + // Scoped to the viewer: a file that will not load must not take the + // manifest and the ship table down with it. + if (!cancelled) setFileError((e as Error).message); + } finally { + if (!cancelled) setFileLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [getArmoryFile, selected]); + + const grouped = useMemo(() => { + const entries = manifest?.entries ?? []; + return SECTION_ORDER.map((section) => ({ + section, + entries: entries.filter((e) => e.section === section), + })).filter((group) => group.entries.length > 0); + }, [manifest]); + + const dotfiles = Object.entries(manifest?.dotfileMap ?? {}); + + return ( +
+ + ← bridge + + +
+
+

▤ Armory

+

+ Skills, plugins and dotfiles edited (or git-synced) in the bridge's data directory and distributed + to every ship. This page only reads them — change them on the bridge host. +

+
+
+ REVISION + + {manifest ? abbreviateRevision(manifest.revision) : "—"} + +
+
+ + {loading &&

loading armory…

} + {error &&

{error}

} + + {manifest && ( +
+ + +
+ {grouped.length === 0 && ( +
+ No armory files. Add them under armory/ in the bridge's data + directory. +
+ )} + {grouped.map((group, index) => ( +
+
0 && "border-t border-line", + )} + style={{ gridTemplateColumns: FILE_COLS }} + > + {group.section.toUpperCase()} + SIZE + MODE +
+
0 && "border-t border-line", + )} + > + {group.section.toUpperCase()} +
+ {group.entries.map((entry) => ( + setSelected(entry.path)} + /> + ))} +
+ ))} +
+ + {selected && ( + + )} + +
+ {dotfiles.length === 0 ? ( +
+ No dotfile map — nothing under dotfiles/ is linked into a + ship's home directory. +
+ ) : ( + dotfiles.map(([source, destination]) => ( +
+ + SOURCE + dotfiles/{source} + + + DESTINATION + {destination} + +
+ )) + )} +
+
+ )} +
+ ); +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function ShipSyncTable({ ships, revision }: { ships: ArmoryShipState[]; revision: string }) { + return ( +
+
+ SHIP + REVISION + SYNCED + STATUS +
+ + {ships.length === 0 && ( +
+ No ships registered yet. +
+ )} + + {ships.map((row) => { + const status = syncStatus(revision, row.state); + const install = row.state?.install; + return ( +
+
+ ▦ {row.ship} + + REVISION + {abbreviateRevision(row.state?.revision ?? null)} + + + SYNCED + {formatTimestamp(row.state?.syncedAt ?? null)} + + + STATUS + + {status} + +
+ + {install && ( +
+ {install.skillCount} skill files · {install.pluginCount} plugin files · {install.dotfileCount}{" "} + dotfiles · {install.removedCount} removed · installed {formatTimestamp(install.installedAt)} +
+ )} + {row.state?.lastError && ( +

{row.state.lastError}

+ )} + {install?.conflicts.map((conflict) => ( +

+ conflict: {conflict} +

+ ))} + {install?.warnings.map((warning) => ( +

+ warning: {warning} +

+ ))} +
+ ); + })} +
+ ); +} + +function FileRow({ + entry, + selected, + onSelect, +}: { + entry: ArmoryEntry; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function FileViewer({ + path, + file, + loading, + error, +}: { + path: string; + file: ArmoryFile | null; + loading: boolean; + error: string | null; +}) { + return ( +
+
+ {path} + {file && ( + + {formatBytes(file.size)} · {file.encoding} · sha256 {abbreviateRevision(file.sha256)} + + )} +
+ + {loading &&

loading…

} + {error &&

{error}

} + + {file && + (file.encoding === "base64" ? ( +
+ binary file, {formatBytes(file.size)} — not shown. +
sha256 {file.sha256}
+
+ ) : ( +
+            {file.contents}
+          
+ ))} +
+ ); +} diff --git a/packages/fleet-client/tests/armory-data.test.ts b/packages/fleet-client/tests/armory-data.test.ts new file mode 100644 index 0000000..f23abe3 --- /dev/null +++ b/packages/fleet-client/tests/armory-data.test.ts @@ -0,0 +1,233 @@ +/** + * armory-data.test.ts — the Armory page's data layer: the three Eden calls it + * makes, the mock fixture it is developed against, and the pure helpers that + * decide what an operator reads on the page. + * + * The Eden half runs against a recording `Bun.serve`, so route, method and query + * are asserted as they go over the wire rather than through the treaty's types. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { EdenFleetBridge } from "../src/data/eden"; +import { makeBridgeClient } from "../src/data/client"; +import { MockFleetBridge } from "../src/data/mock"; +import { abbreviateRevision, formatBytes, stripSection, syncStatus } from "../src/lib/armory"; +import type { ArmoryFile, ArmoryManifest, ArmoryShipState, ArmorySyncState } from "../src/data/types"; + +const REVISION = "a".repeat(64); +const OTHER_REVISION = "b".repeat(64); + +const MANIFEST: ArmoryManifest = { + revision: REVISION, + entries: [ + { path: "dotfiles/tmux.conf", section: "dotfiles", size: 12, sha256: "c".repeat(64), mode: 0o644 }, + { path: "skills/one/SKILL.md", section: "skills", size: 7, sha256: "d".repeat(64), mode: 0o644 }, + ], + dotfileMap: { "tmux.conf": "~/.tmux.conf" }, +}; + +const FILE: ArmoryFile = { + path: "skills/one/SKILL.md", + section: "skills", + size: 7, + sha256: "d".repeat(64), + mode: 0o644, + encoding: "utf8", + contents: "# skill", +}; + +const SHIP_STATES: ArmoryShipState[] = [ + { ship: "forge-01", status: "online", state: null }, + { ship: "forge-02", status: "offline", state: null }, +]; + +describe("EdenFleetBridge armory reads", () => { + let server: ReturnType; + let requests: { method: string; path: string; query: Record; search: string }[]; + let bridge: EdenFleetBridge; + /** Overrides the canned body for a path, to drive the failure cases. */ + let override: Map; + + beforeEach(() => { + requests = []; + override = new Map(); + server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push({ + method: request.method, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + search: url.search, + }); + if (override.has(url.pathname)) return Response.json(override.get(url.pathname)); + if (url.pathname === "/armory") return Response.json(MANIFEST); + if (url.pathname === "/armory/file") return Response.json(FILE); + if (url.pathname === "/armory/ships") return Response.json(SHIP_STATES); + return Response.json({ error: "unexpected route" }, { status: 404 }); + }, + }); + bridge = new EdenFleetBridge(makeBridgeClient(`http://localhost:${server.port}`)); + }); + + afterEach(() => server.stop(true)); + + test("getArmory GETs /armory and returns the manifest", async () => { + expect(await bridge.getArmory()).toEqual(MANIFEST); + expect(requests).toEqual([{ method: "GET", path: "/armory", query: {}, search: "" }]); + }); + + test("getArmoryFile GETs /armory/file with the path as a query parameter", async () => { + expect(await bridge.getArmoryFile("skills/one/SKILL.md")).toEqual(FILE); + expect(requests).toEqual([ + { + method: "GET", + path: "/armory/file", + query: { path: "skills/one/SKILL.md" }, + search: requests[0]!.search, + }, + ]); + }); + + test("getArmoryFile encodes a path with separators and a space", async () => { + await bridge.getArmoryFile("dotfiles/nvim/my config.lua"); + + expect(requests[0]!.query).toEqual({ path: "dotfiles/nvim/my config.lua" }); + // A raw space would be an invalid request line; the treaty must escape it. + expect(requests[0]!.search).not.toContain(" "); + }); + + test("listArmoryShips GETs /armory/ships and returns the rows", async () => { + expect(await bridge.listArmoryShips()).toEqual(SHIP_STATES); + expect(requests).toEqual([{ method: "GET", path: "/armory/ships", query: {}, search: "" }]); + }); + + test("an in-band { error } body on a 200 throws rather than returning it", async () => { + override.set("/armory", { error: "armory/dotfile-map.json is not valid JSON" }); + override.set("/armory/file", { error: "no such armory file" }); + override.set("/armory/ships", { error: "bridge is shutting down" }); + + await expect(bridge.getArmory()).rejects.toThrow("fleet-bridge request failed"); + await expect(bridge.getArmoryFile("skills/one/SKILL.md")).rejects.toThrow("fleet-bridge request failed"); + await expect(bridge.listArmoryShips()).rejects.toThrow("fleet-bridge request failed"); + }); +}); + +describe("MockFleetBridge armory fixture", () => { + test("every manifest entry can be read back with matching facts", async () => { + const mock = new MockFleetBridge(); + const manifest = await mock.getArmory(); + + expect(manifest.entries.length).toBeGreaterThan(0); + expect(manifest.revision).toMatch(/^[0-9a-f]{64}$/); + for (const entry of manifest.entries) { + const file = await mock.getArmoryFile(entry.path); + expect(file).toMatchObject(entry); + expect(file.sha256).toMatch(/^[0-9a-f]{64}$/); + } + }); + + test("entries are sorted by path and cover all three sections", async () => { + const paths = (await new MockFleetBridge().getArmory()).entries.map((e) => e.path); + + expect(paths).toEqual([...paths].sort()); + for (const section of ["skills", "plugins", "dotfiles"]) { + expect(paths.some((p) => p.startsWith(`${section}/`))).toBe(true); + } + }); + + test("every dotfileMap key has a matching dotfiles/ entry", async () => { + const manifest = await new MockFleetBridge().getArmory(); + const paths = new Set(manifest.entries.map((e) => e.path)); + + expect(Object.keys(manifest.dotfileMap).length).toBeGreaterThan(0); + for (const source of Object.keys(manifest.dotfileMap)) { + expect(paths.has(`dotfiles/${source}`)).toBe(true); + } + }); + + test("the fixture carries a binary file, so the viewer's placeholder is reachable", async () => { + const mock = new MockFleetBridge(); + const manifest = await mock.getArmory(); + const files = await Promise.all(manifest.entries.map((e) => mock.getArmoryFile(e.path))); + + expect(files.some((f) => f.encoding === "base64")).toBe(true); + }); + + test("ship states cover in sync, behind and never synced", async () => { + const mock = new MockFleetBridge(); + const manifest = await mock.getArmory(); + const rows = await mock.listArmoryShips(); + + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + if (row.state) expect(row.state.revision === null || typeof row.state.revision === "string").toBe(true); + } + const statuses = rows.map((row) => syncStatus(manifest.revision, row.state)); + expect(statuses).toContain("in sync"); + expect(statuses).toContain("behind"); + expect(statuses).toContain("never synced"); + expect(statuses).toContain("error"); + }); + + test("getArmoryFile rejects an unknown path", async () => { + await expect(new MockFleetBridge().getArmoryFile("skills/nope.md")).rejects.toThrow("armory file not found"); + }); + + test("a ship registered during the session has no armory state", async () => { + const mock = new MockFleetBridge(); + await mock.createShip("http://new-ship:4800"); + + const row = (await mock.listArmoryShips()).find((r) => r.ship === "new-ship"); + expect(row).toEqual({ ship: "new-ship", status: "online", state: null }); + }); +}); + +describe("syncStatus", () => { + const state = (patch: Partial): ArmorySyncState => ({ + revision: REVISION, + bridgeUrl: "http://bridge:4800", + syncedAt: "2026-07-26T00:00:00.000Z", + fileCount: 3, + install: null, + lastError: null, + ...patch, + }); + + test("matching revisions are in sync, differing ones are behind", () => { + expect(syncStatus(REVISION, state({}))).toBe("in sync"); + expect(syncStatus(REVISION, state({ revision: OTHER_REVISION }))).toBe("behind"); + }); + + test("a null revision is never synced", () => { + expect(syncStatus(REVISION, state({ revision: null }))).toBe("never synced"); + }); + + test("lastError outranks the revision comparison", () => { + expect(syncStatus(REVISION, state({ lastError: "pull failed" }))).toBe("error"); + expect(syncStatus(REVISION, state({ revision: null, lastError: "pull failed" }))).toBe("error"); + }); + + test("a ship the bridge could not ask is unknown", () => { + expect(syncStatus(REVISION, null)).toBe("unknown"); + }); +}); + +describe("armory display helpers", () => { + test("abbreviateRevision takes the first 12 characters and handles null", () => { + expect(abbreviateRevision(REVISION)).toBe("a".repeat(12)); + expect(abbreviateRevision(null)).toBe("—"); + }); + + test("stripSection removes the group prefix and leaves anything else alone", () => { + expect(stripSection("skills/one/SKILL.md", "skills")).toBe("one/SKILL.md"); + expect(stripSection("one/SKILL.md", "skills")).toBe("one/SKILL.md"); + }); + + test("formatBytes scales past a kilobyte", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(2048)).toBe("2.0 KB"); + expect(formatBytes(3 * 1024 * 1024)).toBe("3.0 MB"); + }); +}); diff --git a/packages/fleet-protocol/index.ts b/packages/fleet-protocol/index.ts index 0fe04a9..ee71327 100644 --- a/packages/fleet-protocol/index.ts +++ b/packages/fleet-protocol/index.ts @@ -39,6 +39,27 @@ export type { export type { SystemResources } from "./src/system"; export { RepoSchema, CreateRepoInputSchema, type Repo, type CreateRepoInput } from "./src/repo"; export { ShipSchema, type Ship } from "./src/ship"; +export { + ARMORY_SECTIONS, + ARMORY_DIRECTORY, + DOTFILE_MAP_FILENAME, + isSafeArmoryPath, + ArmoryEntrySchema, + DotfileMapSchema, + ArmoryManifestSchema, + ArmoryFileSchema, + ArmorySyncRequestSchema, + ArmorySyncStateSchema, + ArmoryInstallSummarySchema, + type ArmorySection, + type ArmoryEntry, + type DotfileMap, + type ArmoryManifest, + type ArmoryFile, + type ArmorySyncRequest, + type ArmorySyncState, + type ArmoryInstallSummary, +} from "./src/armory"; export { SyncEventSchema, diff --git a/packages/fleet-protocol/src/armory.ts b/packages/fleet-protocol/src/armory.ts new file mode 100644 index 0000000..5f591e6 --- /dev/null +++ b/packages/fleet-protocol/src/armory.ts @@ -0,0 +1,195 @@ +/** + * src/armory.ts — the Armory contract: a bridge-owned directory of files the + * fleet's ships install. + * + * A human hand-edits (or git-syncs) `/armory/`: + * + * armory/ + * skills//SKILL.md plus any extra files the skill needs + * plugins//... one arbitrary tree per agent provider + * dotfiles/... arbitrary files and directories + * dotfile-map.json `dotfiles/`-relative source → destination + * + * The bridge scans that tree into an `ArmoryManifest`, whose `revision` is a + * content address: it changes iff a file's contents, mode, or path changes, or + * the dotfile map changes. Ships compare revisions to decide whether to re-pull, + * so `revision` must be a pure function of armory content — never of scan time, + * host paths, or filesystem ordering. + * + * Paths inside the manifest are always POSIX-separated and relative to the + * armory root (`skills/my-skill/SKILL.md`). `isSafeArmoryPath` is the single + * shared validator for them; both the bridge (when serving) and the ship (when + * installing) apply it, because a manifest is untrusted input on the ship side. + */ + +import { z } from "zod"; + +/** Top-level directories of the armory. Anything else at the root is ignored. */ +export const ARMORY_SECTIONS = ["skills", "plugins", "dotfiles"] as const; + +export type ArmorySection = (typeof ARMORY_SECTIONS)[number]; + +/** The armory's directory name, relative to the bridge's `dataDirectory`. */ +export const ARMORY_DIRECTORY = "armory"; + +/** The dotfile source→destination map, at the armory root. */ +export const DOTFILE_MAP_FILENAME = "dotfile-map.json"; + +const MAX_ARMORY_PATH_BYTES = 1024; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; +const utf8 = new TextEncoder(); + +/** + * Whether `path` is safe to join onto an armory root on either side of the wire. + * + * Rejects: empty, absolute (`/`-leading or `C:`-style), any `\` (so a Windows + * separator can never smuggle a segment past the `/` split), `.`/`..`/empty + * segments, control characters (NUL included), and anything over 1 KiB. + * A single path segment is a valid input too — the scanner checks directory + * entry names with it. + */ +export function isSafeArmoryPath(path: string): boolean { + if (path.length === 0) return false; + if (utf8.encode(path).byteLength > MAX_ARMORY_PATH_BYTES) return false; + if (path.includes("\\")) return false; + if (path.startsWith("/")) return false; + if (/^[A-Za-z]:/.test(path)) return false; + if (CONTROL_CHARACTERS.test(path)) return false; + return path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".."); +} + +/** A destination is only meaningful if it is home-rooted or absolute. */ +function isSafeDotfileDestination(destination: string): boolean { + if (CONTROL_CHARACTERS.test(destination)) return false; + return destination.startsWith("~/") || destination.startsWith("/"); +} + +const ArmoryFileFactsSchema = z.object({ + /** POSIX-separated, relative to the armory root, e.g. `skills/my-skill/SKILL.md`. */ + path: z.string().min(1).refine(isSafeArmoryPath, "must be a safe armory-relative path"), + section: z.enum(ARMORY_SECTIONS), + size: z.number().int().nonnegative(), + /** Lowercase hex sha256 of the file's bytes. */ + sha256: z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256"), + /** Normalized to `0o755` (executable) or `0o644`; host mode bits never leak. */ + mode: z.number().int(), +}); + +export const ArmoryEntrySchema = ArmoryFileFactsSchema; + +export type ArmoryEntry = z.infer; + +/** + * Keys are `armory/dotfiles/`-relative sources; values are `~/`-rooted or absolute + * destinations. + * + * Every issue is reported against the offending key and names which side of the + * pair is broken, because this schema validates a hand-edited file and its errors + * are read by whoever has to go and fix it. That is also why the value type is + * checked *inside* the refinement over a permissive base rather than by a + * `z.string()` value schema: zod skips refinements once the base parse fails, so + * a single mistyped value would otherwise hide every other bad entry in the file. + */ +export const DotfileMapSchema = z + .record(z.string().min(1), z.unknown()) + .superRefine((map, ctx) => { + for (const [source, destination] of Object.entries(map)) { + if (!isSafeArmoryPath(source)) { + ctx.addIssue({ + code: "custom", + path: [source], + message: `source "${source}" must be a relative path under dotfiles/ with no "..", "." or "\\" segments`, + }); + } + if (typeof destination !== "string" || destination.length === 0) { + ctx.addIssue({ + code: "custom", + path: [source], + message: `destination must be a non-empty string, not ${destination === null ? "null" : typeof destination}`, + }); + continue; + } + if (!isSafeDotfileDestination(destination)) { + ctx.addIssue({ + code: "custom", + path: [source], + message: `destination "${destination}" must start with "~/" or be absolute`, + }); + } + } + }) + // Sound only because the refinement above rejected every non-string value. + .transform((map) => map as Record); + +export type DotfileMap = z.infer; + +export const ArmoryManifestSchema = z.object({ + /** Content address of the whole armory: lowercase hex sha256. */ + revision: z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256"), + /** Every scanned file, sorted by `path`. */ + entries: ArmoryEntrySchema.array(), + dotfileMap: DotfileMapSchema, +}); + +export type ArmoryManifest = z.infer; + +/** One file's contents, carrying the same facts the manifest reports for it. */ +export const ArmoryFileSchema = ArmoryFileFactsSchema.extend({ + /** `utf8` when the bytes decode as text; `base64` for anything binary. */ + encoding: z.enum(["utf8", "base64"]), + contents: z.string(), +}); + +export type ArmoryFile = z.infer; + +/** + * Body of the bridge's `POST /armory/sync` push to a ship. + * + * A ship holds no bridge address of its own, so `bridgeUrl` is how it learns + * where to pull from — and it only ever pulls from a bridge that has spoken to + * it. `revision` is a hint that something changed, not an instruction: the ship + * applies whatever revision the manifest it fetches reports, because the armory + * may change again between this push and that fetch. + */ +export const ArmorySyncRequestSchema = z.object({ + bridgeUrl: z.url(), + revision: z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256"), +}); + +export type ArmorySyncRequest = z.infer; + +/** + * What a ship's most recent armory *install* applied, as opposed to what it + * pulled. Counts are files, not skills or plugins: a skill is a directory and a + * plugin is an arbitrary tree, so files are the only unit both share. + */ +export const ArmoryInstallSummarySchema = z.object({ + skillCount: z.number().int().nonnegative(), + pluginCount: z.number().int().nonnegative(), + /** Dotfile symlinks in place; a conflicted or skipped mapping is not one. */ + dotfileCount: z.number().int().nonnegative().default(0), + /** Files uninstalled because the armory no longer carries them. */ + removedCount: z.number().int().nonnegative(), + /** Destinations left alone because something unmanaged was already there. */ + conflicts: z.string().array(), + warnings: z.string().array(), + installedAt: z.string().nullable(), +}); + +export type ArmoryInstallSummary = z.infer; + +/** What a ship reports about its armory cache. */ +export const ArmorySyncStateSchema = z.object({ + /** The applied revision; `null` until the first successful sync. */ + revision: z.string().nullable(), + bridgeUrl: z.string().nullable(), + /** ISO timestamp of the last successful sync. */ + syncedAt: z.string().nullable(), + fileCount: z.number().int().nonnegative(), + /** The last install applied from the cache; `null` until one has run. */ + install: ArmoryInstallSummarySchema.nullable().default(null), + /** Message of the most recent failed sync or install, cleared by the next success. */ + lastError: z.string().nullable(), +}); + +export type ArmorySyncState = z.infer; diff --git a/packages/fleet-protocol/src/config.ts b/packages/fleet-protocol/src/config.ts index d3f52c7..d85f32b 100644 --- a/packages/fleet-protocol/src/config.ts +++ b/packages/fleet-protocol/src/config.ts @@ -1,7 +1,8 @@ /** * src/config.ts — the Fleet Ship configuration contract. * - * A ship is configured from CLI flags (`fleet ship --port --name --fleet-directory`). + * A ship is configured from CLI flags (`fleet ship --port --name --fleet-directory + * --bridge-url`). * The canonical shape is the zod schema below; the host assembles an object from the * flags then validates it against `FleetShipConfigSchema`, and `FleetShipConfig` is * inferred from it so the type and the runtime validator can never drift. @@ -18,6 +19,13 @@ export const FleetShipConfigSchema = z.object({ port: z.number().int(), /** Human-facing name of this ship (surfaced as `ship` on active workspace status). */ name: FleetIdentifierSchema, + /** + * The only bridge this ship accepts armory pushes from: a `POST /armory/sync` + * naming any other origin is refused before anything is fetched. Left unset, + * the ship pins whichever bridge pushes to it first and holds that from then + * on, so a hand-started ship needs no extra flag. + */ + bridgeUrl: z.url().optional(), }); /** The ship configuration, inferred from the schema. */ diff --git a/packages/fleet-ship/src/api/armory.ts b/packages/fleet-ship/src/api/armory.ts new file mode 100644 index 0000000..e18a7c4 --- /dev/null +++ b/packages/fleet-ship/src/api/armory.ts @@ -0,0 +1,46 @@ +/** + * api/armory.ts — the ship's armory routes: the bridge pushes `/armory/sync` to + * say "re-pull and re-install", and anyone can read back what this ship + * currently has cached and applied. + * One Elysia chain so route types stay inferable for Eden. + */ + +import { Elysia, t } from "elysia"; +import { ArmoryCache, ArmorySyncError } from "../armory/armory-cache"; +import { syncAndInstall } from "../armory/armory-sync"; +import { mapError } from "./http"; + +export function armoryPlugin(cache: ArmoryCache) { + return new Elysia({ name: "ship-armory" }) + .post( + "/armory/sync", + async ({ body, set }) => { + try { + return await syncAndInstall(cache, body); + } catch (err) { + const mapped = mapArmoryError(err); + set.status = mapped.status; + return mapped.body; + } + }, + { body: t.Object({ bridgeUrl: t.String(), revision: t.String() }) }, + ) + .get("/armory", async ({ set }) => { + try { + return await cache.state(); + } catch (err) { + const mapped = mapError(err); + set.status = mapped.status; + return mapped.body; + } + }); +} + +/** + * A failed pull is the bridge's fault (502), the push body's (400), or a push + * from a bridge this ship is not pinned to (403) — never a plain 500. + */ +function mapArmoryError(err: unknown): { status: number; body: { error: string } } { + if (err instanceof ArmorySyncError) return { status: err.status, body: { error: err.message } }; + return mapError(err); +} diff --git a/packages/fleet-ship/src/api/index.ts b/packages/fleet-ship/src/api/index.ts index c72e07a..17a5c2a 100644 --- a/packages/fleet-ship/src/api/index.ts +++ b/packages/fleet-ship/src/api/index.ts @@ -1,7 +1,7 @@ /** - * api/index.ts — composes the ship's Elysia app from its two plugins. + * api/index.ts — composes the ship's Elysia app from its route plugins. * - * Both plugins are single Elysia chains, so `.use()` merges their route types + * Each plugin is a single Elysia chain, so `.use()` merges its route types * into the parent and `App = ReturnType` carries the full * merged surface for the CLI's Eden `treaty` client. */ @@ -12,20 +12,26 @@ import type { FleetShipConfig } from "fleet-protocol"; import { workspacesPlugin } from "./workspaces"; import { eventsPlugin } from "./events"; import { systemResourcesPlugin } from "./system-resources"; +import { armoryPlugin } from "./armory"; +import { ArmoryCache } from "../armory/armory-cache"; import { Logestic } from "logestic"; import { MAX_CLIENT_FRAME_BYTES, type TerminalBridge } from "webterm"; export function createApp( manager: WorkspaceManager, - _config: FleetShipConfig, + config: FleetShipConfig, createTerminal?: (options: ConstructorParameters[0]) => Pick, terminalInitTimeoutMs?: number, + armory?: ArmoryCache, ) { return new Elysia({ websocket: { maxPayloadLength: MAX_CLIENT_FRAME_BYTES } }) .use(Logestic.preset("commontz")) .use(workspacesPlugin(manager, createTerminal, terminalInitTimeoutMs)) .use(eventsPlugin(manager)) .use(systemResourcesPlugin()) + // The default cache carries the configured bridge pin; a caller passing its + // own cache (tests) has already decided what that cache accepts. + .use(armoryPlugin(armory ?? new ArmoryCache({ bridgeUrl: config.bridgeUrl }))) } diff --git a/packages/fleet-ship/src/armory/armory-cache.ts b/packages/fleet-ship/src/armory/armory-cache.ts new file mode 100644 index 0000000..e6ac397 --- /dev/null +++ b/packages/fleet-ship/src/armory/armory-cache.ts @@ -0,0 +1,517 @@ +/** + * armory/armory-cache.ts — the ship's local mirror of a bridge's armory. + * + * The bridge pushes `POST /armory/sync {bridgeUrl, revision}`; this class does + * the pulling. It caches files and nothing else: turning the cache into + * installed skills, plugins, and dotfiles is a separate concern that reads from + * here (armory-installer.ts, wired up by armory-sync.ts). The one thing it + * keeps on that installer's behalf is the summary it reports back, so a single + * `state.json` answers "what did this ship pull, and what came of it". + * + * /.config/autosmith/fleet-ship/armory/ + * files/ mirrors the bridge's armory tree + * state.json the applied revision and its entry list + * + * The location is deliberate. It is *not* under the ship's `fleetDirectory`, + * where `WorkspaceManager` enumerates every top-level directory as a candidate + * repo and would walk the cache as if it were one. It is under HOME because + * that is the root the shared managed-file machinery validates every path + * against, so the installers built on top of this cache need no new roots. + * + * A manifest is untrusted network input: every path is re-validated with + * `isSafeArmoryPath`, every destination is proved a strict descendant of + * `files/`, and every downloaded body is verified against the manifest's sha256 + * before it lands. A single bad file fails the whole sync — a half-applied + * armory must never be recorded under a revision that promises all of it. + * + * The push also chooses *which* bridge to pull from, so it is pinned: see + * `requirePinnedBridge` for what that does and does not protect against. + */ + +import { chmod, lstat, mkdir, readdir, rename, rm, rmdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, relative, resolve, sep } from "node:path"; +import { z } from "zod"; +import { + ArmoryEntrySchema, + ArmoryFileSchema, + ArmoryInstallSummarySchema, + ArmoryManifestSchema, + ArmorySyncRequestSchema, + DotfileMapSchema, + isSafeArmoryPath, + type ArmoryEntry, + type ArmoryInstallSummary, + type ArmorySyncRequest, + type ArmorySyncState, + type DotfileMap, +} from "fleet-protocol"; + +/** The cache root, relative to the home directory. */ +const CACHE_RELATIVE_PATH = join(".config", "autosmith", "fleet-ship", "armory"); + +/** Where `ArmoryCache` keeps its mirror, for the installer that reads it back. */ +export function armoryCacheDirectory(homeDirectory: string): string { + return join(resolve(homeDirectory), CACHE_RELATIVE_PATH); +} + +/** + * The dotfile map of the last pull. It lives in `state.json` rather than in + * `files/` because it is manifest metadata, not an armory file: the bridge may + * serve a map naming sources the ship never caches, and the dotfile linker must + * act on exactly the map that came with the revision it is installing. + */ +export async function cachedDotfileMap(cacheDirectory: string): Promise { + return (await readCachedState(resolve(cacheDirectory))).dotfileMap; +} + +/** A sync that failed, carrying the status the ship's route should answer with. */ +export class ArmorySyncError extends Error { + constructor( + message: string, + /** + * 400 for a malformed push, 403 for a push naming a bridge this ship is not + * pinned to, 502 for anything the bridge did or served. + */ + readonly status = 502, + ) { + super(message); + this.name = "ArmorySyncError"; + } +} + +/** + * `state.json`. A superset of the reported `ArmorySyncState`: it also keeps the + * entry list of the applied revision, which is what lets an unchanged push skip + * the pull without re-hashing the whole cache. + */ +const CachedStateSchema = z.object({ + revision: z.string().nullable(), + bridgeUrl: z.string().nullable(), + syncedAt: z.string().nullable(), + entries: ArmoryEntrySchema.array(), + /** Defaulted so a `state.json` written before dotfiles existed still parses. */ + dotfileMap: DotfileMapSchema.default({}), + /** Recorded by whoever installs from the cache; the cache never produces it. */ + install: ArmoryInstallSummarySchema.nullable().default(null), + lastError: z.string().nullable(), +}); + +type CachedState = z.infer; + +const EMPTY_STATE: CachedState = { + revision: null, + bridgeUrl: null, + syncedAt: null, + entries: [], + dotfileMap: {}, + install: null, + lastError: null, +}; + +/** + * A bridge URL reduced to the identity two pushes are compared on: scheme, host, + * port, and path, with any trailing slash dropped. `new URL` already lowercases + * the scheme and host and drops the port when it is the scheme's default, so + * `http://Bridge:4800/` and `http://bridge:4800` normalize alike. Query and + * fragment are not part of a bridge's identity and are discarded. + * + * Returns `null` for anything that is not a URL, which can only ever compare + * unequal — an unparseable value must not be able to match a pinned bridge. + */ +export function normalizeBridgeUrl(bridgeUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(bridgeUrl); + } catch { + return null; + } + return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, "")}`; +} + +/** Missing or unreadable is simply a cold cache; the next sync rebuilds it. */ +async function readCachedState(root: string): Promise { + try { + const parsed = CachedStateSchema.safeParse(await Bun.file(join(root, "state.json")).json()); + return parsed.success ? parsed.data : EMPTY_STATE; + } catch { + return EMPTY_STATE; + } +} + +export class ArmoryCache { + readonly homeDirectory: string; + /** The cache root, so an installer can find `files/` without recomputing it. */ + readonly cacheDirectory: string; + private readonly root: string; + private readonly filesRoot: string; + private readonly statePath: string; + private readonly fetchImpl: typeof fetch; + /** The ship's configured bridge, if it has one; see `requirePinnedBridge`. */ + private readonly configuredBridgeUrl?: string; + private queue: Promise = Promise.resolve(); + + constructor(options?: { homeDirectory?: string; fetch?: typeof fetch; bridgeUrl?: string }) { + this.homeDirectory = resolve(options?.homeDirectory ?? homedir()); + this.root = armoryCacheDirectory(this.homeDirectory); + this.cacheDirectory = this.root; + this.filesRoot = join(this.root, "files"); + this.statePath = join(this.root, "state.json"); + this.fetchImpl = options?.fetch ?? fetch; + this.configuredBridgeUrl = options?.bridgeUrl; + } + + private serialized(operation: () => Promise | T): Promise { + const result = this.queue.then(operation, operation); + this.queue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + /** The last recorded state; zeroed when nothing has ever synced. */ + async state(): Promise { + return reported(await this.readState()); + } + + /** + * Pull the armory from `bridgeUrl` and make the cache match it exactly. + * Rejects (recording `lastError` first) if anything about the pull is wrong. + */ + async sync(request: ArmorySyncRequest): Promise { + const parsed = ArmorySyncRequestSchema.safeParse(request); + if (!parsed.success) { + throw new ArmorySyncError(`invalid armory sync request: ${formatIssues(parsed.error)}`, 400); + } + + return this.serialized(async () => { + const previous = await this.readState(); + // Before the try: a refused push is not this ship's failure, so it must + // not land in `lastError` and make an in-sync ship report as broken. + this.requirePinnedBridge(parsed.data.bridgeUrl, previous.bridgeUrl); + try { + const next = await this.pull(parsed.data, previous); + await this.writeState(next); + return reported(next); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Best-effort: the sync's own failure is what the caller must see, so a + // failure to record it must not replace it. + await this.writeState({ ...previous, lastError: message }).catch(() => {}); + throw error; + } + }); + } + + /** + * Record what an installer made of the cache. `lastError` is set separately + * from `install` so a failed install can be reported without erasing the + * successful pull that preceded it. + */ + async recordInstall( + install: ArmoryInstallSummary | null, + lastError: string | null = null, + ): Promise { + return this.serialized(async () => { + const next: CachedState = { ...(await this.readState()), install, lastError }; + await this.writeState(next); + return reported(next); + }); + } + + /** + * Refuse a push naming any bridge but the one this ship is pinned to, before + * a single byte is fetched. + * + * What this buys: a push decides which server the ship downloads skills, + * plugins, and dotfiles from and installs into the agent config directories of + * whoever runs the ship. Letting the caller choose that server is gratuitous, + * and pinning takes it away. + * + * What it does not buy: the ship's API is unauthenticated, so anyone who can + * reach the port can still make the ship re-pull from its *real* bridge. That + * is harmless — it installs exactly what the operator already publishes. This + * is defence in depth, not authentication; a ship's API must still never be + * exposed to an untrusted network. + * + * The pin is the configured `bridgeUrl` when there is one, and otherwise + * trust-on-first-use: the first push's bridge is recorded in `state.json` and + * every later push must match it. That keeps a hand-started ship usable with + * no extra flag, at the cost of the very first push being unchecked. + */ + private requirePinnedBridge(offered: string, recorded: string | null): void { + const expected = this.configuredBridgeUrl ?? recorded; + if (expected === null || expected === undefined) return; + + const wanted = normalizeBridgeUrl(expected); + if (wanted !== null && wanted === normalizeBridgeUrl(offered)) return; + + throw new ArmorySyncError( + `armory push refused: this ship is pinned to bridge ${expected} but the push named ${offered}` + + (this.configuredBridgeUrl === undefined + ? "; the pin is the first bridge that pushed to this ship" + : "; the pin is this ship's configured --bridge-url"), + 403, + ); + } + + private async pull(request: ArmorySyncRequest, previous: CachedState): Promise { + const base = this.baseUrl(request.bridgeUrl); + const manifest = await this.fetchManifest(base); + + for (const entry of manifest.entries) { + if (!isSafeArmoryPath(entry.path)) { + throw new ArmorySyncError(`bridge served an unsafe armory path: ${entry.path}`); + } + } + + const applied: CachedState = { + revision: manifest.revision, + bridgeUrl: request.bridgeUrl, + syncedAt: new Date().toISOString(), + entries: manifest.entries, + dotfileMap: manifest.dotfileMap, + install: previous.install, + lastError: null, + }; + + // The revision covers the map too, so an unchanged revision cannot have + // brought a new one; `applied` still carries it forward for the installer. + if (manifest.revision === previous.revision && sameEntries(manifest.entries, previous.entries)) { + return applied; + } + + await mkdir(this.filesRoot, { recursive: true }); + for (const entry of manifest.entries) await this.materialize(base, entry); + await this.prune(new Set(manifest.entries.map((entry) => entry.path))); + return applied; + } + + /** Bring one entry's file on disk in line with the manifest. */ + private async materialize(base: string, entry: ArmoryEntry): Promise { + const target = resolve(this.filesRoot, entry.path); + if (!isStrictDescendant(this.filesRoot, target)) { + throw new ArmorySyncError(`armory path escapes the cache: ${entry.path}`); + } + + const current = await lstat(target).catch(() => undefined); + if (current?.isFile() && current.size === entry.size && (await hashFile(target)) === entry.sha256) { + return; + } + // A directory (or symlink) where a file now belongs would defeat the rename + // below; pruning runs too late to clear it. Confined to `files/`. + if (current && !current.isFile()) await rm(target, { recursive: true, force: true }); + + const bytes = await this.fetchFile(base, entry); + await this.ensureParent(entry.path); + await atomicWrite(target, bytes, entry.mode); + } + + /** + * `mkdir -p` for an entry's parent, clearing anything non-directory in the + * way. An armory that turns `skills/one` from a file into a directory would + * otherwise wedge every future sync. Confined to `files/`. + */ + private async ensureParent(path: string): Promise { + const segments = path.split("/").slice(0, -1); + let directory = this.filesRoot; + for (const segment of segments) { + directory = join(directory, segment); + const info = await lstat(directory).catch(() => undefined); + if (info && !info.isDirectory()) await rm(directory, { recursive: true, force: true }); + } + await mkdir(directory, { recursive: true }); + } + + private async fetchManifest(base: string) { + const body = await this.getJson(`${base}/armory`, "manifest"); + const parsed = ArmoryManifestSchema.safeParse(body); + if (!parsed.success) { + throw new ArmorySyncError(`bridge served an invalid armory manifest: ${formatIssues(parsed.error)}`); + } + return parsed.data; + } + + /** The entry's verified bytes, decoded from whichever encoding the bridge used. */ + private async fetchFile(base: string, entry: ArmoryEntry): Promise { + const url = `${base}/armory/file?path=${encodeURIComponent(entry.path)}`; + const body = await this.getJson(url, `file ${entry.path}`); + const parsed = ArmoryFileSchema.safeParse(body); + if (!parsed.success) { + throw new ArmorySyncError(`bridge served an invalid armory file for ${entry.path}: ${formatIssues(parsed.error)}`); + } + if (parsed.data.path !== entry.path) { + throw new ArmorySyncError( + `bridge served ${parsed.data.path} when asked for ${entry.path}`, + ); + } + + // Undecodable base64 needs no separate check: `Buffer.from` drops what it + // cannot read, and the hash below rejects whatever comes out. + const bytes = + parsed.data.encoding === "base64" + ? new Uint8Array(Buffer.from(parsed.data.contents, "base64")) + : new TextEncoder().encode(parsed.data.contents); + const digest = new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); + if (digest !== entry.sha256) { + throw new ArmorySyncError( + `armory file ${entry.path} does not match the manifest hash (expected ${entry.sha256}, got ${digest})`, + ); + } + return bytes; + } + + private async getJson(url: string, what: string): Promise { + let response: Response; + try { + response = await this.fetchImpl(url); + } catch (error) { + throw new ArmorySyncError(`could not reach the bridge for the armory ${what}: ${message(error)}`); + } + if (!response.ok) { + throw new ArmorySyncError(`bridge answered ${response.status} for the armory ${what}`); + } + try { + return await response.json(); + } catch (error) { + throw new ArmorySyncError(`bridge served unparseable JSON for the armory ${what}: ${message(error)}`); + } + } + + /** + * The bridge's base URL, trailing slash stripped. Request URLs are built by + * concatenation rather than `new URL(path, base)` so a bridge mounted under a + * path prefix keeps it. + */ + private baseUrl(bridgeUrl: string): string { + let parsed: URL; + try { + parsed = new URL(bridgeUrl); + } catch { + throw new ArmorySyncError(`invalid bridge url: ${bridgeUrl}`, 400); + } + // `fetch` speaks `file:` and `data:` too; a push must not be able to turn + // this into a local-file read. + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new ArmorySyncError(`bridge url must be http(s): ${bridgeUrl}`, 400); + } + return bridgeUrl.replace(/\/+$/, ""); + } + + /** Delete every cached file the manifest no longer names, and any directory that leaves empty. */ + private async prune(keep: Set): Promise { + await pruneDirectory(this.filesRoot, "", keep); + } + + private async readState(): Promise { + return readCachedState(this.root); + } + + /** Written last and atomically: a crash mid-sync leaves the old state, so the next sync redoes the work. */ + private async writeState(state: CachedState): Promise { + await mkdir(this.root, { recursive: true }); + await atomicWrite(this.statePath, new TextEncoder().encode(`${JSON.stringify(state, null, 2)}\n`), 0o600); + } +} + +/** Drop the on-disk-only bookkeeping to get what a ship reports over HTTP. */ +function reported(state: CachedState): ArmorySyncState { + return { + revision: state.revision, + bridgeUrl: state.bridgeUrl, + syncedAt: state.syncedAt, + fileCount: state.entries.length, + install: state.install, + lastError: state.lastError, + }; +} + +function sameEntries(a: ArmoryEntry[], b: ArmoryEntry[]): boolean { + if (a.length !== b.length) return false; + return a.every((entry, index) => { + const other = b[index]!; + return ( + entry.path === other.path && + entry.sha256 === other.sha256 && + entry.mode === other.mode && + entry.size === other.size + ); + }); +} + +/** The number of files kept under `directory`, after deleting everything else. */ +async function pruneDirectory(root: string, prefix: string, keep: Set): Promise { + const directory = prefix === "" ? root : join(root, prefix); + let contents; + try { + contents = await readdir(directory, { withFileTypes: true }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return 0; + throw error; + } + + let kept = 0; + for (const item of contents) { + const path = prefix === "" ? item.name : `${prefix}/${item.name}`; + const target = join(directory, item.name); + if (item.isDirectory()) { + const remaining = await pruneDirectory(root, path, keep); + if (remaining === 0) await rmdir(target).catch(() => undefined); + kept += remaining; + continue; + } + if (keep.has(path)) { + kept++; + continue; + } + await rm(target, { force: true }); + } + return kept; +} + +async function atomicWrite(target: string, bytes: Uint8Array, mode: number): Promise { + const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`; + try { + await Bun.write(temporary, bytes); + // Before the rename, so the file is never briefly visible at `target` with + // the wrong mode. + await chmod(temporary, mode); + await rename(temporary, target); + } catch (error) { + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} + +async function hashFile(target: string): Promise { + const hasher = new Bun.CryptoHasher("sha256"); + // Streamed rather than read whole: armory files run to megabytes. Driven + // through the reader instead of `for await`, because the DOM lib's + // `ReadableStream` declares no `[Symbol.asyncIterator]` and this module is + // compiled under that lib by the web client. + const reader = Bun.file(target).stream().getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + hasher.update(value); + } + return hasher.digest("hex"); +} + +function isStrictDescendant(root: string, target: string): boolean { + const within = relative(root, target); + return within !== "" && !within.startsWith("..") && !within.startsWith(sep) && !/^[A-Za-z]:/.test(within); +} + +/** Zod's default rendering is a JSON blob; this keeps the message readable in an HTTP body. */ +function formatIssues(error: z.ZodError): string { + return error.issues + .map((issue) => (issue.path.length === 0 ? issue.message : `${issue.path.map(String).join(".")}: ${issue.message}`)) + .join("; "); +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/fleet-ship/src/armory/armory-installer.ts b/packages/fleet-ship/src/armory/armory-installer.ts new file mode 100644 index 0000000..6dfd82d --- /dev/null +++ b/packages/fleet-ship/src/armory/armory-installer.ts @@ -0,0 +1,412 @@ +/** + * armory/armory-installer.ts — turn the cached armory into installed files. + * + * `ArmoryCache` mirrors the bridge's armory under `/files/`; this module + * is the half that acts on it: + * + * files/skills//** → //** + * files/plugins/// + * files/dotfiles/ → symlinked wherever the dotfile map says + * + * Skills are modelled because every provider agrees on what one is: a directory + * discovered under a skills root. Plugins are not — each tool's plugin layout + * differs and changes — so the armory author, who can read their own tool's + * docs, chooses the path and the ship simply places the file inside that + * provider's config root. A `plugins/` that is not a known provider is + * skipped with a warning rather than guessed at. + * + * Everything is written through `managed-fs`, never directly: that is what + * gives adopt/conflict semantics, a crash-recoverable manifest, and — via + * `session.remove` — an uninstall that refuses to delete a file the user has + * since edited. `/installed.json` records what this installer wrote, so + * the next run can tell "removed from the armory" from "never installed". + * + * Dotfiles are the exception: they are symlinked, not copied, which managed-fs + * cannot express. `dotfile-linker.ts` owns that phase and its own ownership + * record; this module only sequences it and folds its report into this one. + */ + +import { lstat, readdir, rename, rm, rmdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { z } from "zod"; +import { isSafeArmoryPath } from "fleet-protocol"; +import { + ensureSafeDirectory, + isDirectory, + withManagedFiles, + type WriteStatus, +} from "../managed-fs"; +import { + PROVIDERS, + configRootFor, + isProvider, + skillRootsFor, + type Provider, +} from "../providers"; +import { armoryCacheDirectory, cachedDotfileMap } from "./armory-cache"; +import { linkDotfiles, type DotfileLink } from "./dotfile-linker"; + +export type ArmoryInstallOptions = { + homeDirectory?: string; + cacheDirectory?: string; + force?: boolean; +}; + +export type ArmoryInstallReport = { + skills: { skill: string; provider: Provider; path: string; status: WriteStatus }[]; + plugins: { provider: Provider; path: string; status: WriteStatus }[]; + dotfiles: DotfileLink[]; + removed: string[]; + /** Destinations left alone because an unmanaged file was already there. */ + conflicts: string[]; + warnings: string[]; +}; + +/** `installed.json`: what the previous run put on disk, so this one can undo it. */ +const InstalledRecordSchema = z.object({ + version: z.literal(1), + files: z + .object({ + path: z.string(), + provider: z.string(), + kind: z.enum(["skill", "plugin"]), + }) + .array(), +}); + +type InstalledEntry = z.infer["files"][number]; + +type PresentProvider = { provider: Provider; configRoot: string; skillRoots: string[] }; + +type PlannedFile = { + source: string; + destination: string; + mode: number; + provider: Provider; +} & ({ kind: "skill"; skill: string } | { kind: "plugin" }); + +export async function installArmory( + options: ArmoryInstallOptions = {}, +): Promise { + const homeDirectory = resolve(options.homeDirectory ?? homedir()); + const cacheRoot = options.cacheDirectory + ? resolve(options.cacheDirectory) + : armoryCacheDirectory(homeDirectory); + const report: ArmoryInstallReport = { + skills: [], + plugins: [], + dotfiles: [], + removed: [], + conflicts: [], + warnings: [], + }; + + const filesRoot = join(cacheRoot, "files"); + // A ship that has never synced has nothing to install and nothing to undo. + if (!(await isDirectory(filesRoot))) return report; + + const present: PresentProvider[] = []; + for (const provider of PROVIDERS) { + const configRoot = configRootFor(homeDirectory, provider); + if (await isDirectory(configRoot)) { + present.push({ provider, configRoot, skillRoots: skillRootsFor(homeDirectory, provider) }); + } + } + + const planned = [ + ...(await planSkills(filesRoot, present, report.warnings)), + ...(await planPlugins(filesRoot, present, report.warnings)), + ].sort((a, b) => a.destination.localeCompare(b.destination)); + + const plannedPaths = new Set(planned.map((file) => file.destination)); + const stale = (await readInstalledRecord(cacheRoot, report.warnings)).filter( + (entry) => !plannedPaths.has(entry.path), + ); + + const installed: InstalledEntry[] = []; + const failures: Error[] = []; + + await withManagedFiles(homeDirectory, async (session) => { + const ensured = new Set(); + for (const file of planned) { + try { + const directory = dirname(file.destination); + if (!ensured.has(directory)) { + await ensureSafeDirectory(homeDirectory, directory); + ensured.add(directory); + } + const status = await session.sync(file.destination, await Bun.file(file.source).bytes(), { + provider: file.provider, + kind: file.kind, + force: options.force ?? false, + mode: file.mode, + }); + if (file.kind === "skill") { + report.skills.push({ + skill: file.skill, + provider: file.provider, + path: file.destination, + status, + }); + } else { + report.plugins.push({ provider: file.provider, path: file.destination, status }); + } + // A conflict means we wrote nothing, so claiming ownership of the + // destination would make the next run try to uninstall a file that is + // not ours. + if (status === "conflict") report.conflicts.push(file.destination); + else installed.push({ path: file.destination, provider: file.provider, kind: file.kind }); + } catch (error) { + failures.push( + new Error(`Failed to install armory file ${file.destination}`, { cause: error }), + ); + } + } + + for (const entry of stale) { + try { + const outcome = await session.remove(entry.path, { + provider: entry.provider, + kind: entry.kind, + }); + if (outcome === "removed") report.removed.push(entry.path); + else if (outcome === "not-owned") { + report.warnings.push( + `left ${entry.path} in place: it no longer matches what Fleet installed there`, + ); + } + } catch (error) { + failures.push( + new Error(`Failed to uninstall armory file ${entry.path}`, { cause: error }), + ); + } + } + }); + + for (const path of report.removed) { + await pruneEmptyDirectories(homeDirectory, boundaryFor(homeDirectory, path), path); + } + await writeInstalledRecord(cacheRoot, installed); + + // Third phase, after the copied files: the map comes from the cache's own + // state, so a ship installs exactly the map that arrived with the revision it + // pulled and never re-asks the bridge. + try { + const dotfiles = await linkDotfiles({ + homeDirectory, + cacheDirectory: cacheRoot, + dotfileMap: await cachedDotfileMap(cacheRoot), + force: options.force, + }); + report.dotfiles = dotfiles.links; + report.removed.push(...dotfiles.removed); + report.conflicts.push(...dotfiles.conflicts); + report.warnings.push(...dotfiles.warnings); + } catch (error) { + // Flattened rather than wrapped: an `AggregateError`'s own message names no + // path, and callers render the individual failures. + if (error instanceof AggregateError) { + failures.push(...error.errors.map(asError)); + } else { + failures.push(new Error("Failed to link the armory dotfiles", { cause: error })); + } + } + + if (failures.length > 0) throw new AggregateError(failures, "Failed to install the armory"); + return report; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +async function planSkills( + filesRoot: string, + present: PresentProvider[], + warnings: string[], +): Promise { + const skillsRoot = join(filesRoot, "skills"); + const planned: PlannedFile[] = []; + + for (const entry of await sectionEntries(skillsRoot)) { + if (!entry.isDirectory()) { + warnings.push(`ignored armory skills/${entry.name}: a skill must be a directory`); + continue; + } + if (!isSafeArmoryPath(entry.name)) { + warnings.push(`ignored armory skill ${entry.name}: unsafe name`); + continue; + } + const source = join(skillsRoot, entry.name); + for (const { path, mode } of await treeFiles(source, `skills/${entry.name}`, warnings)) { + for (const provider of present) { + for (const skillRoot of provider.skillRoots) { + planned.push({ + source: join(source, ...path.split("/")), + destination: join(skillRoot, entry.name, ...path.split("/")), + mode, + provider: provider.provider, + kind: "skill", + skill: entry.name, + }); + } + } + } + } + return planned; +} + +async function planPlugins( + filesRoot: string, + present: PresentProvider[], + warnings: string[], +): Promise { + const pluginsRoot = join(filesRoot, "plugins"); + const planned: PlannedFile[] = []; + + for (const entry of await sectionEntries(pluginsRoot)) { + if (!entry.isDirectory() || !isProvider(entry.name)) { + warnings.push( + `ignored armory plugins/${entry.name}: not a directory named after a known provider (${PROVIDERS.join(", ")})`, + ); + continue; + } + // Not a warning: a provider this host does not use is the normal case. + const provider = present.find((candidate) => candidate.provider === entry.name); + if (!provider) continue; + + const source = join(pluginsRoot, entry.name); + for (const { path, mode } of await treeFiles(source, `plugins/${entry.name}`, warnings)) { + planned.push({ + source: join(source, ...path.split("/")), + destination: join(provider.configRoot, ...path.split("/")), + mode, + provider: provider.provider, + kind: "plugin", + }); + } + } + return planned; +} + +/** A section's directory entries; an absent section is simply empty. */ +async function sectionEntries(path: string) { + try { + return await readdir(path, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +/** + * Every regular file under `directory`, as `/`-separated relative paths with + * the mode to install them with. Cached bytes are still untrusted input, so + * each path is re-validated and only the executable bit is carried over. + */ +async function treeFiles( + directory: string, + label: string, + warnings: string[], +): Promise<{ path: string; mode: number }[]> { + const files: { path: string; mode: number }[] = []; + for await (const found of new Bun.Glob("**/*").scan({ + cwd: directory, + dot: true, + onlyFiles: true, + followSymlinks: false, + })) { + const path = found.split(sep).join("/"); + if (!isSafeArmoryPath(path)) { + warnings.push(`ignored armory file ${label}/${path}: unsafe path`); + continue; + } + const stats = await lstat(join(directory, ...path.split("/"))); + if (!stats.isFile() || stats.isSymbolicLink()) { + warnings.push(`ignored armory file ${label}/${path}: not a regular file`); + continue; + } + files.push({ path, mode: (stats.mode & 0o111) === 0 ? 0o644 : 0o755 }); + } + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +/** + * The directory an uninstalled file's now-empty parents may be pruned up to, + * exclusive: a provider's own skills root or config root, never past it. + * Returns `""` — pruning nothing — for a path we cannot place. + */ +function boundaryFor(homeDirectory: string, path: string): string { + for (const provider of PROVIDERS) { + for (const root of [...skillRootsFor(homeDirectory, provider), configRootFor(homeDirectory, provider)]) { + if (isStrictDescendant(root, path)) return root; + } + } + return ""; +} + +/** Delete directories emptied by an uninstall, from `path`'s parent up to `boundary`. */ +async function pruneEmptyDirectories( + homeDirectory: string, + boundary: string, + path: string, +): Promise { + if (boundary === "") return; + let current = dirname(path); + while (isStrictDescendant(boundary, current) && isStrictDescendant(homeDirectory, current)) { + try { + await rmdir(current); + } catch (error) { + // ENOTEMPTY means something else lives here and everything above it stays. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return; + } + current = dirname(current); + } +} + +function isStrictDescendant(root: string, target: string): boolean { + const within = relative(root, target); + return within !== "" && !within.startsWith("..") && !within.startsWith(sep); +} + +function installedRecordPath(cacheRoot: string): string { + return join(cacheRoot, "installed.json"); +} + +async function readInstalledRecord( + cacheRoot: string, + warnings: string[], +): Promise { + const path = installedRecordPath(cacheRoot); + let parsed: unknown; + try { + parsed = await Bun.file(path).json(); + } catch (error) { + // A first run has no record; a corrupt one must not block installing, but + // it does mean this run cannot uninstall what the last one wrote. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + warnings.push(`ignored unreadable ${path}: previously installed files cannot be removed`); + } + return []; + } + const record = InstalledRecordSchema.safeParse(parsed); + if (!record.success) { + warnings.push(`ignored invalid ${path}: previously installed files cannot be removed`); + return []; + } + return record.data.files; +} + +async function writeInstalledRecord(cacheRoot: string, files: InstalledEntry[]): Promise { + const path = installedRecordPath(cacheRoot); + const body = `${JSON.stringify({ version: 1, files }, null, 2)}\n`; + const temporary = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`; + try { + await Bun.write(temporary, body); + await rename(temporary, path); + } catch (error) { + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} diff --git a/packages/fleet-ship/src/armory/armory-sync.ts b/packages/fleet-ship/src/armory/armory-sync.ts new file mode 100644 index 0000000..4b866b2 --- /dev/null +++ b/packages/fleet-ship/src/armory/armory-sync.ts @@ -0,0 +1,65 @@ +/** + * armory/armory-sync.ts — pull the armory, then install it. + * + * `ArmoryCache` stays a pure puller and `installArmory` a pure installer; this + * is the only place that knows both. It exists so the ship's route can stay a + * handler: the ordering rule — a failed install is reported without discarding + * the successful pull that preceded it — belongs here, not in HTTP glue. + */ + +import type { ArmoryInstallSummary, ArmorySyncRequest, ArmorySyncState } from "fleet-protocol"; +import { ArmoryCache, ArmorySyncError } from "./armory-cache"; +import { installArmory, type ArmoryInstallReport } from "./armory-installer"; + +export type SyncAndInstallOptions = { + force?: boolean; + /** Swappable for tests; production always installs from the cache just pulled. */ + install?: typeof installArmory; +}; + +export async function syncAndInstall( + cache: ArmoryCache, + request: ArmorySyncRequest, + options: SyncAndInstallOptions = {}, +): Promise { + await cache.sync(request); + + const install = options.install ?? installArmory; + let report: ArmoryInstallReport; + try { + report = await install({ + homeDirectory: cache.homeDirectory, + cacheDirectory: cache.cacheDirectory, + force: options.force, + }); + } catch (error) { + const detail = describe(error); + await cache.recordInstall(null, `armory install failed: ${detail}`).catch(() => {}); + // 500: the pull worked, so this is the ship's own filesystem, not the bridge's. + throw new ArmorySyncError(`armory install failed: ${detail}`, 500); + } + + return cache.recordInstall(summarize(report)); +} + +export function summarize(report: ArmoryInstallReport): ArmoryInstallSummary { + return { + skillCount: report.skills.length, + pluginCount: report.plugins.length, + dotfileCount: report.dotfiles.filter(({ status }) => status !== "conflict" && status !== "skipped") + .length, + removedCount: report.removed.length, + conflicts: report.conflicts, + warnings: report.warnings, + installedAt: new Date().toISOString(), + }; +} + +/** Flatten an `AggregateError` from the installer: its own message names nothing. */ +function describe(error: unknown): string { + if (error instanceof AggregateError) { + return error.errors.map((failure) => describe(failure)).join("; "); + } + if (!(error instanceof Error)) return String(error); + return error.cause instanceof Error ? `${error.message}: ${error.cause.message}` : error.message; +} diff --git a/packages/fleet-ship/src/armory/dotfile-linker.ts b/packages/fleet-ship/src/armory/dotfile-linker.ts new file mode 100644 index 0000000..fe15aaa --- /dev/null +++ b/packages/fleet-ship/src/armory/dotfile-linker.ts @@ -0,0 +1,294 @@ +/** + * armory/dotfile-linker.ts — put the armory's dotfiles in place, as symlinks. + * + * `dotfile-map.json` names `dotfiles/`-relative sources and where each belongs: + * + * ".tmux.conf": "~/.tmux.conf" → ~/.tmux.conf -> /files/dotfiles/.tmux.conf + * "nvim": "~/.config/nvim" → ~/.config/nvim -> /files/dotfiles/nvim + * + * Links, not copies, and deliberately so: the cache is already an exact mirror + * of the bridge's armory, so a content edit reaches the user through the link on + * the next pull with nothing to reinstall, and a directory source is one link + * rather than a copy per file. This is the one place Fleet's blanket refusal to + * touch symlinks (see managed-fs.ts, which refuses them on every path it + * touches) is relaxed — and only for links this module can prove it created: + * every decision is made on `lstat`/`readlink` of the target itself, and a link + * is only ever replaced or removed while it still points inside this cache's + * `files/dotfiles/`. Anything else at a target belongs to the user or to their + * own dotfile manager and is left exactly as found. managed-fs cannot serve + * this: it manages regular files by content hash. Do not route dotfiles through + * it, and do not weaken its checks to make that possible. + * + * `/dotfiles.json` records the links this module put in place, so a + * mapping that later leaves the map can be undone without guessing. + * + * The map is untrusted network input. A destination is resolved against *this + * ship's* home directory and must land strictly inside it, so no bridge can + * make a fleet symlink into `/etc` or `/usr`. That confinement is lexical: a + * user who has symlinked a directory of their own home elsewhere is taken at + * their word, the same way the rest of their dotfile setup takes them. + */ + +import { lstat, mkdir, readlink, rename, rm, symlink, unlink } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { z } from "zod"; +import { isSafeArmoryPath, type DotfileMap } from "fleet-protocol"; + +export type DotfileLinkStatus = "linked" | "unchanged" | "relinked" | "conflict" | "skipped"; + +export type DotfileLink = { + /** `dotfiles/`-relative, exactly as the map wrote it. */ + source: string; + target: string; + status: DotfileLinkStatus; + /** Why, for `conflict` and `skipped`; what was replaced, for a forced link. */ + detail?: string; +}; + +export type DotfileLinkReport = { + links: DotfileLink[]; + /** Absolute targets whose links this run deleted. */ + removed: string[]; + /** Absolute targets left alone because something unmanaged was already there. */ + conflicts: string[]; + warnings: string[]; +}; + +export type LinkDotfilesOptions = { + homeDirectory: string; + cacheDirectory: string; + dotfileMap: DotfileMap; + force?: boolean; +}; + +/** `dotfiles.json`: the links the previous run put in place, so this one can undo them. */ +const OwnedLinksSchema = z.object({ + version: z.literal(1), + links: z.object({ target: z.string(), source: z.string() }).array(), +}); + +type OwnedLink = z.infer["links"][number]; + +type PlannedLink = { source: string; target: string; sourcePath: string }; + +type Placement = { status: Exclude; detail?: string }; + +export async function linkDotfiles(options: LinkDotfilesOptions): Promise { + const homeDirectory = resolve(options.homeDirectory); + const cacheRoot = resolve(options.cacheDirectory); + const dotfilesRoot = join(cacheRoot, "files", "dotfiles"); + const force = options.force ?? false; + const report: DotfileLinkReport = { links: [], removed: [], conflicts: [], warnings: [] }; + + const planned = await plan(homeDirectory, dotfilesRoot, options.dotfileMap, report); + const failures: Error[] = []; + const owned: OwnedLink[] = []; + // Every target the map still names, conflicts included: a mapping that is in + // the map is never a removal candidate, whatever came of it this run. + const retained = new Set(planned.map((link) => link.target)); + + for (const link of planned) { + try { + const placement = await place(link, dotfilesRoot, force); + report.links.push({ + source: link.source, + target: link.target, + status: placement.status, + ...(placement.detail === undefined ? {} : { detail: placement.detail }), + }); + // A conflict means we wrote nothing, so claiming the target would make the + // next run try to remove a link that is not ours. + if (placement.status === "conflict") report.conflicts.push(link.target); + else owned.push({ target: link.target, source: link.source }); + } catch (error) { + failures.push(new Error(`Failed to link armory dotfile ${link.target}`, { cause: error })); + } + } + + for (const previous of await readOwnedLinks(cacheRoot, report.warnings)) { + if (retained.has(previous.target)) continue; + try { + await unlinkOwned(previous.target, dotfilesRoot, report); + } catch (error) { + failures.push( + new Error(`Failed to unlink armory dotfile ${previous.target}`, { cause: error }), + ); + } + } + + // Before the throw: links that did land are ours whether or not a sibling + // mapping failed, and a record that omits them would orphan them forever. + await writeOwnedLinks(cacheRoot, owned); + + if (failures.length > 0) throw new AggregateError(failures, "Failed to link the armory dotfiles"); + return report; +} + +/** The mappings worth acting on; the rest are reported as `skipped` and dropped. */ +async function plan( + homeDirectory: string, + dotfilesRoot: string, + map: DotfileMap, + report: DotfileLinkReport, +): Promise { + const planned: PlannedLink[] = []; + + for (const [source, destination] of Object.entries(map).sort(([a], [b]) => a.localeCompare(b))) { + const skip = (target: string, detail: string) => { + report.links.push({ source, target, status: "skipped", detail }); + report.warnings.push(`skipped dotfile ${source}: ${detail}`); + }; + + if (!destination.startsWith("~/") && !isAbsolute(destination)) { + skip(destination, `destination "${destination}" is neither "~/"-rooted nor absolute`); + continue; + } + const target = destination.startsWith("~/") + ? resolve(homeDirectory, destination.slice(2)) + : resolve(destination); + if (!isSafeArmoryPath(source)) { + skip(target, `"${source}" is not a safe path under dotfiles/`); + continue; + } + if (!isStrictDescendant(homeDirectory, target)) { + skip(target, `destination "${destination}" is outside ${homeDirectory}`); + continue; + } + const sourcePath = join(dotfilesRoot, ...source.split("/")); + if (!(await entry(sourcePath))) { + skip(target, `dotfiles/${source} is not in the armory cache`); + continue; + } + planned.push({ source, target, sourcePath }); + } + return planned; +} + +async function place( + link: PlannedLink, + dotfilesRoot: string, + force: boolean, +): Promise { + const current = await entry(link.target); + if (!current) { + await mkdir(dirname(link.target), { recursive: true }); + await placeLink(link.target, link.sourcePath); + return { status: "linked" }; + } + + if (current.isSymbolicLink()) { + const existing = resolve(dirname(link.target), await readlink(link.target)); + if (existing === link.sourcePath) return { status: "unchanged" }; + if (isStrictDescendant(dotfilesRoot, existing)) { + await placeLink(link.target, link.sourcePath); + return { status: "relinked" }; + } + if (!force) return { status: "conflict", detail: `a symlink to ${existing} is already there` }; + await placeLink(link.target, link.sourcePath); + return { status: "linked", detail: `replaced a symlink to ${existing}` }; + } + + const what = current.isDirectory() ? "directory" : "file"; + if (!force) return { status: "conflict", detail: `a ${what} is already there` }; + // `rename` cannot replace a directory, so this is the one case that leaves the + // target briefly missing; everything else swaps in atomically. + if (current.isDirectory()) await rm(link.target, { recursive: true, force: true }); + await placeLink(link.target, link.sourcePath); + return { status: "linked", detail: `replaced a ${what}` }; +} + +/** + * Point `target` at `source`. Written to a temporary name in the target's own + * directory and renamed over it, because `symlink(2)` refuses an existing path + * and unlinking first would leave the dotfile missing in between. + */ +async function placeLink(target: string, source: string): Promise { + const temporary = join( + dirname(target), + `.${basename(target)}.fleet-${process.pid}-${crypto.randomUUID()}.tmp`, + ); + try { + await symlink(source, temporary); + await rename(temporary, target); + } catch (error) { + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} + +/** Remove a target this cache recorded, unless it stopped being ours. */ +async function unlinkOwned( + target: string, + dotfilesRoot: string, + report: DotfileLinkReport, +): Promise { + const current = await entry(target); + if (!current) return; + if (!current.isSymbolicLink()) { + report.warnings.push(`left ${target} in place: it is no longer a Fleet dotfile symlink`); + return; + } + const existing = resolve(dirname(target), await readlink(target)); + if (!isStrictDescendant(dotfilesRoot, existing)) { + report.warnings.push(`left ${target} in place: it now points at ${existing}`); + return; + } + // Only the link itself. A parent directory existing is no evidence Fleet + // created it, and the blast radius of being wrong is the user's home. + await unlink(target); + report.removed.push(target); +} + +/** `lstat`, never `stat`: whether the target *is* a link is the whole question. */ +async function entry(path: string) { + try { + return await lstat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +function ownedLinksPath(cacheRoot: string): string { + return join(cacheRoot, "dotfiles.json"); +} + +async function readOwnedLinks(cacheRoot: string, warnings: string[]): Promise { + const path = ownedLinksPath(cacheRoot); + let parsed: unknown; + try { + parsed = await Bun.file(path).json(); + } catch (error) { + // A first run has no record; an unreadable one must not block linking, but + // it does mean this run cannot undo what the last one linked. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + warnings.push(`ignored unreadable ${path}: previously linked dotfiles cannot be removed`); + } + return []; + } + const record = OwnedLinksSchema.safeParse(parsed); + if (!record.success) { + warnings.push(`ignored invalid ${path}: previously linked dotfiles cannot be removed`); + return []; + } + return record.data.links; +} + +async function writeOwnedLinks(cacheRoot: string, links: OwnedLink[]): Promise { + const path = ownedLinksPath(cacheRoot); + const body = `${JSON.stringify({ version: 1, links }, null, 2)}\n`; + const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`; + try { + await mkdir(cacheRoot, { recursive: true }); + await Bun.write(temporary, body); + await rename(temporary, path); + } catch (error) { + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} + +function isStrictDescendant(root: string, target: string): boolean { + const within = relative(root, target); + return within !== "" && !within.startsWith("..") && !within.startsWith(sep) && !isAbsolute(within); +} diff --git a/packages/fleet-ship/src/index.ts b/packages/fleet-ship/src/index.ts index 768b577..c64054d 100755 --- a/packages/fleet-ship/src/index.ts +++ b/packages/fleet-ship/src/index.ts @@ -4,20 +4,24 @@ import { canonicalizeFleetDirectory, resolveFleetShipConfig } from "./config"; import { writeAtlas } from "./atlas"; import { installFleetSkill } from "./skill-installer"; import { installFleetPlugin } from "./plugin-installer"; +import { installArmory } from "./armory/armory-installer"; import { pluginCommand } from "./plugin-command"; export async function installStartupIntegrations(options: { homeDirectory?: string; skillSourcePath?: string; pluginsDirectory?: string; + /** A caller that supplies its own installers opts out of the ones it omits. */ installers?: { skill: typeof installFleetSkill; plugin: typeof installFleetPlugin; + armory?: typeof installArmory; }; } = {}): Promise { const installers = options.installers ?? { skill: installFleetSkill, plugin: installFleetPlugin, + armory: installArmory, }; let skills: Awaited> = []; let plugins: Awaited> = []; @@ -43,6 +47,37 @@ export async function installStartupIntegrations(options: { "Fix the reported path, then run fleet ship plugin install all.", ); } + try { + // Re-apply whatever the ship already has cached, so a restart does not wait + // for the bridge's next push. + const report = await installers.armory?.({ homeDirectory: options.homeDirectory }); + for (const warning of report?.warnings ?? []) console.warn(`Fleet armory: ${warning}.`); + // Dotfile targets are in `conflicts` too, but the remedy differs enough to + // be worth its own message. + const dotfileConflicts = new Set( + (report?.dotfiles ?? []) + .filter(({ status }) => status === "conflict") + .map(({ target }) => target), + ); + for (const path of dotfileConflicts) { + console.warn( + `Fleet startup preserved a conflicting dotfile: ${path}. ` + + "Move or delete it to let the armory's symlink take that path on the next sync or ship restart.", + ); + } + for (const path of report?.conflicts ?? []) { + if (dotfileConflicts.has(path)) continue; + console.warn( + `Fleet startup preserved a conflicting armory file: ${path}. ` + + "Delete it to let the armory install over that path.", + ); + } + } catch (error) { + console.warn( + `Fleet startup could not install the armory: ${formatInstallerError(error)}. ` + + "Fix the reported path; the ship keeps running with what it already had.", + ); + } const conflicts = [ ...skills .filter(({ status }) => status === "conflict") @@ -111,12 +146,17 @@ export const ship = new Command() .option("-p, --port ", "port the HTTP + WebSocket API listens on", parsePort, DEFAULT_PORT) .option("-n, --name ", "human-facing name of this ship", "ship") .option("-f, --fleet-directory ", "directory holding all workspaces (//)", "./fleet") - .action(async (options: { port: number; name: string; fleetDirectory: string }) => { + .option( + "--bridge-url ", + "only bridge whose armory pushes this ship accepts (default: whichever bridge pushes first)", + ) + .action(async (options: { port: number; name: string; fleetDirectory: string; bridgeUrl?: string }) => { try { const config = resolveFleetShipConfig({ fleetDirectory: options.fleetDirectory, port: options.port, name: options.name, + bridgeUrl: options.bridgeUrl, }); await startShip(config); } catch (err) { diff --git a/packages/fleet-ship/src/managed-fs.ts b/packages/fleet-ship/src/managed-fs.ts index 787a9cd..9741506 100644 --- a/packages/fleet-ship/src/managed-fs.ts +++ b/packages/fleet-ship/src/managed-fs.ts @@ -74,12 +74,24 @@ export type ManagedFileOptions = { lockTimeoutMs?: number; }; +/** + * `remove` reports what it did: `removed` when the file was ours and is gone, + * `not-owned` when the manifest does not claim it or its bytes have drifted + * from what we recorded (a user edit is never deleted), `missing` when there is + * nothing at the destination. + */ +export type RemoveStatus = "removed" | "not-owned" | "missing"; + export type ManagedFileSession = { sync( destination: string, - contents: string, + contents: string | Uint8Array, ownership: { provider: string; kind: ManagedKind; force?: boolean; mode?: number }, ): Promise; + remove( + destination: string, + ownership: { provider: string; kind: ManagedKind }, + ): Promise; }; const MANIFEST_RELATIVE_PATH = join( @@ -112,8 +124,9 @@ function sha256(contents: Uint8Array): string { return hasher.digest("hex"); } -function bytes(contents: string): Uint8Array { - return new TextEncoder().encode(contents); +/** Managed contents may be binary (an armory plugin can be), so bytes pass through untouched. */ +function bytes(contents: string | Uint8Array): Uint8Array { + return typeof contents === "string" ? new TextEncoder().encode(contents) : contents; } function normalizedDestination(path: string): string { @@ -959,6 +972,71 @@ export async function withManagedFiles( await options.fault?.("after-final-manifest", normalized); return status; }, + async remove(destination, ownership) { + const normalized = normalizedDestination(destination); + const parentStats = await safeDirectoryPath(homeDirectory, dirname(normalized), false); + // No parent directory means no file; nothing below can exist either. + if (!parentStats) return "missing"; + if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) { + throw new Error(`Refusing to use unsafe parent directory: ${dirname(normalized)}`); + } + const parent = { + path: dirname(normalized), + dev: parentStats.dev, + ino: parentStats.ino, + }; + + const pending = manifest.transitions[normalized]; + let current = await fileSnapshot(normalized); + if (pending) { + // Settle a write this process crashed halfway through before judging + // ownership, exactly as `sync` does. + if (snapshotMatches(current, pending.intendedSha256, pending.intendedMode)) { + manifest.files[normalized] = transitionEntry(pending); + } else if (!snapshotMatches(current, pending.previousSha256, pending.previousMode)) { + return "not-owned"; + } + delete manifest.transitions[normalized]; + await writeManifest(homeDirectory, path, manifest); + current = await fileSnapshot(normalized); + } + + const recorded = manifest.files[normalized]; + if (!current) { + if (recorded) { + delete manifest.files[normalized]; + await writeManifest(homeDirectory, path, manifest); + } + return "missing"; + } + if ( + !recorded || + recorded.provider !== ownership.provider || + recorded.kind !== ownership.kind || + !snapshotMatches(current, recorded.sha256, recorded.mode) + ) { + return "not-owned"; + } + + revalidateParentSync(homeDirectory, parent); + const confirmed = fileSnapshotSync(normalized); + if (!confirmed || !snapshotMatches(confirmed, recorded.sha256, recorded.mode)) { + return "not-owned"; + } + // Manifest first: a crash between the two steps must never leave the + // manifest claiming a file that is gone, which would let a later + // reinstall trust a hash nothing on disk can satisfy. The reverse + // failure — an orphaned file we no longer claim — is inert. + delete manifest.files[normalized]; + await writeManifest(homeDirectory, path, manifest); + revalidateParentSync(homeDirectory, parent); + const latest = fileSnapshotSync(normalized); + if (!latest || latest.dev !== confirmed.dev || latest.ino !== confirmed.ino) { + throw new Error(`Destination changed while removing: ${normalized}`); + } + unlinkSync(normalized); + return "removed"; + }, }; return await operation(session); }, @@ -972,7 +1050,7 @@ export async function withManagedFiles( export async function inspectManagedFile( homeDirectory: string, destination: string, - contents: string, + contents: string | Uint8Array, mode?: number, ): Promise> { const normalized = normalizedDestination(destination); diff --git a/packages/fleet-ship/src/providers.ts b/packages/fleet-ship/src/providers.ts new file mode 100644 index 0000000..7319746 --- /dev/null +++ b/packages/fleet-ship/src/providers.ts @@ -0,0 +1,45 @@ +/** + * providers.ts — the agent providers Fleet installs into, and where each of + * them keeps its configuration. + * + * One source of truth for the embedded `fleet-agent` installers and for the + * armory installer, which fan out over the same rows. Two rules hold + * everywhere: a provider counts as present on this host iff its `configRoot` + * exists (Fleet never creates it — that would fake an install of a tool the + * user does not have), and a skill is written to *every* root + * `skillRootsFor` reports. + */ + +import { join } from "node:path"; + +export type Provider = "claude-code" | "opencode" | "copilot" | "codex"; + +export const PROVIDERS: readonly Provider[] = ["claude-code", "opencode", "copilot", "codex"]; + +export function isProvider(value: string): value is Provider { + return (PROVIDERS as readonly string[]).includes(value); +} + +/** The directory whose existence proves the provider is installed for this user. */ +export function configRootFor(homeDirectory: string, provider: Provider): string { + switch (provider) { + case "claude-code": + return join(homeDirectory, ".claude"); + case "opencode": + return join(homeDirectory, ".config", "opencode"); + case "copilot": + return join(homeDirectory, ".copilot"); + case "codex": + return join(homeDirectory, ".codex"); + } +} + +/** + * Every directory the provider discovers skills in, shallowest-owned first. + * Codex reads both its own `~/.codex/skills` and the cross-tool + * `~/.agents/skills`, so it contributes two. + */ +export function skillRootsFor(homeDirectory: string, provider: Provider): string[] { + const own = join(configRootFor(homeDirectory, provider), "skills"); + return provider === "codex" ? [own, join(homeDirectory, ".agents", "skills")] : [own]; +} diff --git a/packages/fleet-ship/src/skill-installer.ts b/packages/fleet-ship/src/skill-installer.ts index 7209d1b..cad372f 100644 --- a/packages/fleet-ship/src/skill-installer.ts +++ b/packages/fleet-ship/src/skill-installer.ts @@ -7,7 +7,7 @@ */ import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; // TypeScript resolves the source extension before Bun's text-loader override. // @ts-expect-error Bun imports this Markdown file as a string. import embeddedSkill from "../skill/SKILL.md" with { type: "text" }; @@ -20,10 +20,11 @@ import { type PresenceState, type WriteStatus, } from "./managed-fs"; +import { PROVIDERS, configRootFor, skillRootsFor, type Provider } from "./providers"; const SKILL_NAME = "fleet-agent"; -type Provider = "claude-code" | "opencode" | "copilot" | "codex"; +export type { Provider }; export type SkillInstallation = { provider: Provider; @@ -54,49 +55,19 @@ type ProviderPaths = { directories: string[]; }; +/** One row per (provider, skills directory): codex contributes two. */ function providerPaths(homeDirectory: string): ProviderPaths[] { - const claudeSkills = join(homeDirectory, ".claude", "skills"); - const openCodeSkills = join(homeDirectory, ".config", "opencode", "skills"); - const copilotSkills = join(homeDirectory, ".copilot", "skills"); - const codexSkills = join(homeDirectory, ".codex", "skills"); - const sharedSkills = join(homeDirectory, ".agents", "skills"); - - return [ - { - provider: "claude-code", - configRoot: join(homeDirectory, ".claude"), - destination: join(claudeSkills, SKILL_NAME, "SKILL.md"), - directories: [claudeSkills, join(claudeSkills, SKILL_NAME)], - }, - { - provider: "opencode", - configRoot: join(homeDirectory, ".config", "opencode"), - destination: join(openCodeSkills, SKILL_NAME, "SKILL.md"), - directories: [openCodeSkills, join(openCodeSkills, SKILL_NAME)], - }, - { - provider: "copilot", - configRoot: join(homeDirectory, ".copilot"), - destination: join(copilotSkills, SKILL_NAME, "SKILL.md"), - directories: [copilotSkills, join(copilotSkills, SKILL_NAME)], - }, - { - provider: "codex", - configRoot: join(homeDirectory, ".codex"), - destination: join(codexSkills, SKILL_NAME, "SKILL.md"), - directories: [codexSkills, join(codexSkills, SKILL_NAME)], - }, - { - provider: "codex", - configRoot: join(homeDirectory, ".codex"), - destination: join(sharedSkills, SKILL_NAME, "SKILL.md"), - directories: [ - join(homeDirectory, ".agents"), - sharedSkills, - join(sharedSkills, SKILL_NAME), - ], - }, - ]; + return PROVIDERS.flatMap((provider) => + skillRootsFor(homeDirectory, provider).map((skillRoot) => ({ + provider, + configRoot: configRootFor(homeDirectory, provider), + destination: join(skillRoot, SKILL_NAME, "SKILL.md"), + // `dirname(skillRoot)` is the provider's config root for its own skills + // directory, but `~/.agents` for the shared one, which nothing else + // creates. + directories: [dirname(skillRoot), skillRoot, join(skillRoot, SKILL_NAME)], + })), + ); } /** The provider spec rows to act on, optionally narrowed to `providers`. */ diff --git a/packages/fleet-ship/tests/armory-cache.test.ts b/packages/fleet-ship/tests/armory-cache.test.ts new file mode 100644 index 0000000..cdaae13 --- /dev/null +++ b/packages/fleet-ship/tests/armory-cache.test.ts @@ -0,0 +1,468 @@ +/** + * armory-cache.test.ts — drives `ArmoryCache` against a real HTTP bridge + * (`Bun.serve`) and a temp home. The fake bridge is real rather than a stubbed + * `fetch` because the whole point of the cache is what it does with bytes off + * the wire; it also counts requests, which is how "downloads nothing" is + * asserted. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { lstat, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ArmoryEntry, ArmoryManifest } from "fleet-protocol"; +import { ArmoryCache, ArmorySyncError, normalizeBridgeUrl } from "../src/armory/armory-cache"; +import { createApp } from "../src/api"; +import { stubConfig, stubManager } from "./helpers"; + +const homes: string[] = []; +const servers: { stop(): void }[] = []; + +afterEach(async () => { + for (const server of servers.splice(0)) server.stop(); + for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); +}); + +async function makeHome(): Promise { + const home = await mkdtemp(join(tmpdir(), "fleet-ship-armory-")); + homes.push(home); + return home; +} + +const sha256 = (contents: Uint8Array): string => new Bun.CryptoHasher("sha256").update(contents).digest("hex"); +const utf8 = (text: string): Uint8Array => new TextEncoder().encode(text); + +interface FakeFile { + bytes: Uint8Array; + mode?: number; + /** Bytes actually served, when they should differ from what the manifest promises. */ + served?: Uint8Array; +} + +/** A bridge serving `files` (path → contents) plus whatever extra entries a test injects. */ +function fakeBridge(files: Map, extraEntries: ArmoryEntry[] = []) { + const requests: string[] = []; + + const entries = (): ArmoryEntry[] => [ + ...[...files.entries()] + .map(([path, file]) => ({ + path, + section: path.split("/")[0] as ArmoryEntry["section"], + size: file.bytes.byteLength, + sha256: sha256(file.bytes), + mode: file.mode ?? 0o644, + })) + .sort((a, b) => (a.path < b.path ? -1 : 1)), + ...extraEntries, + ]; + + const manifest = (): ArmoryManifest => { + const list = entries(); + return { + revision: new Bun.CryptoHasher("sha256") + .update(JSON.stringify(list.map(({ path, sha256: hash, mode }) => ({ path, hash, mode })))) + .digest("hex"), + entries: list, + dotfileMap: {}, + }; + }; + + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push(url.pathname + url.search); + if (url.pathname === "/armory") return Response.json(manifest()); + if (url.pathname === "/armory/file") { + const path = url.searchParams.get("path") ?? ""; + const entry = entries().find((candidate) => candidate.path === path); + const file = files.get(path); + if (!entry || !file) return new Response("not found", { status: 404 }); + const bytes = file.served ?? file.bytes; + const text = tryDecode(bytes); + return Response.json({ + ...entry, + ...(text === undefined + ? { encoding: "base64", contents: Buffer.from(bytes).toString("base64") } + : { encoding: "utf8", contents: text }), + }); + } + return new Response("not found", { status: 404 }); + }, + }); + servers.push(server); + + return { + url: `http://localhost:${server.port}`, + requests, + revision: () => manifest().revision, + fileRequests: () => requests.filter((path) => path.startsWith("/armory/file")), + }; +} + +function tryDecode(bytes: Uint8Array): string | undefined { + if (bytes.includes(0)) return undefined; + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return undefined; + } +} + +/** The cache's mirrored tree root under a temp home. */ +const filesRoot = (home: string): string => join(home, ".config", "autosmith", "fleet-ship", "armory", "files"); + +const read = (home: string, path: string): Promise => Bun.file(join(filesRoot(home), path)).text(); + +/** The error `promise` rejected with, failing the test if it resolved instead. */ +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error as Error; + } + throw new Error("expected the promise to reject"); +} + +describe("ArmoryCache", () => { + test("a first sync writes every file with its contents and mode, and records the revision", async () => { + const home = await makeHome(); + const bridge = fakeBridge( + new Map([ + ["skills/one/SKILL.md", { bytes: utf8("# one") }], + ["skills/one/run.sh", { bytes: utf8("#!/bin/sh\n"), mode: 0o755 }], + ["dotfiles/gitconfig", { bytes: utf8("[user]\n") }], + ]), + ); + const cache = new ArmoryCache({ homeDirectory: home }); + + const state = await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + + expect(state.revision).toBe(bridge.revision()); + expect(state.bridgeUrl).toBe(bridge.url); + expect(state.fileCount).toBe(3); + expect(state.lastError).toBeNull(); + expect(state.syncedAt).not.toBeNull(); + expect(await read(home, "skills/one/SKILL.md")).toBe("# one"); + expect(await read(home, "dotfiles/gitconfig")).toBe("[user]\n"); + expect((await lstat(join(filesRoot(home), "skills/one/run.sh"))).mode & 0o777).toBe(0o755); + expect((await lstat(join(filesRoot(home), "skills/one/SKILL.md"))).mode & 0o777).toBe(0o644); + expect(await cache.state()).toEqual(state); + }); + + test("an unchanged revision downloads nothing", async () => { + const home = await makeHome(); + const bridge = fakeBridge(new Map([["skills/one/SKILL.md", { bytes: utf8("# one") }]])); + const cache = new ArmoryCache({ homeDirectory: home }); + + await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + expect(bridge.fileRequests()).toHaveLength(1); + + await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + expect(bridge.fileRequests()).toHaveLength(1); + }); + + test("a changed file is re-downloaded, a removed one pruned, and an emptied directory removed", async () => { + const home = await makeHome(); + const files = new Map([ + ["skills/one/SKILL.md", { bytes: utf8("# one") }], + ["skills/gone/SKILL.md", { bytes: utf8("# gone") }], + ]); + const bridge = fakeBridge(files); + const cache = new ArmoryCache({ homeDirectory: home }); + await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + + files.set("skills/one/SKILL.md", { bytes: utf8("# one, edited") }); + files.delete("skills/gone/SKILL.md"); + const state = await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + + expect(state.fileCount).toBe(1); + expect(await read(home, "skills/one/SKILL.md")).toBe("# one, edited"); + expect(await readdir(join(filesRoot(home), "skills"))).toEqual(["one"]); + }); + + test("binary entries round-trip byte-exactly through base64", async () => { + const home = await makeHome(); + const bytes = new Uint8Array([0, 1, 2, 255, 254, 0, 128]); + const bridge = fakeBridge(new Map([["plugins/claude/blob.bin", { bytes }]])); + const cache = new ArmoryCache({ homeDirectory: home }); + + await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + + const written = new Uint8Array(await Bun.file(join(filesRoot(home), "plugins/claude/blob.bin")).arrayBuffer()); + expect([...written]).toEqual([...bytes]); + }); + + test("a file whose bytes do not match the manifest hash fails the sync and leaves the applied state intact", async () => { + const home = await makeHome(); + const files = new Map([["skills/one/SKILL.md", { bytes: utf8("# one") }]]); + const bridge = fakeBridge(files); + const cache = new ArmoryCache({ homeDirectory: home }); + const good = await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + + files.set("skills/two/SKILL.md", { bytes: utf8("# two"), served: utf8("# tampered") }); + const error = await rejection(cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() })); + + expect(error).toBeInstanceOf(ArmorySyncError); + expect(error.message).toContain("skills/two/SKILL.md"); + const state = await cache.state(); + expect(state.revision).toBe(good.revision); + expect(state.fileCount).toBe(1); + expect(state.lastError).toContain("skills/two/SKILL.md"); + }); + + test("a manifest with a traversing path is rejected wholesale and writes nothing", async () => { + const home = await makeHome(); + const evil = utf8("owned"); + const bridge = fakeBridge(new Map([["skills/one/SKILL.md", { bytes: utf8("# one") }]]), [ + { path: "../../evil", section: "skills", size: evil.byteLength, sha256: sha256(evil), mode: 0o644 }, + ]); + const cache = new ArmoryCache({ homeDirectory: home }); + + const error = await rejection(cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() })); + + expect(error).toBeInstanceOf(ArmorySyncError); + expect(error.message).toContain("safe armory-relative path"); + expect(bridge.fileRequests()).toHaveLength(0); + expect(await Bun.file(join(filesRoot(home), "skills/one/SKILL.md")).exists()).toBe(false); + expect(await Bun.file(join(home, ".config", "autosmith", "evil")).exists()).toBe(false); + }); + + test("state() on a cold cache is zeroed rather than throwing", async () => { + const cache = new ArmoryCache({ homeDirectory: await makeHome() }); + + expect(await cache.state()).toEqual({ + revision: null, + bridgeUrl: null, + syncedAt: null, + fileCount: 0, + install: null, + lastError: null, + }); + }); + + test("a failed sync records lastError and the next success clears it", async () => { + const home = await makeHome(); + const bridge = fakeBridge(new Map([["skills/one/SKILL.md", { bytes: utf8("# one") }]])); + const cache = new ArmoryCache({ homeDirectory: home }); + const revision = bridge.revision(); + + await rejection(cache.sync({ bridgeUrl: "http://127.0.0.1:1/", revision })); + expect((await cache.state()).lastError).not.toBeNull(); + + const state = await cache.sync({ bridgeUrl: bridge.url, revision }); + expect(state.lastError).toBeNull(); + expect((await cache.state()).lastError).toBeNull(); + }); + + test("a malformed push is a 400 and never reaches the bridge", async () => { + const bridge = fakeBridge(new Map()); + const cache = new ArmoryCache({ homeDirectory: await makeHome() }); + + const error = await rejection(cache.sync({ bridgeUrl: "file:///etc", revision: "a".repeat(64) })); + + expect(error).toMatchObject({ status: 400 }); + expect(bridge.requests).toHaveLength(0); + }); + + test("a path that turns from a file into a directory is replaced, not wedged", async () => { + const home = await makeHome(); + const files = new Map([["skills/one", { bytes: utf8("# one") }]]); + const bridge = fakeBridge(files); + const cache = new ArmoryCache({ homeDirectory: home }); + await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + + files.delete("skills/one"); + files.set("skills/one/SKILL.md", { bytes: utf8("# nested") }); + await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + + expect(await read(home, "skills/one/SKILL.md")).toBe("# nested"); + }); + + test("the routes report the cache and surface a failed pull as 502", async () => { + const home = await makeHome(); + const bridge = fakeBridge(new Map([["skills/one/SKILL.md", { bytes: utf8("# one") }]])); + const app = createApp(stubManager(), stubConfig, undefined, undefined, new ArmoryCache({ homeDirectory: home })); + const call = async (method: string, path: string, body?: unknown) => { + const response = await app.handle( + new Request(`http://ship${path}`, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ); + return { status: response.status, body: await response.json() }; + }; + const revision = bridge.revision(); + + expect((await call("GET", "/armory")).body).toMatchObject({ revision: null, fileCount: 0 }); + + // The bad pushes come first: once a push succeeds the ship is pinned to that + // bridge, and a later push naming another one is refused as a 403 before it + // can fail for the reason under test here. + const failed = await call("POST", "/armory/sync", { bridgeUrl: "http://127.0.0.1:1/", revision }); + expect(failed.status).toBe(502); + expect((await call("POST", "/armory/sync", { bridgeUrl: "not-a-url", revision })).status).toBe(400); + + expect((await call("POST", "/armory/sync", { bridgeUrl: bridge.url, revision })).body).toMatchObject({ + revision, + fileCount: 1, + }); + expect((await call("GET", "/armory")).body).toMatchObject({ revision, fileCount: 1 }); + }); + + test("a cached file that was corrupted on disk is re-downloaded", async () => { + const home = await makeHome(); + const bridge = fakeBridge(new Map([["skills/one/SKILL.md", { bytes: utf8("# one") }]])); + const cache = new ArmoryCache({ homeDirectory: home }); + await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + + // Same size, different bytes: only the hash check can catch this. + await writeFile(join(filesRoot(home), "skills/one/SKILL.md"), "# ONE"); + // Force the pull past the unchanged-revision shortcut, as a bridge restart would. + await rm(join(home, ".config", "autosmith", "fleet-ship", "armory", "state.json")); + await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + + expect(await read(home, "skills/one/SKILL.md")).toBe("# one"); + }); +}); + +/** + * The push chooses which server the ship installs from, so it is pinned. These + * assert on the fake bridge's request log as much as on the status: the whole + * point is that a refused push causes no fetch at all. + */ +describe("ArmoryCache bridge pinning", () => { + const oneFile = () => new Map([["skills/one/SKILL.md", { bytes: utf8("# one") }]]); + + test("a configured bridge accepts its own pushes and refuses another one without fetching", async () => { + const home = await makeHome(); + const bridge = fakeBridge(oneFile()); + const attacker = fakeBridge(oneFile()); + const cache = new ArmoryCache({ homeDirectory: home, bridgeUrl: bridge.url }); + + const state = await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + expect(state.fileCount).toBe(1); + + const error = await rejection(cache.sync({ bridgeUrl: attacker.url, revision: attacker.revision() })); + + expect(error).toBeInstanceOf(ArmorySyncError); + expect(error).toMatchObject({ status: 403 }); + expect(error.message).toContain(bridge.url); + expect(error.message).toContain(attacker.url); + expect(attacker.requests).toHaveLength(0); + }); + + test("a configured bridge refuses a push even on a cold cache", async () => { + const attacker = fakeBridge(oneFile()); + const cache = new ArmoryCache({ homeDirectory: await makeHome(), bridgeUrl: "http://bridge.test:4800" }); + + await expect( + cache.sync({ bridgeUrl: attacker.url, revision: attacker.revision() }), + ).rejects.toMatchObject({ status: 403 }); + expect(attacker.requests).toHaveLength(0); + }); + + test("with no configured bridge the first push pins the ship, and later ones must match it", async () => { + const home = await makeHome(); + const bridge = fakeBridge(oneFile()); + const attacker = fakeBridge(oneFile()); + const cache = new ArmoryCache({ homeDirectory: home }); + + expect((await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() })).bridgeUrl).toBe(bridge.url); + + await expect( + cache.sync({ bridgeUrl: attacker.url, revision: attacker.revision() }), + ).rejects.toMatchObject({ status: 403 }); + expect(attacker.requests).toHaveLength(0); + + // The pinned bridge still works afterwards. + expect((await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() })).fileCount).toBe(1); + }); + + test("a refused push leaves the applied state exactly as it was", async () => { + const home = await makeHome(); + const bridge = fakeBridge(oneFile()); + const attacker = fakeBridge(new Map([["skills/evil/SKILL.md", { bytes: utf8("# evil") }]])); + const cache = new ArmoryCache({ homeDirectory: home, bridgeUrl: bridge.url }); + const good = await cache.sync({ bridgeUrl: bridge.url, revision: bridge.revision() }); + + await expect( + cache.sync({ bridgeUrl: attacker.url, revision: attacker.revision() }), + ).rejects.toMatchObject({ status: 403 }); + + // Not even `lastError`: the ship is in sync, and a refused push is not its failure. + expect(await cache.state()).toEqual(good); + expect(await read(home, "skills/one/SKILL.md")).toBe("# one"); + expect(await Bun.file(join(filesRoot(home), "skills/evil/SKILL.md")).exists()).toBe(false); + }); + + test("the route answers a refused push with 403", async () => { + const attacker = fakeBridge(oneFile()); + const app = createApp(stubManager(), stubConfig, undefined, undefined, new ArmoryCache({ + homeDirectory: await makeHome(), + bridgeUrl: "http://bridge.test:4800", + })); + + const response = await app.handle( + new Request("http://ship/armory/sync", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ bridgeUrl: attacker.url, revision: attacker.revision() }), + }), + ); + + expect(response.status).toBe(403); + expect(((await response.json()) as { error: string }).error).toContain("refused"); + expect(attacker.requests).toHaveLength(0); + }); + + test("the pin compares origins, not strings", async () => { + const home = await makeHome(); + const bridge = fakeBridge(oneFile()); + const port = new URL(bridge.url).port; + const cache = new ArmoryCache({ homeDirectory: home, bridgeUrl: bridge.url }); + const revision = bridge.revision(); + + for (const equivalent of [`${bridge.url}/`, `http://LOCALHOST:${port}`, `HTTP://localhost:${port}/`]) { + expect((await cache.sync({ bridgeUrl: equivalent, revision })).fileCount).toBe(1); + } + + for (const different of [`http://localhost:${Number(port) + 1}`, `http://127.0.0.1:${port}`]) { + await expect(cache.sync({ bridgeUrl: different, revision })).rejects.toMatchObject({ status: 403 }); + } + }); +}); + +describe("normalizeBridgeUrl", () => { + test("scheme, host, and a trailing slash do not change a bridge's identity", () => { + const canonical = normalizeBridgeUrl("http://bridge:4800"); + expect(canonical).toBe("http://bridge:4800"); + for (const equivalent of [ + "http://Bridge:4800/", + "HTTP://BRIDGE:4800", + "http://bridge:4800///", + "http://bridge:4800/?x=1#frag", + ]) { + expect(normalizeBridgeUrl(equivalent)).toBe(canonical); + } + }); + + test("a path is part of the identity, and a default port is not", () => { + expect(normalizeBridgeUrl("http://bridge/fleet/")).toBe("http://bridge/fleet"); + expect(normalizeBridgeUrl("http://bridge/fleet")).not.toBe(normalizeBridgeUrl("http://bridge/other")); + expect(normalizeBridgeUrl("http://bridge:80/")).toBe(normalizeBridgeUrl("http://bridge")); + expect(normalizeBridgeUrl("https://bridge:443/")).toBe(normalizeBridgeUrl("https://bridge")); + }); + + test("a different host, port, or scheme is a different bridge", () => { + expect(normalizeBridgeUrl("http://bridge:4800")).not.toBe(normalizeBridgeUrl("http://bridge:4801")); + expect(normalizeBridgeUrl("http://bridge:4800")).not.toBe(normalizeBridgeUrl("http://other:4800")); + expect(normalizeBridgeUrl("http://bridge:4800")).not.toBe(normalizeBridgeUrl("https://bridge:4800")); + }); + + test("what is not a URL is nothing this can match", () => { + expect(normalizeBridgeUrl("not-a-url")).toBeNull(); + expect(normalizeBridgeUrl("")).toBeNull(); + }); +}); diff --git a/packages/fleet-ship/tests/armory-installer.test.ts b/packages/fleet-ship/tests/armory-installer.test.ts new file mode 100644 index 0000000..e5fb742 --- /dev/null +++ b/packages/fleet-ship/tests/armory-installer.test.ts @@ -0,0 +1,270 @@ +/** + * armory-installer.test.ts — drives `installArmory` against a temp home holding + * both a fabricated armory cache and fabricated provider config roots. The + * cache is written as real files rather than mocked because what the installer + * has to get right is bytes and modes reaching provider directories. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, lstat, mkdir, mkdtemp, readlink, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { DotfileMap } from "fleet-protocol"; +import { installArmory } from "../src/armory/armory-installer"; + +describe("installArmory", () => { + const dirs: string[] = []; + + afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); + }); + + const fixture = async () => { + const homeDirectory = await mkdtemp(join(tmpdir(), "fleet-armory-install-")); + dirs.push(homeDirectory); + const cacheDirectory = join(homeDirectory, ".config", "autosmith", "fleet-ship", "armory"); + return { homeDirectory, cacheDirectory }; + }; + + /** Write one file into the fabricated cache, as `ArmoryCache` would have. */ + const cached = async ( + cacheDirectory: string, + path: string, + contents: string | Uint8Array, + mode = 0o644, + ) => { + const target = join(cacheDirectory, "files", ...path.split("/")); + await mkdir(dirname(target), { recursive: true }); + await Bun.write(target, contents); + await chmod(target, mode); + return target; + }; + + /** The one `state.json` field the installer reads back: the dotfile map. */ + const cachedDotfileMap = async (cacheDirectory: string, dotfileMap: DotfileMap) => { + await mkdir(cacheDirectory, { recursive: true }); + await Bun.write( + join(cacheDirectory, "state.json"), + JSON.stringify({ + revision: null, + bridgeUrl: null, + syncedAt: null, + entries: [], + dotfileMap, + install: null, + lastError: null, + }), + ); + }; + + const providers = async (homeDirectory: string, ...names: string[]) => { + for (const name of names) await mkdir(join(homeDirectory, name), { recursive: true }); + }; + + const exists = async (path: string) => { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }; + + test("returns an empty report when nothing has ever been synced", async () => { + const { homeDirectory } = await fixture(); + await providers(homeDirectory, ".claude"); + + expect(await installArmory({ homeDirectory })).toEqual({ + skills: [], + plugins: [], + dotfiles: [], + removed: [], + conflicts: [], + warnings: [], + }); + expect(await exists(join(homeDirectory, ".claude", "skills"))).toBe(false); + }); + + test("fans a skill out to every present provider, including both codex roots", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await providers(homeDirectory, ".claude", ".config/opencode", ".codex"); + await cached(cacheDirectory, "skills/demo/SKILL.md", "# demo\n"); + await cached(cacheDirectory, "skills/demo/references/notes.md", "notes\n"); + + const report = await installArmory({ homeDirectory }); + + const roots = [ + join(homeDirectory, ".claude", "skills"), + join(homeDirectory, ".config", "opencode", "skills"), + join(homeDirectory, ".codex", "skills"), + join(homeDirectory, ".agents", "skills"), + ]; + expect(report.skills).toHaveLength(roots.length * 2); + expect(new Set(report.skills.map(({ skill }) => skill))).toEqual(new Set(["demo"])); + expect(report.skills.every(({ status }) => status === "installed")).toBe(true); + for (const root of roots) { + expect(await Bun.file(join(root, "demo", "SKILL.md")).text()).toBe("# demo\n"); + expect(await Bun.file(join(root, "demo", "references", "notes.md")).text()).toBe("notes\n"); + } + // Absent provider: nothing is created for it. + expect(await exists(join(homeDirectory, ".copilot"))).toBe(false); + }); + + test("places a plugin tree under the provider's config root and keeps it executable", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await providers(homeDirectory, ".claude"); + await cached(cacheDirectory, "plugins/claude-code/plugins/mine/plugin.json", '{"name":"mine"}\n'); + await cached( + cacheDirectory, + "plugins/claude-code/plugins/mine/run.sh", + "#!/usr/bin/env bash\nexit 0\n", + 0o755, + ); + + const report = await installArmory({ homeDirectory }); + + const manifest = join(homeDirectory, ".claude", "plugins", "mine", "plugin.json"); + const script = join(homeDirectory, ".claude", "plugins", "mine", "run.sh"); + expect(report.plugins.map(({ provider, path }) => ({ provider, path }))).toEqual([ + { provider: "claude-code", path: manifest }, + { provider: "claude-code", path: script }, + ]); + expect(await Bun.file(manifest).text()).toBe('{"name":"mine"}\n'); + expect((await stat(script)).mode & 0o777).toBe(0o755); + expect((await stat(manifest)).mode & 0o777).toBe(0o644); + }); + + test("copies a plugin whose bytes are not valid UTF-8 byte for byte", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await providers(homeDirectory, ".copilot"); + const binary = new Uint8Array([0x00, 0xff, 0xfe, 0x80, 0x41, 0xc3, 0x28]); + await cached(cacheDirectory, "plugins/copilot/blobs/data.bin", binary); + + await installArmory({ homeDirectory }); + + const installed = await Bun.file( + join(homeDirectory, ".copilot", "blobs", "data.bin"), + ).bytes(); + expect([...installed]).toEqual([...binary]); + }); + + test("uninstalls what left the armory, and prunes the directories that empties", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await providers(homeDirectory, ".claude"); + await cached(cacheDirectory, "skills/gone/SKILL.md", "# gone\n"); + await cached(cacheDirectory, "skills/gone/scripts/go.sh", "exit 0\n", 0o755); + await cached(cacheDirectory, "skills/stays/SKILL.md", "# stays\n"); + await installArmory({ homeDirectory }); + + await rm(join(cacheDirectory, "files", "skills", "gone"), { recursive: true }); + const report = await installArmory({ homeDirectory }); + + const skills = join(homeDirectory, ".claude", "skills"); + expect(new Set(report.removed)).toEqual( + new Set([join(skills, "gone", "SKILL.md"), join(skills, "gone", "scripts", "go.sh")]), + ); + expect(await exists(join(skills, "gone"))).toBe(false); + expect(await exists(skills)).toBe(true); + expect(await Bun.file(join(skills, "stays", "SKILL.md")).text()).toBe("# stays\n"); + expect(report.skills.map(({ status }) => status)).toEqual(["unchanged"]); + }); + + test("leaves a user-edited file alone when the armory drops it, and says so", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await providers(homeDirectory, ".claude"); + await cached(cacheDirectory, "skills/gone/SKILL.md", "# gone\n"); + await installArmory({ homeDirectory }); + const destination = join(homeDirectory, ".claude", "skills", "gone", "SKILL.md"); + await Bun.write(destination, "# mine now\n"); + + await rm(join(cacheDirectory, "files", "skills", "gone"), { recursive: true }); + const report = await installArmory({ homeDirectory }); + + expect(report.removed).toEqual([]); + expect(report.warnings.join("\n")).toContain(destination); + expect(await Bun.file(destination).text()).toBe("# mine now\n"); + }); + + test("preserves an unmanaged file at a destination until forced", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await providers(homeDirectory, ".claude"); + const destination = join(homeDirectory, ".claude", "skills", "demo", "SKILL.md"); + await mkdir(dirname(destination), { recursive: true }); + await Bun.write(destination, "# user\n"); + await cached(cacheDirectory, "skills/demo/SKILL.md", "# armory\n"); + + const conflicted = await installArmory({ homeDirectory }); + expect(conflicted.conflicts).toEqual([destination]); + expect(conflicted.skills.map(({ status }) => status)).toEqual(["conflict"]); + expect(await Bun.file(destination).text()).toBe("# user\n"); + + const forced = await installArmory({ homeDirectory, force: true }); + expect(forced.conflicts).toEqual([]); + expect(await Bun.file(destination).text()).toBe("# armory\n"); + }); + + test("warns about an unknown plugin provider and a loose file under skills", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await providers(homeDirectory, ".claude"); + await cached(cacheDirectory, "skills/loose.md", "not a skill\n"); + await cached(cacheDirectory, "plugins/cursor/config.json", "{}\n"); + + const report = await installArmory({ homeDirectory }); + + expect(report.skills).toEqual([]); + expect(report.plugins).toEqual([]); + expect(report.warnings.join("\n")).toContain("skills/loose.md"); + expect(report.warnings.join("\n")).toContain("plugins/cursor"); + expect(await exists(join(homeDirectory, ".claude", "skills"))).toBe(false); + expect(await exists(join(homeDirectory, ".claude", "config.json"))).toBe(false); + }); + + test("installs no dotfile until the cache has recorded a map for it", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await providers(homeDirectory, ".claude"); + await cached(cacheDirectory, "dotfiles/gitconfig", "[user]\n"); + + const report = await installArmory({ homeDirectory }); + + expect(report).toEqual({ + skills: [], + plugins: [], + dotfiles: [], + removed: [], + conflicts: [], + warnings: [], + }); + expect(await exists(join(homeDirectory, ".gitconfig"))).toBe(false); + }); + + test("symlinks the cached dotfile map, and unlinks a mapping it drops", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await providers(homeDirectory, ".claude"); + await cached(cacheDirectory, "dotfiles/.tmux.conf", "set -g mouse on\n"); + await cached(cacheDirectory, "dotfiles/nvim/init.lua", "vim.o.number = true\n"); + await cachedDotfileMap(cacheDirectory, { + ".tmux.conf": "~/.tmux.conf", + nvim: "~/.config/nvim", + }); + + const installed = await installArmory({ homeDirectory }); + + const tmux = join(homeDirectory, ".tmux.conf"); + const nvim = join(homeDirectory, ".config", "nvim"); + expect(installed.dotfiles.map(({ target, status }) => ({ target, status }))).toEqual([ + { target: tmux, status: "linked" }, + { target: nvim, status: "linked" }, + ]); + expect(await readlink(tmux)).toBe(join(cacheDirectory, "files", "dotfiles", ".tmux.conf")); + expect(await Bun.file(join(nvim, "init.lua")).text()).toBe("vim.o.number = true\n"); + + await cachedDotfileMap(cacheDirectory, { ".tmux.conf": "~/.tmux.conf" }); + const dropped = await installArmory({ homeDirectory }); + + expect(dropped.dotfiles.map(({ status }) => status)).toEqual(["unchanged"]); + expect(dropped.removed).toEqual([nvim]); + expect(await exists(nvim)).toBe(false); + expect(await exists(tmux)).toBe(true); + }); +}); diff --git a/packages/fleet-ship/tests/armory-sync.test.ts b/packages/fleet-ship/tests/armory-sync.test.ts new file mode 100644 index 0000000..327db1d --- /dev/null +++ b/packages/fleet-ship/tests/armory-sync.test.ts @@ -0,0 +1,79 @@ +/** + * armory-sync.test.ts — the pull-then-install orchestration. The bridge is a + * stub `fetch` serving an empty manifest because what is under test is the + * ordering of the two halves, not the pull itself (see armory-cache.test.ts). + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ArmoryCache } from "../src/armory/armory-cache"; +import type { ArmoryInstallReport } from "../src/armory/armory-installer"; +import { syncAndInstall } from "../src/armory/armory-sync"; + +describe("syncAndInstall", () => { + const dirs: string[] = []; + const revision = "a".repeat(64); + + afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); + }); + + const cacheFor = async () => { + const homeDirectory = await mkdtemp(join(tmpdir(), "fleet-armory-sync-")); + dirs.push(homeDirectory); + const fetchImpl = (async (input: Parameters[0]) => + String(input).endsWith("/armory") + ? Response.json({ revision, entries: [], dotfileMap: {} }) + : new Response("no such file", { status: 404 })) as typeof fetch; + return new ArmoryCache({ homeDirectory, fetch: fetchImpl }); + }; + + const request = { bridgeUrl: "http://bridge.test", revision }; + + test("records what the install applied", async () => { + const cache = await cacheFor(); + const report: ArmoryInstallReport = { + skills: [{ skill: "demo", provider: "claude-code", path: "/home/x", status: "installed" }], + plugins: [], + dotfiles: [ + { source: ".tmux.conf", target: "/home/.tmux.conf", status: "linked" }, + { source: "nvim", target: "/home/.config/nvim", status: "conflict" }, + ], + removed: ["/home/y"], + conflicts: ["/home/z"], + warnings: ["careful"], + }; + + const state = await syncAndInstall(cache, request, { install: async () => report }); + + expect(state).toMatchObject({ revision, lastError: null }); + expect(state.install).toMatchObject({ + skillCount: 1, + pluginCount: 0, + // The conflicted mapping is not a link in place. + dotfileCount: 1, + removedCount: 1, + conflicts: ["/home/z"], + warnings: ["careful"], + }); + expect((await cache.state()).install?.installedAt).toBe(state.install!.installedAt); + }); + + test("a failed install is reported without discarding the successful pull", async () => { + const cache = await cacheFor(); + + await expect( + syncAndInstall(cache, request, { + install: async () => { + throw new AggregateError([new Error("permission denied: /home/x")], "nope"); + }, + }), + ).rejects.toMatchObject({ status: 500 }); + + const state = await cache.state(); + expect(state.revision).toBe(revision); + expect(state.lastError).toContain("permission denied: /home/x"); + }); +}); diff --git a/packages/fleet-ship/tests/dotfile-linker.test.ts b/packages/fleet-ship/tests/dotfile-linker.test.ts new file mode 100644 index 0000000..5525100 --- /dev/null +++ b/packages/fleet-ship/tests/dotfile-linker.test.ts @@ -0,0 +1,268 @@ +/** + * dotfile-linker.test.ts — drives `linkDotfiles` against a temp home holding a + * fabricated armory cache. Links are checked with `lstat`/`readlink` as well as + * by reading through them: "the content is right" and "it is a symlink into the + * cache" are separate claims, and only the second one distinguishes this + * installer from a copy. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { lstat, mkdir, mkdtemp, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { linkDotfiles } from "../src/armory/dotfile-linker"; + +describe("linkDotfiles", () => { + const dirs: string[] = []; + + afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); + }); + + const fixture = async () => { + const homeDirectory = await mkdtemp(join(tmpdir(), "fleet-dotfile-link-")); + dirs.push(homeDirectory); + const cacheDirectory = join(homeDirectory, ".config", "autosmith", "fleet-ship", "armory"); + return { homeDirectory, cacheDirectory }; + }; + + /** Write one file under the cache's `files/dotfiles/`, as `ArmoryCache` would have. */ + const cached = async (cacheDirectory: string, path: string, contents: string) => { + const target = join(cacheDirectory, "files", "dotfiles", ...path.split("/")); + await mkdir(dirname(target), { recursive: true }); + await Bun.write(target, contents); + return target; + }; + + const exists = async (path: string) => { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }; + + test("links a file and a directory, and both read through to the cache", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + const conf = await cached(cacheDirectory, ".tmux.conf", "set -g mouse on\n"); + await cached(cacheDirectory, "nvim/init.lua", "vim.o.number = true\n"); + + const report = await linkDotfiles({ + homeDirectory, + cacheDirectory, + dotfileMap: { ".tmux.conf": "~/.tmux.conf", nvim: "~/.config/nvim" }, + }); + + const tmux = join(homeDirectory, ".tmux.conf"); + const nvim = join(homeDirectory, ".config", "nvim"); + expect(report.links).toEqual([ + { source: ".tmux.conf", target: tmux, status: "linked" }, + { source: "nvim", target: nvim, status: "linked" }, + ]); + expect(report).toMatchObject({ removed: [], conflicts: [], warnings: [] }); + expect((await lstat(tmux)).isSymbolicLink()).toBe(true); + expect(await readlink(tmux)).toBe(conf); + expect(await Bun.file(tmux).text()).toBe("set -g mouse on\n"); + // One link for the whole directory, not one per file inside it. + expect((await lstat(nvim)).isSymbolicLink()).toBe(true); + expect(await readlink(nvim)).toBe(join(cacheDirectory, "files", "dotfiles", "nvim")); + expect(await Bun.file(join(nvim, "init.lua")).text()).toBe("vim.o.number = true\n"); + }); + + test("creates the target's missing parent directories", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await cached(cacheDirectory, "starship.toml", "add_newline = false\n"); + + const report = await linkDotfiles({ + homeDirectory, + cacheDirectory, + dotfileMap: { "starship.toml": "~/deeply/nested/starship.toml" }, + }); + + expect(report.links.map(({ status }) => status)).toEqual(["linked"]); + const target = join(homeDirectory, "deeply", "nested", "starship.toml"); + expect((await lstat(target)).isSymbolicLink()).toBe(true); + expect(await Bun.file(target).text()).toBe("add_newline = false\n"); + }); + + test("re-running an unchanged map relinks nothing", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await cached(cacheDirectory, ".tmux.conf", "set -g mouse on\n"); + const dotfileMap = { ".tmux.conf": "~/.tmux.conf" }; + await linkDotfiles({ homeDirectory, cacheDirectory, dotfileMap }); + + const report = await linkDotfiles({ homeDirectory, cacheDirectory, dotfileMap }); + + expect(report.links.map(({ status }) => status)).toEqual(["unchanged"]); + expect(report.removed).toEqual([]); + }); + + test("retargets its own link when the map moves the target to another source", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await cached(cacheDirectory, "one.conf", "one\n"); + const two = await cached(cacheDirectory, "two.conf", "two\n"); + await linkDotfiles({ + homeDirectory, + cacheDirectory, + dotfileMap: { "one.conf": "~/.thing" }, + }); + + const report = await linkDotfiles({ + homeDirectory, + cacheDirectory, + dotfileMap: { "two.conf": "~/.thing" }, + }); + + const target = join(homeDirectory, ".thing"); + expect(report.links).toEqual([{ source: "two.conf", target, status: "relinked" }]); + expect(report.removed).toEqual([]); + expect(await readlink(target)).toBe(two); + expect(await Bun.file(target).text()).toBe("two\n"); + }); + + test("keeps a real file at the target byte-intact until forced", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await cached(cacheDirectory, ".tmux.conf", "from the armory\n"); + const target = join(homeDirectory, ".tmux.conf"); + await writeFile(target, "mine\n"); + const dotfileMap = { ".tmux.conf": "~/.tmux.conf" }; + + const conflicted = await linkDotfiles({ homeDirectory, cacheDirectory, dotfileMap }); + + expect(conflicted.conflicts).toEqual([target]); + expect(conflicted.links[0]).toMatchObject({ status: "conflict" }); + expect((await lstat(target)).isSymbolicLink()).toBe(false); + expect(await Bun.file(target).text()).toBe("mine\n"); + + const forced = await linkDotfiles({ homeDirectory, cacheDirectory, dotfileMap, force: true }); + + expect(forced.conflicts).toEqual([]); + expect(forced.links[0]).toMatchObject({ status: "linked", detail: "replaced a file" }); + expect((await lstat(target)).isSymbolicLink()).toBe(true); + expect(await Bun.file(target).text()).toBe("from the armory\n"); + }); + + test("leaves a symlink that points outside the cache alone", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await cached(cacheDirectory, ".tmux.conf", "from the armory\n"); + const elsewhere = join(homeDirectory, "other-dotfiles.conf"); + await writeFile(elsewhere, "somebody else's\n"); + const target = join(homeDirectory, ".tmux.conf"); + await symlink(elsewhere, target); + + const report = await linkDotfiles({ + homeDirectory, + cacheDirectory, + dotfileMap: { ".tmux.conf": "~/.tmux.conf" }, + }); + + expect(report.conflicts).toEqual([target]); + expect(report.links[0]?.detail).toContain(elsewhere); + expect(await readlink(target)).toBe(elsewhere); + }); + + test("removes the link of a dropped mapping, but never its parent directory", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await cached(cacheDirectory, ".tmux.conf", "tmux\n"); + await cached(cacheDirectory, "nvim/init.lua", "nvim\n"); + await linkDotfiles({ + homeDirectory, + cacheDirectory, + dotfileMap: { ".tmux.conf": "~/.tmux.conf", nvim: "~/.config/nvim" }, + }); + + const report = await linkDotfiles({ + homeDirectory, + cacheDirectory, + dotfileMap: { ".tmux.conf": "~/.tmux.conf" }, + }); + + expect(report.removed).toEqual([join(homeDirectory, ".config", "nvim")]); + expect(await exists(join(homeDirectory, ".config", "nvim"))).toBe(false); + expect(await exists(join(homeDirectory, ".config"))).toBe(true); + expect(await exists(join(homeDirectory, ".tmux.conf"))).toBe(true); + }); + + test("does not remove a target the user has since replaced with a real file", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await cached(cacheDirectory, ".tmux.conf", "tmux\n"); + await linkDotfiles({ + homeDirectory, + cacheDirectory, + dotfileMap: { ".tmux.conf": "~/.tmux.conf" }, + }); + const target = join(homeDirectory, ".tmux.conf"); + await rm(target); + await writeFile(target, "mine now\n"); + + const report = await linkDotfiles({ homeDirectory, cacheDirectory, dotfileMap: {} }); + + expect(report.removed).toEqual([]); + expect(report.warnings.join("\n")).toContain(target); + expect(await Bun.file(target).text()).toBe("mine now\n"); + }); + + test("skips a destination outside the home directory", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await cached(cacheDirectory, "passwd", "root:x:0:0:::\n"); + await cached(cacheDirectory, "escape", "escaped\n"); + + const report = await linkDotfiles({ + homeDirectory, + cacheDirectory, + dotfileMap: { passwd: "/etc/passwd", escape: "~/../escape" }, + }); + + expect(report.links.map(({ status }) => status)).toEqual(["skipped", "skipped"]); + expect(report.warnings.join("\n")).toContain("/etc/passwd"); + expect(report.warnings.join("\n")).toContain("~/../escape"); + expect(report.removed).toEqual([]); + expect((await lstat("/etc/passwd")).isSymbolicLink()).toBe(false); + expect(await exists(join(dirname(homeDirectory), "escape"))).toBe(false); + }); + + test("skips a mapping whose source is missing from the cache, or unsafe", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await cached(cacheDirectory, "present.conf", "here\n"); + + const report = await linkDotfiles({ + homeDirectory, + cacheDirectory, + dotfileMap: { "ghost.conf": "~/.ghost", "../outside.conf": "~/.outside" }, + }); + + expect(report.links.map(({ status }) => status)).toEqual(["skipped", "skipped"]); + expect(report.warnings.join("\n")).toContain("dotfiles/ghost.conf is not in the armory cache"); + expect(report.warnings.join("\n")).toContain("not a safe path"); + expect(await exists(join(homeDirectory, ".ghost"))).toBe(false); + expect(await exists(join(homeDirectory, ".outside"))).toBe(false); + }); + + test("reports a failing mapping as an AggregateError without losing the others", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await cached(cacheDirectory, "blocked.conf", "blocked\n"); + await cached(cacheDirectory, "fine.conf", "fine\n"); + // A regular file where a parent directory would have to go: `mkdir` fails + // with ENOTDIR, which is a failure rather than a conflict. + await writeFile(join(homeDirectory, "wedged"), "not a directory\n"); + + const failure = await linkDotfiles({ + homeDirectory, + cacheDirectory, + dotfileMap: { "blocked.conf": "~/wedged/blocked.conf", "fine.conf": "~/.fine" }, + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(AggregateError); + const errors = (failure as AggregateError).errors as Error[]; + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toContain(join(homeDirectory, "wedged", "blocked.conf")); + const fine = join(homeDirectory, ".fine"); + expect(await Bun.file(fine).text()).toBe("fine\n"); + + // The successful link was recorded, so dropping its mapping still undoes it. + const report = await linkDotfiles({ homeDirectory, cacheDirectory, dotfileMap: {} }); + expect(report.removed).toEqual([fine]); + }); +}); diff --git a/packages/fleet-ship/tests/managed-fs-remove.test.ts b/packages/fleet-ship/tests/managed-fs-remove.test.ts new file mode 100644 index 0000000..56d3e19 --- /dev/null +++ b/packages/fleet-ship/tests/managed-fs-remove.test.ts @@ -0,0 +1,104 @@ +/** + * managed-fs-remove.test.ts — `ManagedFileSession.remove`, the uninstall half of + * the managed-file contract, asserted directly rather than through an installer. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { lstat, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { withManagedFiles } from "../src/managed-fs"; + +describe("ManagedFileSession.remove", () => { + const dirs: string[] = []; + const owner = { provider: "claude-code", kind: "skill" } as const; + + afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); + }); + + const home = async () => { + const directory = await mkdtemp(join(tmpdir(), "fleet-managed-remove-")); + dirs.push(directory); + return directory; + }; + + const exists = async (path: string) => { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }; + + test("removes a file Fleet still owns", async () => { + const homeDirectory = await home(); + const destination = join(homeDirectory, ".claude", "skills", "demo", "SKILL.md"); + + const outcome = await withManagedFiles(homeDirectory, async (session) => { + await session.sync(destination, "# demo\n", { ...owner, mode: 0o644 }); + return session.remove(destination, owner); + }); + + expect(outcome).toBe("removed"); + expect(await exists(destination)).toBe(false); + }); + + test("keeps a file the user has edited since it was installed", async () => { + const homeDirectory = await home(); + const destination = join(homeDirectory, ".claude", "skills", "demo", "SKILL.md"); + await withManagedFiles(homeDirectory, (session) => + session.sync(destination, "# demo\n", { ...owner, mode: 0o644 }), + ); + await Bun.write(destination, "# mine\n"); + + const outcome = await withManagedFiles(homeDirectory, (session) => + session.remove(destination, owner), + ); + + expect(outcome).toBe("not-owned"); + expect(await Bun.file(destination).text()).toBe("# mine\n"); + }); + + test("reports an unmanaged file as not-owned and a vanished one as missing", async () => { + const homeDirectory = await home(); + const unmanaged = join(homeDirectory, ".claude", "settings.json"); + await Bun.write(unmanaged, "{}\n"); + + const outcomes = await withManagedFiles(homeDirectory, async (session) => [ + await session.remove(unmanaged, owner), + await session.remove(join(homeDirectory, ".claude", "nothing-here.json"), owner), + await session.remove(join(homeDirectory, ".nowhere", "nothing-here.json"), owner), + ]); + + expect(outcomes).toEqual(["not-owned", "missing", "missing"]); + expect(await Bun.file(unmanaged).text()).toBe("{}\n"); + }); + + test("will not touch a destination outside the home directory", async () => { + const homeDirectory = await home(); + const outside = await home(); + const destination = join(outside, "SKILL.md"); + await Bun.write(destination, "# elsewhere\n"); + + await expect( + withManagedFiles(homeDirectory, (session) => session.remove(destination, owner)), + ).rejects.toThrow("outside home directory"); + expect(await Bun.file(destination).text()).toBe("# elsewhere\n"); + }); + + test("does not delete a file another provider owns", async () => { + const homeDirectory = await home(); + const destination = join(homeDirectory, ".claude", "skills", "demo", "SKILL.md"); + + const outcome = await withManagedFiles(homeDirectory, async (session) => { + await session.sync(destination, "# demo\n", { ...owner, mode: 0o644 }); + return session.remove(destination, { provider: "opencode", kind: "skill" }); + }); + + expect(outcome).toBe("not-owned"); + expect(await exists(destination)).toBe(true); + }); +});