Skip to content

8 file factory from bridge ships - #26

Merged
FireSquid6 merged 8 commits into
mainfrom
8-file-factory-from-bridge---ships
Jul 27, 2026
Merged

8 file factory from bridge ships#26
FireSquid6 merged 8 commits into
mainfrom
8-file-factory-from-bridge---ships

Conversation

@FireSquid6

Copy link
Copy Markdown
Owner

No description provided.

FireSquid6 and others added 2 commits July 26, 2026 17:08
The armory is a bridge-owned directory of skills, plugins, and dotfiles
that ships install. This adds its read side: `ArmoryService` scans
`<dataDirectory>/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) <noreply@anthropic.com>
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
`<home>/.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) <noreply@anthropic.com>
@FireSquid6 FireSquid6 linked an issue Jul 26, 2026 that may be closed by this pull request
FireSquid6 and others added 4 commits July 26, 2026 21:20
Skills are modeled and plugins are not, which is the asymmetry the feature
asks for. A skill is a directory, so `skills/<name>/**` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`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:<port>. 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) <noreply@anthropic.com>
@FireSquid6

Copy link
Copy Markdown
Owner Author

@claude do a code review of these changes

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown

Claude finished @FireSquid6's task in 1m 39s —— View job


Code Review

  • Gather context and understand the request
  • Review fleet-protocol changes (armory types)
  • Review fleet-bridge changes (armory-service, armory-watcher, api)
  • Review fleet-ship changes (armory-cache, armory-installer, armory-sync, dotfile-linker)
  • Review fleet-client changes (ArmoryRoute, data layer)
  • Review CLI changes
  • Post final review

Overall this is a well-engineered feature — content-addressed manifests, path-safety validators, symlink confinement, atomic writes, and meaningful test coverage (including several path-traversal / symlink-escape tests) throughout. No violations of the repo's DB/service-layer or comment rules were found. A few real issues below, ordered by severity.

High

  1. Bridge scanner follows a symlinked section directorypackages/fleet-bridge/src/armory/armory-service.ts:145-149,155-168
    scan() calls walk(join(this.root, section), ...) directly on each top-level section dir (armory/skills, armory/plugins, armory/dotfiles), and walk only checks item.isSymbolicLink() for entries discovered while listing a parent directory (line 175). It never checks whether the section directory itself is a symlink, and readdir follows a symlink given directly as its path. Since the module's own doc comment states "a symlink in the armory would let a manifest consumer pull a file from anywhere on the bridge host" — this is exactly that gap, just one level up. If armory/skills is ever replaced with a symlink (compromised git remote, bad deploy script, bug), the scanner will hash and serve everything under the symlink target via GET /armory/file, and it'll get pushed to every ship in the fleet. The existing symlink test (armory.test.ts:160-171) only covers a symlink nested inside a section, not the section directory itself.
    Fix this →

  2. POST /armory/sync on the ship has no authenticationpackages/fleet-ship/src/api/armory.ts:15-27
    The route accepts an attacker-controlled bridgeUrl (only scheme is validated in armory-cache.ts) and, per packages/fleet-ship/src/index.ts, the server listens with no explicit hostname restriction. This predates the PR (no route in fleet-ship/src/api has auth), but this PR is what turns "no auth" into real impact: anyone who can reach the ship's port can direct it to pull a "manifest" from an attacker-run server and have it write skills/plugins and symlink dotfiles into ~/.claude, ~/.codex, ~/.copilot, ~/.config/opencode, etc. — unauthenticated remote config/code injection. Worth flagging even if fixing ship-wide auth is out of scope for this PR specifically.

Medium

  1. Weaker path validation for dotfile destinations than sourcespackages/fleet-protocol/src/armory.ts:61-65 (isSafeDotfileDestination)
    Unlike isSafeArmoryPath (used for manifest sources), this validator only checks control characters and a ~/// prefix — it never rejects .. segments. A destination like ~/../../etc/cron.d/x passes validation even though the surrounding docs assert the check exists because "a manifest is untrusted input on the ship side." Since dotfileMap travels inside the manifest to ships, this weakens the safety guarantee the field is supposed to provide (worth double-checking whatever ship-side code actually creates the symlinks re-validates more strictly).

  2. No cross-process locking on installed.json / dotfiles.json bookkeepingpackages/fleet-ship/src/armory/armory-installer.ts:123,190 and dotfile-linker.ts:256-289
    These are plain read-then-write with no lock, unlike managed-fs.ts's own manifest, which uses a proper lock. If a bridge-triggered sync races the ship's own startup installArmory() call shortly after boot, whichever write lands last silently clobbers the other's bookkeeping — the actual files/symlinks stay correct (managed-fs still tracks them), but they become un-uninstallable "ghosts" once dropped from the bridge's armory.

  3. ArmoryRoute.tsx boot effect fails all-or-nothingpackages/fleet-client/src/routes/ArmoryRoute.tsx:55
    Promise.all([getArmory(), listArmoryShips()]) means a failure in the (logically independent) ships table takes down the file browser too. Promise.allSettled, or fetching each independently as the file-content effect already does, would let one section degrade without the other.

Low

  1. packages/fleet-bridge/src/config.ts:28publicUrl is z.string().min(1) rather than a URL schema, while it's later validated as z.url() on the ship side; a malformed --public-url fails silently per-ship instead of at bridge startup.
  2. packages/fleet-bridge/src/fleet-manager.ts (mapArmoryErrors) — unexpected fs errors (e.g. EACCES) fall through to a generic handler that echoes error.message, potentially leaking absolute host paths from the (unauthenticated) /armory routes.
  3. packages/fleet-bridge/src/armory/armory-watcher.ts:36fs.watch(..., { recursive: true }) has historically inconsistent support on Linux across Node/Bun versions; deep changes in the armory tree may silently not trigger a push depending on runtime.
  4. Duplicated isStrictDescendant-style helpers across armory-cache.ts, armory-installer.ts, and dotfile-linker.ts with subtly different bodies (only one rejects a Windows drive-letter prefix) — worth consolidating into one shared helper.
  5. packages/fleet-client/src/data/eden.ts:217-237 — the in-band { error } 200-body guard for getArmory/getArmoryFile/listArmoryShips is dead code; none of the corresponding bridge routes ever return errors that way (they map to non-2xx statuses instead), and the comment asserting otherwise is inaccurate.
  6. packages/fleet-client/src/data/mock.tsfakeSha256(seed.path) hashes only the path, not content, so mock hashes don't reflect content changes (latent gap in mock fidelity, no test currently depends on it).

Nice to have

  • packages/fleet-bridge/src/index.tsstartBridge returns { manager, watcher } but nothing ties the watcher's lifetime to manager.shutdown(); easy to leak the fs.watch handle if a caller only calls shutdown().
  • packages/fleet-ship/src/armory/dotfile-linker.ts placeLink/unlinkOwned re-check lstat once but don't re-verify immediately before the destructive rename/unlink, unlike managed-fs.ts's atomic operations which re-stat right before mutating — a narrow TOCTOU window if something else creates a file at the target between check and mutation.

skill-installer.ts's -44/+15 diff was verified to be a clean, behavior-preserving extraction into providers.ts — no dangling references. CLI, docs, and the rest of the data layer looked solid with no notable issues.

FireSquid6 and others added 2 commits July 27, 2026 12:26
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
@FireSquid6
FireSquid6 merged commit 2dd27d8 into main Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

File factory from bridge -> ships

1 participant