8 file factory from bridge ships - #26
Conversation
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>
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>
|
@claude do a code review of these changes |
|
Claude finished @FireSquid6's task in 1m 39s —— View job Code 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
Medium
Low
Nice to have
|
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>
No description provided.