From f0ccb445adce33f606c0724c59c7a4e3f849715c Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Sun, 26 Jul 2026 17:08:40 -0500 Subject: [PATCH 1/8] Serve the armory manifest and files from the bridge The armory is a bridge-owned directory of skills, plugins, and dotfiles that ships install. This adds its read side: `ArmoryService` scans `/armory` into a content-addressed manifest whose revision is a pure function of armory content, and `GET /armory` plus `GET /armory/file?path=` expose it. The scan is defensive because the directory is hand-edited or git-synced: symlinks are never followed or listed, host mode bits are normalized away, and the manifest is the only path a read can name. A broken `dotfile-map.json` fails loudly and names the file, every offending key, and which side of the pair is wrong. Nothing consumes these routes yet; the ship-side pull follows. Co-Authored-By: Claude Opus 5 (1M context) --- packages/fleet-bridge/src/api/armory.ts | 36 ++ packages/fleet-bridge/src/api/index.ts | 6 +- .../fleet-bridge/src/armory/armory-service.ts | 276 +++++++++++++++ packages/fleet-bridge/src/fleet-manager.ts | 51 ++- packages/fleet-bridge/tests/armory.test.ts | 332 ++++++++++++++++++ packages/fleet-protocol/index.ts | 15 + packages/fleet-protocol/src/armory.ts | 143 ++++++++ 7 files changed, 856 insertions(+), 3 deletions(-) create mode 100644 packages/fleet-bridge/src/api/armory.ts create mode 100644 packages/fleet-bridge/src/armory/armory-service.ts create mode 100644 packages/fleet-bridge/tests/armory.test.ts create mode 100644 packages/fleet-protocol/src/armory.ts diff --git a/packages/fleet-bridge/src/api/armory.ts b/packages/fleet-bridge/src/api/armory.ts new file mode 100644 index 0000000..1ca60d1 --- /dev/null +++ b/packages/fleet-bridge/src/api/armory.ts @@ -0,0 +1,36 @@ +/** + * api/armory.ts — the read side of the Armory: the manifest of the bridge's + * `armory/` directory and the contents of any file it lists. Ships poll these to + * decide whether to re-pull. 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() }) }, + ); +} 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..f30decf --- /dev/null +++ b/packages/fleet-bridge/src/armory/armory-service.ts @@ -0,0 +1,276 @@ +/** + * 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 are skipped outright (never + * followed, never listed) because a symlink in the armory 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 manifest is the single source of truth: `readFile` only serves paths the + * manifest lists, which is what confines reads to the three section directories. + * 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 } 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); + + const info = await lstat(target).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ENOTDIR") throw new ArmoryNotFoundError(path); + throw error; + }); + if (!info.isFile()) throw new ArmoryNotFoundError(path); + if (info.size > MAX_ARMORY_FILE_BYTES) throw new ArmoryTooLargeError(path, info.size); + + 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 ------------------------------------------------------------- + + private async scan(): Promise { + const entries: ArmoryEntry[] = []; + for (const section of ARMORY_SECTIONS) { + await this.walk(join(this.root, section), 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"); + for await (const chunk of Bun.file(target).stream()) hasher.update(chunk); + 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); +} + +/** 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/fleet-manager.ts b/packages/fleet-bridge/src/fleet-manager.ts index 782d6e6..14201bd 100644 --- a/packages/fleet-bridge/src/fleet-manager.ts +++ b/packages/fleet-bridge/src/fleet-manager.ts @@ -12,13 +12,17 @@ * routing and duplicate detection. */ +import { join } from "node:path"; import { + ARMORY_DIRECTORY, CreateRepoInputSchema, FleetIdentifierSchema, ShipSchema, WorkspaceRefsSchema, WorkspaceSummarySchema, WorkspaceStatusSchema, + type ArmoryFile, + type ArmoryManifest, type CreateRepoInput, type FleetEvent, type Repo, @@ -40,6 +44,13 @@ import { 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 +112,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)); } /** @@ -407,6 +426,36 @@ 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)); + } + + /** Drop the cached scan — called when the armory directory changes on disk. */ + invalidateArmory(): void { + this.armory.invalidate(); + } + + 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. */ diff --git a/packages/fleet-bridge/tests/armory.test.ts b/packages/fleet-bridge/tests/armory.test.ts new file mode 100644 index 0000000..f6faf12 --- /dev/null +++ b/packages/fleet-bridge/tests/armory.test.ts @@ -0,0 +1,332 @@ +/** + * 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("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-protocol/index.ts b/packages/fleet-protocol/index.ts index 0fe04a9..06d38f3 100644 --- a/packages/fleet-protocol/index.ts +++ b/packages/fleet-protocol/index.ts @@ -39,6 +39,21 @@ 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, + type ArmorySection, + type ArmoryEntry, + type DotfileMap, + type ArmoryManifest, + type ArmoryFile, +} 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..0bc348e --- /dev/null +++ b/packages/fleet-protocol/src/armory.ts @@ -0,0 +1,143 @@ +/** + * 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; From 9c9853b13586e51ac963facb4514a2052eb3d5b8 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Sun, 26 Jul 2026 17:27:54 -0500 Subject: [PATCH 2/8] Push the armory from the bridge and pull it onto each ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge watches its armory directory and, on change, tells every online ship to re-pull; a ship also gets told when it joins the fleet or comes back online, so one that missed a push catches up. The push carries only a revision and the URL to pull from — the ship has no bridge client otherwise, and learns where the bridge is from the push itself. `ArmoryCache` does the pulling into `/.config/autosmith/fleet-ship/armory/`, deliberately not the fleet directory, where `WorkspaceManager` would walk it as a repo. Under HOME it also sits inside the root the managed-file machinery validates against, which the installers to come will need. A manifest is untrusted input: paths are re-validated, destinations proved to stay inside the cache, and every downloaded body checked against its hash before it lands. One bad file fails the whole sync rather than recording a revision that promises more than the cache holds. This caches files only; nothing is installed yet. Co-Authored-By: Claude Opus 5 (1M context) --- .../fleet-bridge/src/armory/armory-watcher.ts | 78 ++++ packages/fleet-bridge/src/config.ts | 18 +- packages/fleet-bridge/src/fleet-manager.ts | 60 ++- packages/fleet-bridge/src/index.ts | 25 +- .../fleet-bridge/tests/armory-push.test.ts | 202 +++++++++ packages/fleet-bridge/tests/helpers.ts | 19 + packages/fleet-protocol/index.ts | 4 + packages/fleet-protocol/src/armory.ts | 30 ++ packages/fleet-ship/src/api/armory.ts | 41 ++ packages/fleet-ship/src/api/index.ts | 8 +- .../fleet-ship/src/armory/armory-cache.ts | 383 ++++++++++++++++++ .../fleet-ship/tests/armory-cache.test.ts | 323 +++++++++++++++ 12 files changed, 1181 insertions(+), 10 deletions(-) create mode 100644 packages/fleet-bridge/src/armory/armory-watcher.ts create mode 100644 packages/fleet-bridge/tests/armory-push.test.ts create mode 100644 packages/fleet-ship/src/api/armory.ts create mode 100644 packages/fleet-ship/src/armory/armory-cache.ts create mode 100644 packages/fleet-ship/tests/armory-cache.test.ts 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 14201bd..e8993ec 100644 --- a/packages/fleet-bridge/src/fleet-manager.ts +++ b/packages/fleet-bridge/src/fleet-manager.ts @@ -34,7 +34,7 @@ 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, @@ -251,6 +251,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 }; } @@ -443,6 +446,55 @@ export class FleetManager { 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(); @@ -663,7 +715,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/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/helpers.ts b/packages/fleet-bridge/tests/helpers.ts index be83094..e1ab5d0 100644 --- a/packages/fleet-bridge/tests/helpers.ts +++ b/packages/fleet-bridge/tests/helpers.ts @@ -23,6 +23,8 @@ 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 }[]; /** All Eden calls resolve to this error `{status, value:{error}}`. */ errorResponse?: { status: number; message: string }; /** All Eden calls throw (simulated network failure). */ @@ -190,6 +192,23 @@ export function makeFakeClient(httpUrl: string, ships: Map) { return { workspaces: workspacesFn, "system-resources": { get: () => wrap(() => fakeResources(ship()?.name ?? "unknown")) }, + armory: { + 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-protocol/index.ts b/packages/fleet-protocol/index.ts index 06d38f3..97974b0 100644 --- a/packages/fleet-protocol/index.ts +++ b/packages/fleet-protocol/index.ts @@ -48,11 +48,15 @@ export { DotfileMapSchema, ArmoryManifestSchema, ArmoryFileSchema, + ArmorySyncRequestSchema, + ArmorySyncStateSchema, type ArmorySection, type ArmoryEntry, type DotfileMap, type ArmoryManifest, type ArmoryFile, + type ArmorySyncRequest, + type ArmorySyncState, } from "./src/armory"; export { diff --git a/packages/fleet-protocol/src/armory.ts b/packages/fleet-protocol/src/armory.ts index 0bc348e..19f5e35 100644 --- a/packages/fleet-protocol/src/armory.ts +++ b/packages/fleet-protocol/src/armory.ts @@ -141,3 +141,33 @@ export const ArmoryFileSchema = ArmoryFileFactsSchema.extend({ }); 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 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(), + /** Message of the most recent failed sync, cleared by the next success. */ + lastError: z.string().nullable(), +}); + +export type ArmorySyncState = z.infer; diff --git a/packages/fleet-ship/src/api/armory.ts b/packages/fleet-ship/src/api/armory.ts new file mode 100644 index 0000000..fa84c2b --- /dev/null +++ b/packages/fleet-ship/src/api/armory.ts @@ -0,0 +1,41 @@ +/** + * api/armory.ts — the ship's armory routes: the bridge pushes `/armory/sync` to + * say "re-pull", and anyone can read back what this ship currently has cached. + * One Elysia chain so route types stay inferable for Eden. + */ + +import { Elysia, t } from "elysia"; +import { ArmoryCache, ArmorySyncError } from "../armory/armory-cache"; +import { mapError } from "./http"; + +export function armoryPlugin(cache: ArmoryCache) { + return new Elysia({ name: "ship-armory" }) + .post( + "/armory/sync", + async ({ body, set }) => { + try { + return await cache.sync(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) or the push body's (400), 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..d801b8a 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,6 +12,8 @@ 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"; @@ -20,12 +22,14 @@ export function createApp( _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()) + .use(armoryPlugin(armory ?? new ArmoryCache())) } 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..edd1b02 --- /dev/null +++ b/packages/fleet-ship/src/armory/armory-cache.ts @@ -0,0 +1,383 @@ +/** + * 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. + * + * /.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. + */ + +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, + ArmoryManifestSchema, + ArmorySyncRequestSchema, + isSafeArmoryPath, + type ArmoryEntry, + type ArmorySyncRequest, + type ArmorySyncState, +} from "fleet-protocol"; + +/** The cache root, relative to the home directory. */ +const CACHE_RELATIVE_PATH = join(".config", "autosmith", "fleet-ship", "armory"); + +/** 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, 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(), + lastError: z.string().nullable(), +}); + +type CachedState = z.infer; + +const EMPTY_STATE: CachedState = { + revision: null, + bridgeUrl: null, + syncedAt: null, + entries: [], + lastError: null, +}; + +export class ArmoryCache { + private readonly root: string; + private readonly filesRoot: string; + private readonly statePath: string; + private readonly fetchImpl: typeof fetch; + private queue: Promise = Promise.resolve(); + + constructor(options?: { homeDirectory?: string; fetch?: typeof fetch }) { + this.root = join(resolve(options?.homeDirectory ?? homedir()), CACHE_RELATIVE_PATH); + this.filesRoot = join(this.root, "files"); + this.statePath = join(this.root, "state.json"); + this.fetchImpl = options?.fetch ?? fetch; + } + + 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(); + 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; + } + }); + } + + 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, + lastError: null, + }; + + 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 { + try { + const parsed = CachedStateSchema.safeParse(await Bun.file(this.statePath).json()); + return parsed.success ? parsed.data : EMPTY_STATE; + } catch { + // Missing or unreadable is simply a cold cache; the next sync rebuilds it. + return EMPTY_STATE; + } + } + + /** 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, + 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"); + for await (const chunk of Bun.file(target).stream()) hasher.update(chunk); + 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/tests/armory-cache.test.ts b/packages/fleet-ship/tests/armory-cache.test.ts new file mode 100644 index 0000000..1962d1b --- /dev/null +++ b/packages/fleet-ship/tests/armory-cache.test.ts @@ -0,0 +1,323 @@ +/** + * 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 } 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, + 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 }); + 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 }); + + 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); + }); + + 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"); + }); +}); From ed4b0f40967a5b172035b35a317cb6522c66c0d9 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Sun, 26 Jul 2026 21:20:31 -0500 Subject: [PATCH 3/8] Install armory skills and plugins on the ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills are modeled and plugins are not, which is the asymmetry the feature asks for. A skill is a directory, so `skills//**` fans out whole to every provider present on the host, including both of codex's destinations. A plugin tree is copied straight into its provider's config root, letting whoever writes the armory decide the layout: guessing each provider's plugin conventions would be wrong more often than they are. Removing something from the armory now uninstalls it. `session.remove` deletes a file only when the manifest claims it and its bytes still hash to what we recorded, so a file the user has since edited is left alone and reported rather than clobbered. It drops the manifest entry before the unlink — a manifest claiming a file that is gone would let a later reinstall trust a hash nothing on disk can satisfy, while the reverse leaves an inert orphan. Managed contents widen to `string | Uint8Array`: an armory plugin can be a binary, and the UTF-8 round trip was corrupting it. Ships also install at boot, so a restart re-applies the cache without waiting for a push. Dotfiles are still untouched. Co-Authored-By: Claude Opus 5 (1M context) --- packages/fleet-protocol/index.ts | 2 + packages/fleet-protocol/src/armory.ts | 22 +- packages/fleet-ship/src/api/armory.ts | 6 +- .../fleet-ship/src/armory/armory-cache.ts | 39 +- .../fleet-ship/src/armory/armory-installer.ts | 378 ++++++++++++++++++ packages/fleet-ship/src/armory/armory-sync.ts | 63 +++ packages/fleet-ship/src/index.ts | 21 + packages/fleet-ship/src/managed-fs.ts | 86 +++- packages/fleet-ship/src/providers.ts | 45 +++ packages/fleet-ship/src/skill-installer.ts | 59 +-- .../fleet-ship/tests/armory-cache.test.ts | 1 + .../fleet-ship/tests/armory-installer.test.ts | 221 ++++++++++ packages/fleet-ship/tests/armory-sync.test.ts | 73 ++++ .../tests/managed-fs-remove.test.ts | 104 +++++ 14 files changed, 1067 insertions(+), 53 deletions(-) create mode 100644 packages/fleet-ship/src/armory/armory-installer.ts create mode 100644 packages/fleet-ship/src/armory/armory-sync.ts create mode 100644 packages/fleet-ship/src/providers.ts create mode 100644 packages/fleet-ship/tests/armory-installer.test.ts create mode 100644 packages/fleet-ship/tests/armory-sync.test.ts create mode 100644 packages/fleet-ship/tests/managed-fs-remove.test.ts diff --git a/packages/fleet-protocol/index.ts b/packages/fleet-protocol/index.ts index 97974b0..ee71327 100644 --- a/packages/fleet-protocol/index.ts +++ b/packages/fleet-protocol/index.ts @@ -50,6 +50,7 @@ export { ArmoryFileSchema, ArmorySyncRequestSchema, ArmorySyncStateSchema, + ArmoryInstallSummarySchema, type ArmorySection, type ArmoryEntry, type DotfileMap, @@ -57,6 +58,7 @@ export { type ArmoryFile, type ArmorySyncRequest, type ArmorySyncState, + type ArmoryInstallSummary, } from "./src/armory"; export { diff --git a/packages/fleet-protocol/src/armory.ts b/packages/fleet-protocol/src/armory.ts index 19f5e35..10ab9ce 100644 --- a/packages/fleet-protocol/src/armory.ts +++ b/packages/fleet-protocol/src/armory.ts @@ -158,6 +158,24 @@ export const ArmorySyncRequestSchema = z.object({ 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(), + /** 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. */ @@ -166,7 +184,9 @@ export const ArmorySyncStateSchema = z.object({ /** ISO timestamp of the last successful sync. */ syncedAt: z.string().nullable(), fileCount: z.number().int().nonnegative(), - /** Message of the most recent failed sync, cleared by the next success. */ + /** 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(), }); diff --git a/packages/fleet-ship/src/api/armory.ts b/packages/fleet-ship/src/api/armory.ts index fa84c2b..173e5f3 100644 --- a/packages/fleet-ship/src/api/armory.ts +++ b/packages/fleet-ship/src/api/armory.ts @@ -1,11 +1,13 @@ /** * api/armory.ts — the ship's armory routes: the bridge pushes `/armory/sync` to - * say "re-pull", and anyone can read back what this ship currently has cached. + * 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) { @@ -14,7 +16,7 @@ export function armoryPlugin(cache: ArmoryCache) { "/armory/sync", async ({ body, set }) => { try { - return await cache.sync(body); + return await syncAndInstall(cache, body); } catch (err) { const mapped = mapArmoryError(err); set.status = mapped.status; diff --git a/packages/fleet-ship/src/armory/armory-cache.ts b/packages/fleet-ship/src/armory/armory-cache.ts index edd1b02..2f7c946 100644 --- a/packages/fleet-ship/src/armory/armory-cache.ts +++ b/packages/fleet-ship/src/armory/armory-cache.ts @@ -4,7 +4,9 @@ * 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. + * 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 @@ -30,10 +32,12 @@ import { z } from "zod"; import { ArmoryEntrySchema, ArmoryFileSchema, + ArmoryInstallSummarySchema, ArmoryManifestSchema, ArmorySyncRequestSchema, isSafeArmoryPath, type ArmoryEntry, + type ArmoryInstallSummary, type ArmorySyncRequest, type ArmorySyncState, } from "fleet-protocol"; @@ -41,6 +45,11 @@ import { /** 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); +} + /** A sync that failed, carrying the status the ship's route should answer with. */ export class ArmorySyncError extends Error { constructor( @@ -63,6 +72,8 @@ const CachedStateSchema = z.object({ bridgeUrl: z.string().nullable(), syncedAt: z.string().nullable(), entries: ArmoryEntrySchema.array(), + /** Recorded by whoever installs from the cache; the cache never produces it. */ + install: ArmoryInstallSummarySchema.nullable().default(null), lastError: z.string().nullable(), }); @@ -73,10 +84,14 @@ const EMPTY_STATE: CachedState = { bridgeUrl: null, syncedAt: null, entries: [], + install: null, lastError: null, }; 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; @@ -84,7 +99,9 @@ export class ArmoryCache { private queue: Promise = Promise.resolve(); constructor(options?: { homeDirectory?: string; fetch?: typeof fetch }) { - this.root = join(resolve(options?.homeDirectory ?? homedir()), CACHE_RELATIVE_PATH); + 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; @@ -130,6 +147,22 @@ export class ArmoryCache { }); } + /** + * 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); + }); + } + private async pull(request: ArmorySyncRequest, previous: CachedState): Promise { const base = this.baseUrl(request.bridgeUrl); const manifest = await this.fetchManifest(base); @@ -145,6 +178,7 @@ export class ArmoryCache { bridgeUrl: request.bridgeUrl, syncedAt: new Date().toISOString(), entries: manifest.entries, + install: previous.install, lastError: null, }; @@ -298,6 +332,7 @@ function reported(state: CachedState): ArmorySyncState { bridgeUrl: state.bridgeUrl, syncedAt: state.syncedAt, fileCount: state.entries.length, + install: state.install, lastError: state.lastError, }; } 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..54250cb --- /dev/null +++ b/packages/fleet-ship/src/armory/armory-installer.ts @@ -0,0 +1,378 @@ +/** + * 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/// + * + * 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 deliberately untouched here; they install by a different rule. + */ + +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 } from "./armory-cache"; + +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 }[]; + 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: [], + 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); + + if (failures.length > 0) throw new AggregateError(failures, "Failed to install the armory"); + return report; +} + +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..39c20b1 --- /dev/null +++ b/packages/fleet-ship/src/armory/armory-sync.ts @@ -0,0 +1,63 @@ +/** + * 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, + 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/index.ts b/packages/fleet-ship/src/index.ts index 768b577..e3febac 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,23 @@ 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}.`); + for (const path of report?.conflicts ?? []) { + 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") 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 index 1962d1b..15c4120 100644 --- a/packages/fleet-ship/tests/armory-cache.test.ts +++ b/packages/fleet-ship/tests/armory-cache.test.ts @@ -236,6 +236,7 @@ describe("ArmoryCache", () => { bridgeUrl: null, syncedAt: null, fileCount: 0, + install: null, lastError: null, }); }); 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..23de895 --- /dev/null +++ b/packages/fleet-ship/tests/armory-installer.test.ts @@ -0,0 +1,221 @@ +/** + * 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, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +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; + }; + + 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: [], + 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("ignores dotfiles, which install by a different rule", async () => { + const { homeDirectory, cacheDirectory } = await fixture(); + await providers(homeDirectory, ".claude"); + await cached(cacheDirectory, "dotfiles/gitconfig", "[user]\n"); + await cached(cacheDirectory, "dotfile-map.json", '{"gitconfig":"~/.gitconfig"}\n'); + + const report = await installArmory({ homeDirectory }); + + expect(report).toEqual({ + skills: [], + plugins: [], + removed: [], + conflicts: [], + warnings: [], + }); + expect(await exists(join(homeDirectory, ".gitconfig"))).toBe(false); + }); +}); 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..3555f60 --- /dev/null +++ b/packages/fleet-ship/tests/armory-sync.test.ts @@ -0,0 +1,73 @@ +/** + * 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: [], + 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, + 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/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); + }); +}); From 025392733d0b0d903c7a915a965c65d5a573eb71 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Sun, 26 Jul 2026 21:38:25 -0500 Subject: [PATCH 4/8] Symlink the armory's dotfiles into place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mapping in dotfile-map.json becomes a symlink from its destination to the cached source, so a content edit reaches the user through the link on the next pull 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 is relaxed, and only for links this code can prove it created: every decision is made on lstat/readlink of the target itself, and a link is replaced or removed only while it still points inside the cache. A real file, a real directory, or somebody else's symlink is left exactly as found and reported as a conflict. Parent directories are never removed — ~/.config existing is no evidence we created it. Destinations resolve against the ship's own home and must land inside it. The map arrives over the network, and no bridge should be able to make a fleet symlink into /etc. Co-Authored-By: Claude Opus 5 (1M context) --- packages/fleet-protocol/src/armory.ts | 2 + .../fleet-ship/src/armory/armory-cache.ts | 36 ++- .../fleet-ship/src/armory/armory-installer.ts | 38 ++- packages/fleet-ship/src/armory/armory-sync.ts | 2 + .../fleet-ship/src/armory/dotfile-linker.ts | 294 ++++++++++++++++++ packages/fleet-ship/src/index.ts | 14 + .../fleet-ship/tests/armory-installer.test.ts | 55 +++- packages/fleet-ship/tests/armory-sync.test.ts | 6 + .../fleet-ship/tests/dotfile-linker.test.ts | 268 ++++++++++++++++ 9 files changed, 703 insertions(+), 12 deletions(-) create mode 100644 packages/fleet-ship/src/armory/dotfile-linker.ts create mode 100644 packages/fleet-ship/tests/dotfile-linker.test.ts diff --git a/packages/fleet-protocol/src/armory.ts b/packages/fleet-protocol/src/armory.ts index 10ab9ce..5f591e6 100644 --- a/packages/fleet-protocol/src/armory.ts +++ b/packages/fleet-protocol/src/armory.ts @@ -166,6 +166,8 @@ export type ArmorySyncRequest = z.infer; 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. */ diff --git a/packages/fleet-ship/src/armory/armory-cache.ts b/packages/fleet-ship/src/armory/armory-cache.ts index 2f7c946..2f0d66e 100644 --- a/packages/fleet-ship/src/armory/armory-cache.ts +++ b/packages/fleet-ship/src/armory/armory-cache.ts @@ -35,11 +35,13 @@ import { 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. */ @@ -50,6 +52,16 @@ 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( @@ -72,6 +84,8 @@ const CachedStateSchema = z.object({ 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(), @@ -84,10 +98,21 @@ const EMPTY_STATE: CachedState = { bridgeUrl: null, syncedAt: null, entries: [], + dotfileMap: {}, install: null, lastError: null, }; +/** 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. */ @@ -178,10 +203,13 @@ export class ArmoryCache { 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; } @@ -309,13 +337,7 @@ export class ArmoryCache { } private async readState(): Promise { - try { - const parsed = CachedStateSchema.safeParse(await Bun.file(this.statePath).json()); - return parsed.success ? parsed.data : EMPTY_STATE; - } catch { - // Missing or unreadable is simply a cold cache; the next sync rebuilds it. - return EMPTY_STATE; - } + return readCachedState(this.root); } /** Written last and atomically: a crash mid-sync leaves the old state, so the next sync redoes the work. */ diff --git a/packages/fleet-ship/src/armory/armory-installer.ts b/packages/fleet-ship/src/armory/armory-installer.ts index 54250cb..6dfd82d 100644 --- a/packages/fleet-ship/src/armory/armory-installer.ts +++ b/packages/fleet-ship/src/armory/armory-installer.ts @@ -6,6 +6,7 @@ * * 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 @@ -20,7 +21,9 @@ * since edited. `/installed.json` records what this installer wrote, so * the next run can tell "removed from the armory" from "never installed". * - * Dotfiles are deliberately untouched here; they install by a different rule. + * 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"; @@ -41,7 +44,8 @@ import { skillRootsFor, type Provider, } from "../providers"; -import { armoryCacheDirectory } from "./armory-cache"; +import { armoryCacheDirectory, cachedDotfileMap } from "./armory-cache"; +import { linkDotfiles, type DotfileLink } from "./dotfile-linker"; export type ArmoryInstallOptions = { homeDirectory?: string; @@ -52,6 +56,7 @@ export type ArmoryInstallOptions = { 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[]; @@ -91,6 +96,7 @@ export async function installArmory( const report: ArmoryInstallReport = { skills: [], plugins: [], + dotfiles: [], removed: [], conflicts: [], warnings: [], @@ -183,10 +189,38 @@ export async function installArmory( } 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[], diff --git a/packages/fleet-ship/src/armory/armory-sync.ts b/packages/fleet-ship/src/armory/armory-sync.ts index 39c20b1..4b866b2 100644 --- a/packages/fleet-ship/src/armory/armory-sync.ts +++ b/packages/fleet-ship/src/armory/armory-sync.ts @@ -46,6 +46,8 @@ 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, 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 e3febac..d9c261e 100755 --- a/packages/fleet-ship/src/index.ts +++ b/packages/fleet-ship/src/index.ts @@ -52,7 +52,21 @@ export async function installStartupIntegrations(options: { // 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 it aside, or re-sync the armory with --force to replace it with the armory's link.", + ); + } 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.", diff --git a/packages/fleet-ship/tests/armory-installer.test.ts b/packages/fleet-ship/tests/armory-installer.test.ts index 23de895..e5fb742 100644 --- a/packages/fleet-ship/tests/armory-installer.test.ts +++ b/packages/fleet-ship/tests/armory-installer.test.ts @@ -6,9 +6,10 @@ */ import { afterEach, describe, expect, test } from "bun:test"; -import { chmod, lstat, mkdir, mkdtemp, rm, stat } from "node:fs/promises"; +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", () => { @@ -39,6 +40,23 @@ describe("installArmory", () => { 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 }); }; @@ -60,6 +78,7 @@ describe("installArmory", () => { expect(await installArmory({ homeDirectory })).toEqual({ skills: [], plugins: [], + dotfiles: [], removed: [], conflicts: [], warnings: [], @@ -201,21 +220,51 @@ describe("installArmory", () => { expect(await exists(join(homeDirectory, ".claude", "config.json"))).toBe(false); }); - test("ignores dotfiles, which install by a different rule", async () => { + 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"); - await cached(cacheDirectory, "dotfile-map.json", '{"gitconfig":"~/.gitconfig"}\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 index 3555f60..327db1d 100644 --- a/packages/fleet-ship/tests/armory-sync.test.ts +++ b/packages/fleet-ship/tests/armory-sync.test.ts @@ -37,6 +37,10 @@ describe("syncAndInstall", () => { 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"], @@ -48,6 +52,8 @@ describe("syncAndInstall", () => { 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"], 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]); + }); +}); From d22130829f50c2b18e845c17ba8da98654341534 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Sun, 26 Jul 2026 22:03:27 -0500 Subject: [PATCH 5/8] Browse the armory from the web client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Armory page under Ships lists every armory file grouped by section, shows one read-only at a time, and renders the dotfile map — the piece an operator most often needs to check against reality. Binary files report their size and hash rather than being rendered as mojibake. Nothing on the page can modify armory content; these files are edited in the bridge's data directory or synced there from git. A new `GET /armory/ships` aggregates what each ship has actually applied, so the page can distinguish in sync from behind from never synced, and surface a failed sync's error and any install conflicts inline. One unreachable ship degrades to an unknown state rather than blanking the page. Also fixes two type errors this step exposed. `fleet-client` declared no typecheck script, so the root command silently skipped the whole package — and with it the two armory modules the client compiles under the DOM lib, where `ReadableStream` has no async iterator. Both hash loops now drive the reader directly, and the package declares the script, so eleven packages typecheck where ten did. Co-Authored-By: Claude Opus 5 (1M context) --- packages/fleet-bridge/src/api/armory.ts | 10 +- .../fleet-bridge/src/armory/armory-service.ts | 11 +- packages/fleet-bridge/src/fleet-manager.ts | 28 ++ packages/fleet-bridge/src/types.ts | 14 +- .../fleet-bridge/tests/armory-ships.test.ts | 127 +++++++ packages/fleet-bridge/tests/helpers.ts | 16 +- packages/fleet-client/package.json | 3 +- packages/fleet-client/src/App.tsx | 2 + .../fleet-client/src/data/FleetContext.tsx | 25 +- packages/fleet-client/src/data/eden.ts | 33 +- packages/fleet-client/src/data/mock.ts | 245 +++++++++++- packages/fleet-client/src/data/provider.ts | 17 +- packages/fleet-client/src/data/types.ts | 20 +- packages/fleet-client/src/layouts/Sidebar.tsx | 10 + packages/fleet-client/src/layouts/TopBar.tsx | 3 +- packages/fleet-client/src/lib/armory.ts | 55 +++ .../fleet-client/src/routes/ArmoryRoute.tsx | 356 ++++++++++++++++++ .../fleet-client/tests/armory-data.test.ts | 233 ++++++++++++ .../fleet-ship/src/armory/armory-cache.ts | 11 +- 19 files changed, 1204 insertions(+), 15 deletions(-) create mode 100644 packages/fleet-bridge/tests/armory-ships.test.ts create mode 100644 packages/fleet-client/src/lib/armory.ts create mode 100644 packages/fleet-client/src/routes/ArmoryRoute.tsx create mode 100644 packages/fleet-client/tests/armory-data.test.ts diff --git a/packages/fleet-bridge/src/api/armory.ts b/packages/fleet-bridge/src/api/armory.ts index 1ca60d1..9e356f5 100644 --- a/packages/fleet-bridge/src/api/armory.ts +++ b/packages/fleet-bridge/src/api/armory.ts @@ -1,8 +1,9 @@ /** * api/armory.ts — the read side of the Armory: the manifest of the bridge's - * `armory/` directory and the contents of any file it lists. Ships poll these to - * decide whether to re-pull. One Elysia chain so route types stay inferable for - * Eden. + * `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"; @@ -32,5 +33,6 @@ export function armoryPlugin(manager: FleetManager) { } }, { query: t.Object({ path: t.String() }) }, - ); + ) + .get("/armory/ships", () => manager.armoryShipStates()); } diff --git a/packages/fleet-bridge/src/armory/armory-service.ts b/packages/fleet-bridge/src/armory/armory-service.ts index f30decf..4c9402e 100644 --- a/packages/fleet-bridge/src/armory/armory-service.ts +++ b/packages/fleet-bridge/src/armory/armory-service.ts @@ -252,7 +252,16 @@ function revisionOf(entries: ArmoryEntry[], dotfileMap: DotfileMap): string { async function hashFile(target: string): Promise { const hasher = new Bun.CryptoHasher("sha256"); - for await (const chunk of Bun.file(target).stream()) hasher.update(chunk); + // 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"); } diff --git a/packages/fleet-bridge/src/fleet-manager.ts b/packages/fleet-bridge/src/fleet-manager.ts index e8993ec..cd3aba8 100644 --- a/packages/fleet-bridge/src/fleet-manager.ts +++ b/packages/fleet-bridge/src/fleet-manager.ts @@ -23,6 +23,7 @@ import { WorkspaceStatusSchema, type ArmoryFile, type ArmoryManifest, + type ArmorySyncState, type CreateRepoInput, type FleetEvent, type Repo, @@ -40,6 +41,7 @@ import { type BridgeWorkspaceEvent, type BridgeWorkspaceStatus, type BridgeWorkspaceSummary, + type ShipArmoryState, type ShipInfo, type ShipSystemResources, } from "./types"; @@ -441,6 +443,32 @@ export class FleetManager { 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(); 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-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/helpers.ts b/packages/fleet-bridge/tests/helpers.ts index e1ab5d0..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. */ @@ -25,6 +25,8 @@ export interface FakeShip { 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). */ @@ -193,6 +195,18 @@ export function makeFakeClient(httpUrl: string, ships: Map) { 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(); 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-ship/src/armory/armory-cache.ts b/packages/fleet-ship/src/armory/armory-cache.ts index 2f0d66e..7e88196 100644 --- a/packages/fleet-ship/src/armory/armory-cache.ts +++ b/packages/fleet-ship/src/armory/armory-cache.ts @@ -419,7 +419,16 @@ async function atomicWrite(target: string, bytes: Uint8Array, mode: number): Pro async function hashFile(target: string): Promise { const hasher = new Bun.CryptoHasher("sha256"); - for await (const chunk of Bun.file(target).stream()) hasher.update(chunk); + // 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"); } From ac207b7c81750ff1910b77663efdfeb840bd5522 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 27 Jul 2026 10:09:55 -0500 Subject: [PATCH 6/8] Add the armory CLI, docs, and a publicUrl for launched fleets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fleet client armory ls/cat/ships` reads the armory from the bridge. `ships` is the one that matters operationally: it says which ships have applied the current revision and prints each ship's sync error, install conflicts, and warnings, since finding those is why anyone runs it. `cat` refuses to write a binary to stdout — a redirect producing a mangled file is worse than an error. `fleet launch` could not set the bridge's publicUrl, so a launched fleet always told ships http://localhost:. That is right for one host and wrong the moment a remote ship registers, where localhost is the ship itself. The section now takes publicUrl, and a config with remote ships and no publicUrl warns rather than failing silently later. Fixes a dotfile conflict warning that sent users after a --force flag which does not exist; the remedy is to move or delete the file in the way. The guide and the warning now give that same remedy in the same words. Timestamps in CLI tables go through a helper that accepts a Date: Eden Treaty revives ISO strings in a response body, so `syncedAt` does not arrive as the string its type promises. Both the suite and the typechecker were green while `armory ships` crashed on it. Closes #8. Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/src/format.ts | 79 ++++- apps/cli/src/index.ts | 104 ++++++- apps/cli/src/launch-command.ts | 7 +- apps/cli/src/launch-config.ts | 34 +++ apps/cli/tests/format.test.ts | 124 ++++++++ apps/cli/tests/launch-config.test.ts | 52 +++- .../content/docs/guides/agent-integrations.md | 13 + .../src/content/docs/guides/multi-host.md | 43 ++- .../src/content/docs/guides/the-armory.md | 281 ++++++++++++++++++ apps/docs/src/content/docs/guides/web-gui.md | 12 + .../src/content/docs/reference/bridge-api.md | 115 ++++++- apps/docs/src/content/docs/reference/cli.md | 97 +++++- .../content/docs/reference/fleet-config.md | 34 ++- .../src/content/docs/reference/ship-api.md | 70 ++++- packages/fleet-ship/src/index.ts | 2 +- 15 files changed, 1052 insertions(+), 15 deletions(-) create mode 100644 apps/docs/src/content/docs/guides/the-armory.md 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..12d95e6 100644 --- a/apps/cli/src/launch-command.ts +++ b/apps/cli/src/launch-command.ts @@ -11,13 +11,18 @@ 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)); 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..dce1a41 --- /dev/null +++ b/apps/docs/src/content/docs/guides/the-armory.md @@ -0,0 +1,281 @@ +--- +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. + +## 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 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..9813482 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 @@ -344,7 +436,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..577538d 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,31 @@ 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`. + ## `gui` Both fields are optional, so `gui: {}` is valid — as long as a bridge exists to @@ -187,6 +214,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 +228,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..c632132 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,68 @@ 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 — and it only ever pulls from a +bridge that has spoken to it. + +```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: `. | +| `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-ship/src/index.ts b/packages/fleet-ship/src/index.ts index d9c261e..50cf628 100755 --- a/packages/fleet-ship/src/index.ts +++ b/packages/fleet-ship/src/index.ts @@ -62,7 +62,7 @@ export async function installStartupIntegrations(options: { for (const path of dotfileConflicts) { console.warn( `Fleet startup preserved a conflicting dotfile: ${path}. ` + - "Move it aside, or re-sync the armory with --force to replace it with the armory's link.", + "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 ?? []) { From ed50b0ebe910d1323d1f90bb15c73379fc4dea4c Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 27 Jul 2026 12:26:23 -0500 Subject: [PATCH 7/8] Refuse a symlinked armory section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scanner skipped symlinks it found while listing a directory, but walked the three section directories by path, and readdir follows a symlink handed to it. So `armory/skills -> ../secrets` was listed and served in full through GET /armory/file, and pushed to every ship — exactly what the module header promised could not happen. readFile had the same gap one level deeper. It lstat'd only the final component, and the manifest it checks membership against is cached, so a directory that was real at scan time could be a symlink by the time the read arrived. Both ends are now resolved with realpath before any bytes are read. The armory root itself may still be a symlink. Pointing it at a git checkout is a documented workflow and is the operator's own choice about their own data directory; what is refused is a symlink at or below the section level, which is content other people may be able to open pull requests against. Reported in review on #26. Co-Authored-By: Claude Opus 5 (1M context) --- .../fleet-bridge/src/armory/armory-service.ts | 65 +++++++++++-- packages/fleet-bridge/tests/armory.test.ts | 92 +++++++++++++++++++ 2 files changed, 148 insertions(+), 9 deletions(-) diff --git a/packages/fleet-bridge/src/armory/armory-service.ts b/packages/fleet-bridge/src/armory/armory-service.ts index 4c9402e..b4c8e32 100644 --- a/packages/fleet-bridge/src/armory/armory-service.ts +++ b/packages/fleet-bridge/src/armory/armory-service.ts @@ -3,19 +3,24 @@ * `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 are skipped outright (never - * followed, never listed) because a symlink in the armory would let a manifest + * 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. + * 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. * - * The manifest is the single source of truth: `readFile` only serves paths the - * manifest lists, which is what confines reads to the three section directories. * 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 } from "node:fs/promises"; +import { lstat, readdir, realpath } from "node:fs/promises"; import { join, relative, resolve, sep } from "node:path"; import { ARMORY_SECTIONS, @@ -125,13 +130,27 @@ export class ArmoryService { const target = resolve(this.root, path); if (!isStrictDescendant(this.root, target)) throw new ArmoryPathError(path); - const info = await lstat(target).catch((error: NodeJS.ErrnoException) => { + // 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; @@ -142,10 +161,29 @@ export class ArmoryService { // --- 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) { - await this.walk(join(this.root, section), section, section, entries); + 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(); @@ -265,6 +303,15 @@ async function hashFile(target: string): Promise { 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); diff --git a/packages/fleet-bridge/tests/armory.test.ts b/packages/fleet-bridge/tests/armory.test.ts index f6faf12..a0b1abd 100644 --- a/packages/fleet-bridge/tests/armory.test.ts +++ b/packages/fleet-bridge/tests/armory.test.ts @@ -170,6 +170,98 @@ describe("ArmoryService", () => { 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 ✅"); From 2a3ad4f391373ead6b505dfa2a75ff64daf21a5b Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 27 Jul 2026 12:38:59 -0500 Subject: [PATCH 8/8] Pin the bridge a ship will accept armory pushes from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /armory/sync` took the URL to pull from out of the request body and checked only its scheme, so anyone who could reach a ship's port could choose where its skills, plugins, and dotfiles came from. A ship now accepts a push only from the bridge given as `--bridge-url`, or, unset, from whichever bridge pushed first. A mismatch is refused 403 before any fetch happens. Origins compare normalized, so a trailing slash or a differently-cased host is the same bridge rather than a baffling refusal. A refused push writes nothing, not even `lastError`: it is not the ship's failure, and recording it would make an in-sync ship read as `error` in `fleet client armory ships` because someone poked its port. This is defence in depth, not authentication. It stops an attacker choosing the source; it does not stop one who can reach the port from triggering a re-pull from the real bridge, which is harmless. The ship API remains unauthenticated — `WS /workspaces/:repo/:name/terminal` is still command execution — and the armory guide now says so where an operator will read it. Reported in review on #26. Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/src/launch-command.ts | 34 +++- .../src/content/docs/guides/the-armory.md | 42 +++++ apps/docs/src/content/docs/reference/cli.md | 12 +- .../content/docs/reference/fleet-config.md | 19 ++- .../src/content/docs/reference/ship-api.md | 7 +- packages/fleet-protocol/src/config.ts | 10 +- packages/fleet-ship/src/api/armory.ts | 5 +- packages/fleet-ship/src/api/index.ts | 6 +- .../fleet-ship/src/armory/armory-cache.ts | 72 +++++++- packages/fleet-ship/src/index.ts | 7 +- .../fleet-ship/tests/armory-cache.test.ts | 154 +++++++++++++++++- 11 files changed, 349 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/launch-command.ts b/apps/cli/src/launch-command.ts index 12d95e6..247611f 100644 --- a/apps/cli/src/launch-command.ts +++ b/apps/cli/src/launch-command.ts @@ -28,9 +28,32 @@ async function runLaunch(configPath: string): Promise { ({ 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; @@ -53,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/docs/src/content/docs/guides/the-armory.md b/apps/docs/src/content/docs/guides/the-armory.md index dce1a41..71ec477 100644 --- a/apps/docs/src/content/docs/guides/the-armory.md +++ b/apps/docs/src/content/docs/guides/the-armory.md @@ -72,6 +72,32 @@ 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 @@ -264,6 +290,22 @@ 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 diff --git a/apps/docs/src/content/docs/reference/cli.md b/apps/docs/src/content/docs/reference/cli.md index 9813482..0a6b037 100644 --- a/apps/docs/src/content/docs/reference/cli.md +++ b/apps/docs/src/content/docs/reference/cli.md @@ -370,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:`. diff --git a/apps/docs/src/content/docs/reference/fleet-config.md b/apps/docs/src/content/docs/reference/fleet-config.md index 577538d..d4ad567 100644 --- a/apps/docs/src/content/docs/reference/fleet-config.md +++ b/apps/docs/src/content/docs/reference/fleet-config.md @@ -102,6 +102,18 @@ 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 @@ -178,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. diff --git a/apps/docs/src/content/docs/reference/ship-api.md b/apps/docs/src/content/docs/reference/ship-api.md index c632132..b6a5ccf 100644 --- a/apps/docs/src/content/docs/reference/ship-api.md +++ b/apps/docs/src/content/docs/reference/ship-api.md @@ -272,8 +272,10 @@ This route has no error mapping — it always returns `200` on a healthy host. 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 — and it only ever pulls from a -bridge that has spoken to it. +`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 @@ -296,6 +298,7 @@ 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. | 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 index 173e5f3..e18a7c4 100644 --- a/packages/fleet-ship/src/api/armory.ts +++ b/packages/fleet-ship/src/api/armory.ts @@ -36,7 +36,10 @@ export function armoryPlugin(cache: ArmoryCache) { }); } -/** A failed pull is the bridge's fault (502) or the push body's (400), never a plain 500. */ +/** + * 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 d801b8a..17a5c2a 100644 --- a/packages/fleet-ship/src/api/index.ts +++ b/packages/fleet-ship/src/api/index.ts @@ -19,7 +19,7 @@ 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, @@ -29,7 +29,9 @@ export function createApp( .use(workspacesPlugin(manager, createTerminal, terminalInitTimeoutMs)) .use(eventsPlugin(manager)) .use(systemResourcesPlugin()) - .use(armoryPlugin(armory ?? new ArmoryCache())) + // 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 index 7e88196..e6ac397 100644 --- a/packages/fleet-ship/src/armory/armory-cache.ts +++ b/packages/fleet-ship/src/armory/armory-cache.ts @@ -23,6 +23,9 @@ * `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"; @@ -66,7 +69,10 @@ export async function cachedDotfileMap(cacheDirectory: string): Promise { try { @@ -121,15 +147,18 @@ export class ArmoryCache { 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 }) { + 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 { @@ -158,6 +187,9 @@ export class ArmoryCache { 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); @@ -188,6 +220,42 @@ export class ArmoryCache { }); } + /** + * 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); diff --git a/packages/fleet-ship/src/index.ts b/packages/fleet-ship/src/index.ts index 50cf628..c64054d 100755 --- a/packages/fleet-ship/src/index.ts +++ b/packages/fleet-ship/src/index.ts @@ -146,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/tests/armory-cache.test.ts b/packages/fleet-ship/tests/armory-cache.test.ts index 15c4120..cdaae13 100644 --- a/packages/fleet-ship/tests/armory-cache.test.ts +++ b/packages/fleet-ship/tests/armory-cache.test.ts @@ -11,7 +11,7 @@ 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 } from "../src/armory/armory-cache"; +import { ArmoryCache, ArmorySyncError, normalizeBridgeUrl } from "../src/armory/armory-cache"; import { createApp } from "../src/api"; import { stubConfig, stubManager } from "./helpers"; @@ -296,15 +296,19 @@ describe("ArmoryCache", () => { 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 }); - - 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); }); test("a cached file that was corrupted on disk is re-downloaded", async () => { @@ -322,3 +326,143 @@ describe("ArmoryCache", () => { 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(); + }); +});