6343c03bfa16daeb6bd1dc947df2dfa492fdbe94
19
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6343c03bfa |
Merge remote-tracking branch 'origin/fix/1240-fleet-transport-check' into chain/e2e-compose
# Conflicts: # tools/install.sh |
||
|
|
264ac5d25a | Merge remote-tracking branch 'origin/fix/1241-launch-failure-visible' into chain/e2e-compose | ||
|
|
6914229062 | Merge remote-tracking branch 'origin/fix/1237-fleet-v2-dispatch' into chain/e2e-compose | ||
|
|
975475c8fb | Merge remote-tracking branch 'origin/fix/1236-installer-dir-modes' into chain/e2e-compose | ||
|
|
5c35a250de |
test(fleet): name what the pane-boundary case's binary check rides on (#1241)
ci/woodpecker/pr/ci Pipeline was successful
Review finding from scooby. This case does not use run_start, so install_pane_binaries' symlinks land under a home its launcher never consults (HOME is the trusted parent here). It resolves mosaic and pi through MOSAIC_RUNTIME_BIN=$FAKE_BIN instead. Valid path, valid green — and a trap for anyone who later drops that env var believing the symlinks cover it, which would break the #1241 binary check rather than exercise it. Comment only; no behavior change. Harness rc=0. Refs #1241. |
||
|
|
10a1f82031 |
test(fleet): cover the pane-pid-unresolved branch this PR shipped (#1241)
ci/woodpecker/pr/ci Pipeline was canceled
Review finding from scooby: this PR added a failure branch the harness structurally could not reach. The fake tmux answered `has-session` only for `=_holder:0.0`, so every non-holder agent landed in the session-is-gone branch no matter what — the `elif` (tmux still reports the session, no pane PID after the retries) had zero coverage and no way to get any. That is the same shape as the bug this PR exists to fix, one layer down: a code path shipped green where the gate that should measure it cannot. Less severe, because the branch fails closed at exit 69 rather than reporting success — but "the harness can't reach it" is the sentence that precedes the next silent regression, so it gets closed here rather than filed. `MOSAIC_TEST_HELD_SESSIONS` lets a case name targets the shim should also answer for. It answers them only AFTER `new-session`, and that detail is the whole trick: the launcher asks `has-session` about the same name twice — once at line 255 where a yes means "already running, exit 0", and once at 417 where a yes means "the session survived". A shim answering yes to both short-circuits at the first and never reaches the branch under test. It would have looked like coverage while measuring the idempotency path. Both failure modes were measured, not reasoned about: - toggle absent (the old shim): `code=pane-did-not-survive` — the case lands on the wrong branch, which is exactly the unreachability being reported. - toggle answering unconditionally: launcher exits 0 via the idempotency short-circuit — "launcher reported success over a session with no resolvable pane PID". - toggle gated on new-session: `code=pane-pid-unresolved`, exit 69. The case also asserts the diagnostic is not `pane-did-not-survive` and does not mention the heartbeat, so the two pane faults cannot collapse into one message. Gates: bash -n · launcher harness rc=0 · test-fleet-units.sh (real tmux) rc=0 · fleet specs 342 passed. Refs #1241. |
||
|
|
b61789fe26 |
fix(fleet): tell the operator when the fleet transport is missing (#1240)
ci/woodpecker/pr/ci Pipeline was successful
`mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every roster the CLI scaffolds sets `transport: tmux`, but neither `tools/install.sh` nor `tools/_scripts/mosaic-doctor` contained the string "tmux" at all. A greenfield host therefore came out of the installer able to install a fleet, start a fleet, and run no seat, with `mosaic fleet ps` as the operator's first and only signal. Measured on mosaic-sbx-dev (Debian, no tmux, framework installed): `mosaic-doctor` reported 11 warnings and not one of them named the reason no seat could launch. The installer gets a warning, not a `require_cmd` hard failure: tmux is required by the fleet, not by mosaic. Hosts that install this to run `mosaic claude` and never scaffold a roster are common, and failing their install over a binary they do not need would be wrong. The check runs in `--check` mode too — "what is the state of this host" is the question `--check` is asked. Both checks read the roster's own `transport:` rather than assuming tmux, so a host declaring something else is pointed at the binary it actually needs instead of at the wrong package. The two implementations are deliberately parallel and each carries a comment pointing at the other. They are separate because the installer must answer this before the framework's own scripts are guaranteed to be on disk. One harness drives BOTH from the shipped text — the functions are extracted from the scripts by awk rather than copied — so the pair cannot drift silently, and the test cannot keep passing after the shipped copy changes. The harness is wired into `test:framework-shell`. Without that it would have tripped the #1017 enumeration guard as UNENUMERATED, which is the guard doing its job: a check nothing runs is not a check. Evidence: - red: the harness fails against origin/next ("could not extract fleet_declared_transport"); `grep -ci tmux` on both files at origin/next = 0. - green on real hosts, all four branches: - dev (no tmux, no roster) -> WARN naming tmux, points at `mosaic fleet init` - dev (no tmux, v2 roster) -> WARN naming the roster, points at `mosaic fleet start` - dev installer --check -> WARN saying start "reports success and no seat comes up" - canary (tmux present) -> `[OK] Fleet transport available: tmux` under --verbose, silent by default (pass() is verbose-gated), installer silent - harness green on node:24-alpine/busybox, the CI base image. - `bash -n` x3, `pnpm typecheck` 45/45, fleet specs 342 passed, enumeration guard OK, its self-test OK, prettier clean. Refs #1240. Upstream of #1237/#1243 and #1241/#1244: a correct fix for either of those still leaves this host with no live seat. |
||
|
|
61a907a12f |
fix(fleet): fail the agent launcher when the pane cannot survive (#1241)
ci/woodpecker/pr/ci Pipeline was successful
`mosaic fleet start` returned 0 over three dead panes. The launcher knew, and said the wrong thing at the wrong severity to the wrong layer. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a cleared environment. When that binary is absent the pane dies in under a second, tmux destroys the session, and the diagnostic goes with it. The launcher then found no PANE_PID, printed a WARNING about the *heartbeat sidecar*, and exited 0 — so systemd logged "Finished ... successfully" and `fleet start` reported success. `fleet ps` was the only component telling the truth. Two changes, both in start-agent-session.sh: 1. Before any effect, resolve `mosaic` and the roster's runtime against PANE_PATH — the pane's own view of the path, not the launcher's. `mosaic yolo <runtime>` calls checkRuntime(runtime) and looks for a binary named exactly like the runtime, so this asks the same question the pane will ask a moment later, while an operator can still see the answer. Absent binary -> exit 69, code=missing-binary, no session created. 2. Replace the dead-pane WARNING+exit-0. An absent session one second after new-session is a runtime that died on startup, not a heartbeat problem -> exit 69, code=pane-did-not-survive, with the command to run by hand to see why. A present session with no pane PID after five attempts -> code=pane-pid-unresolved. Neither branch kills the session; destroying a possibly-live pane on a guess is worse than leaving it for inspection. Exit 69 (EX_UNAVAILABLE) is deliberate: the 64s already in this file mean the projection was bad, and here the data is fine and the host is not ready. Callers separate the cases by `code=`, the same way fail_env's codes share 64. This propagates for free. `fleet start` calls runChecked() for the holder and each agent, and runChecked throws on non-zero, so layers 4 and 5 stop lying without a TypeScript change. Two adjacent defects are left for a follow-up issue rather than widened into this diff: the per-agent loop aborts on the first failure instead of attempting all and reporting an aggregate, and runChecked's bare throw surfaces the launcher's message under a Node unhandled-rejection stack trace because program.parse() is synchronous. Tests: - test-start-agent-session.sh gains three cases: `mosaic` absent from the pane path, the runtime absent from the pane path, and a pane that does not survive. Each was verified individually red against the unmodified origin/next launcher. - The two cases asserting a valid launch now supply a pane PID. Until now the suite's one success path was itself a dead pane the launcher reported as fine. - The harness fakes `npm` so PANE_PATH stops depending on whatever the host has installed, and fails loudly if the host provides `mosaic` or `pi` in the system path, where the missing-binary cases would not be measurable at all. - test-fleet-units.sh gains a `pi` shim in its runtime bin. The real-tmux harness named `pi` in its roster and never installed it; the new preflight caught it. Refs #1241 |
||
|
|
6f5b4c3dc1 |
fix(fleet): restore ConditionPathExists dropped by my own red-check
ci/woodpecker/pr/ci Pipeline was successful
Self-inflicted and worth recording rather than quietly amending. To prove the new tests were red without the fix I ran `git checkout origin/next -- <fleet.ts> <[email protected]>`. That writes the *index*, not just the working tree. Copying my versions back afterwards restored the working tree only, so the unit file sat staged-as-origin/next and modified-in-tree, and the next commit ( |
||
|
|
67f5014cc0 |
fix(fleet): refuse v2 add/remove cleanly, and pin the Condition's effect
Two follow-ups from the canary red->green run and scooby's review.
1. The v2 refusal in `add`/`remove` was a bare `throw`, which reaches the CLI
top level uncaught and prints the guidance under a Node stack trace. The
message *is* the point of the refusal, so it now goes through
`command.error()` — the same clean path the roster-config error uses.
Caught on canary, not in review: the unit tests asserted the message text
and passed either way.
2. The unit-template test asserted only that ConditionPathExists is present.
Presence is not effect. Added two tests for the parts that can drift in
code while that assertion still passes: the condition resolving to exactly
the file the fleet writes (%h/%i rendered against a real install), and the
launcher genuinely failing on an absent generated env (exit 64,
`missing-file`) — which is what makes the condition load-bearing rather
than decorative.
systemd is not available in the suite, so the effect itself was measured on
canary (2026-08-16), roster v2 generation 3:
with the condition: start rc=0, Result=success, ConditionResult=no,
journal "skipped, unmet condition check"
condition removed by
drop-in, nothing else: start rc=1, Result=exit-code, ExecMainStatus=64,
unit failed, "agent environment rejected: missing-file"
Canary red->green for the three commands, same v2 roster, side by side:
fleet ps 0.0.50-next.2413 rc=1 -> branch rc=0 (3 agents listed)
fleet install 0.0.50-next.2413 rc=1 -> branch rc=0
fleet remove <name> 0.0.50-next.2413 rc=1 -> branch rc=1, refusal naming
delete + apply
All three previously failed with "Fleet roster has unknown field(s):
generation." The #791 negative was measured too: the six existing
*.env.generated files were untouched by `install` (mtimes 20+ minutes older
than the run).
Gates: typecheck 0, eslint 0, prettier clean, fleet specs 382 passed, new spec
10/10 with the fix and 9/10 red against origin/next (the 10th passes there for
an unrelated reason and is annotated as such). Full suite: only
mutator-gate.acceptance.spec.ts fails, pre-existing on origin/next.
Still true and still worth saying: a correct fix here shows install rc=0 and
start rc=0 and STILL no live seat. #1240 (tmux absent) is upstream, #1241
(start reports lifecycle-complete over dead panes) and the missing agent
runtime are downstream.
Refs #1237
Reviewed-by: scooby (by git comms; cannot file a Gitea review from fomo-lin)
|
||
|
|
463745e314 |
fix(#1237): let ps/install work on a roster-v2 fleet, and refuse add/remove honestly
On a roster-v2 fleet, `ps`, `install`, `install-systemd`, `add` and `remove` all failed in the v1 parser. The consequence was that a greenfield v2 box could never get its unit templates placed, so nothing downstream could start. The read-only commands get a narrow version-agnostic view of the roster (version, socket name, holder session, and per agent name/alias/runtime). This is deliberately not a v2 -> v1 downshift. A downshifted FleetRoster would be accepted by generateAgentEnvValues, which would make a third writer of fleet/agents/<name>.env.generated through the v1 mapping and break the #791 single-SSOT invariant that projectRosterV2AgentGeneratedEnv is documented to hold. The view is too small to write a roster or an env file back from, so that misuse is unavailable rather than merely discouraged. So on a v2 roster `install` places the tool files and the unit templates, enables the units, and writes no generated env at all. Env belongs to `apply` and `regen`, both already v2-native. That change alone would have traded an init-time failure for a boot-time one. `install` enables mosaic-agent@<name>.service (WantedBy=default.target) without starting it, so a reboot between `install` and the first `apply` would run ExecStart against an absent env file and fail every seat unit, further from its cause. The unit template now carries ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated which skips an enabled-but-unconfigured unit cleanly and starts it on the next start once the reconciler has written env. On v1 it is a no-op, since v1 `install` writes env itself. Found in review by scooby. `add` and `remove` are not routed to `create` and `delete`. They are different operations: the v1 pair edits the roster and drives systemd, the v2 pair is documented as changing desired state without runtime actions. `add` also collects four fields where a v2 agent requires eleven, so routing it would mean inventing an operator's provider, alias, reasoning and tool policy. On v2 both now fail with the real two-step sequence instead. Tests: 8 new, 7 of which are red before this change. Includes the greenfield case scooby asked for — `ps` on a fresh v2 install with nothing running is rc=0 and lists every agent stopped, since that is the command an operator runs to find out why there is no seat. Note for anyone verifying this: a correct fix here shows `install` rc=0 and `start` rc=0 and still no live seat. #1240 (tmux absent) is upstream, #1241 (start reports lifecycle-complete over dead panes) and the missing agent runtime are downstream. A dead pane after this change is not a regression here. Refs #1237, #791, #1240, #1241 |
||
|
|
03eda02c20 |
fix(installer): warn on a failed credentials/ chmod instead of swallowing it
ci/woodpecker/pr/ci Pipeline was successful
scooby's review flag 1 on #1242. The other three chmods warn; this one was `|| true`. It is the one directory holding secrets, so a chmod that fails silently there is the failure most worth a line in the output. Comment-and-warn only. No behaviour change on the success path. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1 |
||
|
|
3b4055017e |
fix(installer): pin umask and set the 0700 modes the fleet boundary requires (#1236)
ci/woodpecker/pr/ci Pipeline was canceled
A greenfield install cannot run `mosaic fleet init --write`. It fails with `unsafe-permissions` on an unnamed `(directory)` and an unhandled Node throw, and every mutating `mosaic fleet` command fails the same way. Measured on a reverted-to-greenfield sandbox VM at CLI 0.0.50-next.2413: `~/.config/mosaic`, `fleet/` and `credentials/` all land at 0775, and 1735 directories under the framework root carry `mode & 022`. Two independent causes, and fixing either one alone leaves it broken. 1. The installer inherited the caller's umask. Debian/Ubuntu ship 002, so every `mkdir -p` produced 0775. Fedora/RHEL ship 022 and produced 0755. The product therefore worked or did not depending on the operator's login shell, with nothing in the install output distinguishing the two. 022 is already what this script assumes it produces — `make_durable_snapshot` restores the ambient umask specifically so "every later sync copy and new framework dir" gets 0644/0755 — so pin it rather than inherit it. 2. Even at a correct 0755, three directories are rejected. The fleet code guards its managed paths with two masks in two languages: `assertPrivateManagedDirectory` (fleet-reconciler.js, `mode & 0o077`) covers MOSAIC_HOME and `fleet/` and runs before the roster lock is taken; `assert_private_directory` (tools/fleet/start-agent-session.sh, `mode & 077`) covers `fleet/agents` and runs before a pane is spawned. Their laxer siblings use `mode & 0o022` and accept 0755. The strict mask wins, so the installer states 0700 outright instead of hoping a umask implies it. The `find -perm /022 -exec chmod go-w` sweep repairs a tree installed before this change, which the umask alone cannot reach. It strips group/other WRITE only — never read or execute — and is scoped to directories, so it corrects the boundary violation without changing who may traverse or read anything. It is not sufficient for `fleet/agents`: stripping write from 0755 yields 0750 and `mode & 077` is still non-zero, which is why that path gets its own chmod. Reported as #1236. The `fleet/agents` half was found by scooby reading start-agent-session.sh; the umask framing is theirs too — my first report blamed the distro rather than the umask. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1 |
||
|
|
fb5bb98a32 |
Revert "fix(installer): re-link runtime assets after the CLI stage"
ci/woodpecker/pr/ci Pipeline was canceled
This reverts
|
||
|
|
47e90767b7 |
fix(installer): re-link runtime assets after the CLI stage, so greenfield keeps its enforcement hooks
ci/woodpecker/pr/ci Pipeline was canceled
The framework's install.sh ends by running mosaic-link-runtime-assets, which asks the `mosaic` CLI whether lease enforcement can be activated before deciding whether to wire the #828 hooks into settings.json. Part 1 (framework) runs before Part 2 (npm CLI), so on a first install there is no CLI to ask. The script takes its fail-safe branch, prints a four-line ERROR, and writes settings.json with mutator-gate.py and receipt-observer-client.py stripped out. Measured on canary 1125, rolled back to greenfield, `--next --yes`, no TTY: framework template ~/.config/mosaic/runtime/claude/settings.json mutator-gate.py 1 occurrence receipt-observer-client.py 1 occurrence installed ~/.claude/settings.json after a clean rc=0 install mutator-gate.py wired: False receipt-observer-client.py wired: False So enforcement ends up off because of the order the two halves install in, not because of anything about the host. Falsified by running the same script by hand once the CLI existed: rc=0, both hooks wired: True. The guard's real verdict on that host was 'activatable' the whole time. This adds one more pass after Part 2. The script is idempotent (unchanged files are skipped), so on an upgrade — CLI already present, first pass already correct — it is a no-op. It deliberately does not pass --allow-inactive-enforcement: Part 1 does not either, and a repair pass must not be more permissive than the pass it corrects. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
00bc602f93 |
fix(installer): persist the bootstrapped Node on PATH, and stop duplicating PATH lines
ci/woodpecker/pr/ci Pipeline was successful
Two defects found by the second unattended greenfield run on canary (VMID 1125,
rolled back to its greenfield snapshot first).
1. ensure_node() exported the Mosaic-managed Node for the installer process and
nothing wrote it down. The install finished rc=0, put $PREFIX/bin in
~/.profile, and the next login shell found `mosaic` and then died on
env: 'node': No such file or directory
The CLI is a Node script, so a CLI on PATH without its runtime is a
successful install that produces a broken command. persist_node_on_path()
now writes the runtime's bin dir to the same profile, from both the
fresh-install and the already-installed-but-not-on-PATH branches.
2. The 'is it already in a shell rc file' guard was a single
`grep -qslF "$dir" "${rc_files[@]}"` over four paths, most of which do
not exist on a clean host. Handing grep a missing file makes the exit status
implementation-defined: GNU grep 3.11 returns 0 when -q already matched an
earlier file, ugrep 7.5 returns 2 for the missing one regardless. On the 2
path the caller reads 'not present yet' and appends another PATH line, so
every re-install grew the profile. Measured: 3 runs produced 3 duplicate
entries; with the fix, 1.
path_entry_exists() now tests each file for existence and greps it on its
own, so the result does not depend on the grep implementation.
The profile-writing body is factored into persist_on_path(), shared by the CLI
prefix and the Node runtime, since both now need identical treatment.
Verified in a scratch $HOME: fresh write, idempotent across three runs, zsh
routes to .zshenv, an unwritable profile warns and survives set -e, and an
already-on-PATH prefix is a no-op that creates no file. Falsified by restoring
the multi-file grep: duplicates return.
|
||
|
|
d0c223bdf9 |
fix(wizard): write PATH to .profile/.zshenv, never .bashrc
ci/woodpecker/pr/ci Pipeline was successful
getShellProfilePath() preferred ~/.bashrc when it existed, and ~/.zshrc for
zsh. setupPath() in stages/finalize.ts appends the PATH export to whatever
it returns. Debian's default ~/.bashrc opens with
case $- in *i*) ;; *) return;; esac
so a line appended to the bottom of it never runs for 'bash -lc', for
systemd units, for 'ssh host cmd', or for any agent seat — precisely the
consumers that need the CLI. An install could print its summary and exit 0
while leaving 'mosaic: command not found'. .zshrc has the same problem:
zsh only reads it for interactive shells.
Now ~/.profile, which login shells read and which Debian's copy sources
.bashrc from for interactive shells, so one line covers both. For zsh the
always-sourced file is .zshenv. fish and PowerShell are unchanged.
__tests__/platform/detect.test.ts pins it, including a case asserting that
no shell resolves to an interactive-only rc file. Falsified by inverting
the fix: 5 failed / 1 passed; restored 6/6. Full package suite unchanged at
17 files / 4 tests failing, matching clean origin/next.
|
||
|
|
cc0d24d5c4 |
fix(installer): bootstrap Node.js on a greenfield host
tools/install.sh required node and npm and installed neither. Measured on a snapshot-reverted Debian 13 image with no node, npm or git: the run stopped at `require_cmd node` with "Required command not found: node", exit 1, nothing installed, and no indication of how to proceed. Adds ensure_node() to preflight. It fetches an official Node.js release into $HOME/.mosaic/node, verifies it against that release's SHASUMS256.txt, and refuses rather than degrades when the entry is missing or the checksum does not match. sha256sum on Linux, shasum on macOS. .tar.gz over the smaller .tar.xz because gzip is universally present and xz is not — a minimal image is the case this exists to handle. No-op when a suitable node is already on PATH, so it never fights an operator's nvm/fnm/distro node. MOSAIC_SKIP_NODE_BOOTSTRAP=1 declines the download and fails with instructions instead. Inlined rather than factored into a sibling file because this script is fetched standalone by curl and has nothing to source. |
||
|
|
40fecd4d38 |
fix(installer): put $PREFIX/bin on PATH instead of warning about it
The three duplicated PATH blocks in tools/install.sh only warned, so an unattended install finished with rc=0 and left `mosaic: command not found` — there was no operator to read the advice and act on it. Measured on a greenfield Debian 13 sandbox: `--next --yes` installed @mosaicstack/[email protected] successfully and the CLI was still unreachable. Replaces all three copies with one ensure_prefix_on_path helper that appends the export to ~/.profile (~/.zshenv under zsh) and is a no-op when the prefix is already on PATH or already in a shell profile. Not ~/.bashrc: Debian's default .bashrc returns early for non-interactive shells, so a line appended there is unreachable to `bash -lc`, systemd units and agent seats — the consumers that need the CLI. |