Compare commits

..
Author SHA1 Message Date
goals 3af594590a fix(#1264): preserve portable standalone launch
ci/woodpecker/pr/ci Pipeline failed
2026-08-16 19:01:26 -05:00
goals 9dc90be7e1 fix(#1264): harden unattended identity bootstrap
ci/woodpecker/pr/ci Pipeline failed
2026-08-16 18:33:39 -05:00
goals 43fa047787 fix(#1264): bootstrap fleet identity without a TTY
ci/woodpecker/pr/ci Pipeline failed
2026-08-16 17:37:32 -05:00
fred 476db12b92 Merge pull request 'fix(fleet): tell the operator when the fleet transport is missing (#1240)' (#1245) from fix/1240-fleet-transport-check into next
ci/woodpecker/push/publish Pipeline failed
Reviewed by scooby via git comms (terminal ACK 32d986). Merge directed by Jason 2026-08-16. Conflicts from #1229 and #1252 resolved on the branch by a pi seat; pre-merge gate verified: tools/install.sh=5d28f773, framework/install.sh=1578c33b, test:framework-shell=48 links with both #1252 and #1245 suites present.
2026-08-16 18:12:33 +00:00
fred 5198c3f198 merge next into fix/1240-fleet-transport-check
ci/woodpecker/pr/ci Pipeline failed
Resolves conflicts from #1229 (tools/install.sh node provisioning) and #1252
(package.json test:framework-shell). tools/install.sh resolved to the reviewed
composite blob 5d28f773; package.json resolved as a union so both #1252's four
suites and #1245's transport-check suite run (48 links).
2026-08-16 13:11:31 -05:00
fred 19ac0a02d7 Merge pull request 'test(#1017): wire in four CI-fit shell suites, drop their signed exclusions' (#1252) from fix/1017-wire-start-agent-session into next
ci/woodpecker/push/publish Pipeline was canceled
Reviewed by scooby via git comms (no mosaicstack principal on fomo-lin; review is the comms record, terminal ACK 32d986). Merge directed by Jason 2026-08-16. Part of the five-PR greenfield composite verified E2E on two independent bare boxes.
2026-08-16 18:06:53 +00:00
fred 6d9387c857 Merge pull request 'fix(fleet): fail the agent launcher when the pane cannot survive (#1241)' (#1244) from fix/1241-launch-failure-visible into next
ci/woodpecker/push/publish Pipeline was canceled
Reviewed by scooby via git comms (no mosaicstack principal on fomo-lin; review is the comms record, terminal ACK 32d986). Merge directed by Jason 2026-08-16. Part of the five-PR greenfield composite verified E2E on two independent bare boxes.
2026-08-16 18:06:50 +00:00
fred 14cb9c6a1e Merge pull request 'fix(fleet): let ps/install work on a roster-v2 fleet, and refuse add/remove honestly (#1237 piece A)' (#1243) from fix/1237-fleet-v2-dispatch into next
ci/woodpecker/push/publish Pipeline was canceled
Reviewed by scooby via git comms (no mosaicstack principal on fomo-lin; review is the comms record, terminal ACK 32d986). Merge directed by Jason 2026-08-16. Part of the five-PR greenfield composite verified E2E on two independent bare boxes.
2026-08-16 18:06:35 +00:00
fred b5b322f80d Merge pull request 'fix(installer): pin umask and set the 0700 modes the fleet boundary requires (#1236)' (#1242) from fix/1236-installer-dir-modes into next
ci/woodpecker/push/publish Pipeline was canceled
Reviewed by scooby via git comms (no mosaicstack principal on fomo-lin; review is the comms record, terminal ACK 32d986). Merge directed by Jason 2026-08-16. Part of the five-PR greenfield composite verified E2E on two independent bare boxes.
2026-08-16 18:06:21 +00:00
jason.woltje e4674709be Merge pull request 'fix(installer): make a greenfield install actually work — node bootstrap, PATH, wizard profile' (#1229) from fix/installer-path-and-node into next
ci/woodpecker/push/publish Pipeline was canceled
Reviewed-on: #1229
2026-08-16 18:01:07 +00:00
fred c56483eb1b test(#1017): wire in four CI-fit shell suites, drop their signed exclusions
ci/woodpecker/pr/ci Pipeline failed
check-test-enumeration.sh signed four suites as 'likely CI-fit; #1017 burndown'.
Measured all four: each passes standing alone, and each still passes with tmux
removed from PATH entirely (test-start-agent-session.sh writes its own tmux shim
into a fake bin dir, so it never needed the real binary).

Red-first: removing the four exclusion lines makes the guard report exactly four
UNENUMERATED failures. Appending the four to test:framework-shell returns it to
OK, with in-population enumerated going 32 -> 36 and signed exclusions 19 -> 15.

Refs #1017
2026-08-16 01:14:03 -05:00
fred 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.
2026-08-16 00:21:13 -05:00
fred 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.
2026-08-16 00:19:25 -05:00
fred 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.
2026-08-16 00:15:50 -05:00
fred 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
2026-08-15 23:56:53 -05:00
fred 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 (67f5014c) committed the index — silently
removing the ConditionPathExists line that 463745e3 had added.

Nothing caught it. The spec reads the file from the working tree, so it stayed
10/10 green against a HEAD that no longer had the guard. Found by reading
`git status` after the push, not by any gate.

Verified by content, not by assumption:
  origin/next  0 occurrences
  463745e3     1
  67f5014c     0   <- the regression
  this commit  1

Refs #1237
2026-08-15 23:35:54 -05:00
fred 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)
2026-08-15 23:34:35 -05:00
fred 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
2026-08-15 23:24:24 -05:00
fredandClaude Opus 5 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
2026-08-15 22:40:47 -05:00
fredandClaude Opus 5 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
2026-08-15 22:29:48 -05:00
fred 07373ede4d docs(install): record the two trust/portability assumptions in install_node
ci/woodpecker/pr/ci Pipeline was successful
Comment-only, no behaviour change. Both raised by scooby in the #1229 review
as non-blocking findings worth writing down rather than fixing here.

F-A: the SHASUMS256.txt check gives integrity, not authenticity. TLS to
$NODE_DIST_BASE is the whole trust root, and MOSAIC_NODE_DIST_BASE widens it
to any mirror with no signature backstop. GPG-verifying SHASUMS256.txt.sig is
filed as its own follow-up so it gets its own review.

F-C: the uname map pulls the glibc build, so musl hosts fail — visibly, via
node_is_suitable, not silently.
2026-08-15 21:50:14 -05:00
fredandClaude Opus 5 fb5bb98a32 Revert "fix(installer): re-link runtime assets after the CLI stage"
ci/woodpecker/pr/ci Pipeline was canceled
This reverts 47e90767. I was wrong: the fix is correct about the cause and
makes the outcome worse.

The acceptance run passed everything I set out to check — greenfield canary
1125, --next --yes, no TTY, rc=0, node v22.23.2 + CLI 0.0.50-next.2413 from a
fresh login shell, and both enforcement hooks wired in ~/.claude/settings.json
where before they were stripped. Then `mosaic doctor` on that same host:

  [ERROR] Lease-enforcement hooks (mutator-gate.py, receipt-observer-client.py)
  are wired in ~/.claude/settings.json, but broker not healthy
  (checkBrokerSupervisorHealth() reports unhealthy). Every gated tool call will
  fail closed and BRICK this agent (see #869).

So the change takes a greenfield host from 'enforcement quietly off, agent
works' to 'enforcement wired, broker absent, agent bricks on the first gated
tool call'. The pre-existing behaviour reaches the safe state for the wrong
reason; this reaches the unsafe state for the right one. Safe-for-the-wrong-
reason still wins.

The real defect is underneath both, and it is not an ordering bug:

  mosaic __link-claude-settings ...   -> rc=0  (leaseEnforcementActivatable:
                                                 activatable, wire the hooks)
  mosaic doctor                       -> ERROR (checkBrokerSupervisorHealth:
                                                 unhealthy, hooks will brick)

Two capability checks, same host, opposite verdicts. And after a complete
install there is no broker supervisor to be healthy: no systemd --user unit
matching lease/broker, nothing under ~/.mosaic but the bootstrapped node, and
no lease or broker script in ~/.config/mosaic/tools/_scripts/. Lease
enforcement cannot be activated on a greenfield host at all, so
leaseEnforcementActivatable() returning true is the thing that is wrong.

Filing that separately. PR #1229 goes back to exactly the four commits scooby
reviewed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-15 21:42:20 -05:00
fredandClaude Opus 5 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]>
2026-08-15 21:38:11 -05:00
fred 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.
2026-08-15 16:07:56 -05:00
fred 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.
2026-08-15 15:54:10 -05:00
fred 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.
2026-08-15 15:54:09 -05:00
fred 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.
2026-08-15 15:47:30 -05:00
44 changed files with 2888 additions and 114 deletions
+9 -9
View File
@@ -9,19 +9,19 @@ This book is the canonical home for installation, configuration, deployment, rou
- [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries.
- [Documentation sitemap](../SITEMAP.md) — resolvable current navigation and authority-gated migration summary.
- [Product requirements](../PRD.md) — normative requirements, currently marked draft.
- [Operations index](operations/README.md) — current local procedures and explicitly held operational outlines.
- [Operations index](operations/README.md) — current local procedures, unattended fleet first-start handling, and explicitly held operational outlines.
- [Security index](security/README.md) — current SSO provider configuration.
## Chapter map
| Chapter | Scope | Status |
| ------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. |
| `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. |
| `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. |
| [`operations/`](operations/README.md) | Health, observability, routine operation, and maintenance. | Local upgrade/recovery is current; connector lease operations are held. |
| [`security/`](security/README.md) | Authentication, authorization, SSO, secrets, and security controls. | SSO provider guide is current; other pages are planned. |
| `recovery/` | Incident response, backup, rollback, and recovery. | Scaffold only. |
| Chapter | Scope | Status |
| ------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. |
| `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. |
| `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. |
| [`operations/`](operations/README.md) | Health, observability, routine operation, and maintenance. | Local upgrade/recovery and fleet first start are current; connector lease operations are held. |
| [`security/`](security/README.md) | Authentication, authorization, SSO, secrets, and security controls. | SSO provider guide is current; other pages are planned. |
| `recovery/` | Incident response, backup, rollback, and recovery. | Scaffold only. |
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
+1
View File
@@ -5,6 +5,7 @@
## Current procedures
- [Upgrade safety and recovery](upgrade-safety-and-recovery.md) — installed-CLI and local-PGlite upgrade, rollback, and framework-configuration recovery.
- [Fleet unattended first start](fleet-unattended-first-start.md) — systemd/no-TTY identity initialization, failure handling, and isolated verification.
## Held procedures
@@ -0,0 +1,70 @@
# Fleet Unattended First-Start Operations
> **Status:** Current after issue #1264 lands. This runbook covers only Mosaic's first-run identity
> gate; it does not install runtimes or credentials.
## Operational contract
A systemd fleet unit launches under a sanitized environment with no TTY. The generated environment
sets `MOSAIC_AGENT_NAME`; Mosaic resolves that exact value against the canonical installed roster
before writing identity files.
If top-level identity contracts are missing, Mosaic atomically seeds them from the shipped generic
sources:
| Destination | Source | New-file mode |
| ---------------------- | ------------------------------- | ------------- |
| `$MOSAIC_HOME/SOUL.md` | `$MOSAIC_HOME/defaults/SOUL.md` | `0600` |
| `$MOSAIC_HOME/USER.md` | `$MOSAIC_HOME/defaults/USER.md` | `0600` |
Creation is no-clobber and safe under concurrent seat starts. Existing regular files remain
byte-for-byte and mode-for-mode unchanged. The runtime composer then injects the exact roster name
and class; the generic source files grant no seat authority.
## Failure handling
The fleet path never falls back to an interactive wizard. It exits nonzero before runtime execution
when:
- `MOSAIC_AGENT_NAME` is not an exact roster member;
- a defined ambient `MOSAIC_AGENT_CLASS` is blank/whitespace or disagrees with that member's
canonical class;
- a missing destination has no safe regular default source;
- a source or existing destination is a symlink (including dangling), directory, unavailable, or over the bounded size;
- the fleet communications helper/roster cannot be validated; or
- `USER.md` cannot be securely re-read at the point where its content is composed.
Diagnostics begin with:
```text
[mosaic] ERROR: unattended fleet identity initialization failed: ...
```
Repair the exact named source, destination, roster, or helper and retry only that roster member. Do
not delete or replace an existing personalized `SOUL.md`/`USER.md` merely to clear the check.
## Verification without a live seat
The source gate is:
```bash
pnpm --filter @mosaicstack/mosaic... build && \
pnpm --filter @mosaicstack/mosaic exec vitest run \
src/commands/launch-first-start.spec.ts
```
The build leg is load-bearing: `dist/` is ignored, so a direct Vitest invocation could otherwise run
absent or stale CLI output. The gate runs the exact-source built CLI in subprocesses with piped stdin,
temporary homes, a canonical fixture roster, fake runtime/broker executables, and no provider call. It
covers no-TTY launch, exact identity,
private modes, no-clobber, missing/symlink defaults, unknown members, blank/mismatched class,
portable standalone composition/wizard preservation, and concurrent first start.
Do not use this fixture as proof that a real provider credential is present or that a package has
been deployed. Those require separate environment-specific evidence.
## Related
- [User workflow](../../USER-GUIDE/workflows/fleet-unattended-first-start.md)
- [Developer architecture](../../DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md)
- [Verification report](../../reports/qa/2026-08-16-1264-unattended-first-start.md)
+1
View File
@@ -26,6 +26,7 @@ This book is the canonical home for architecture, package and application guides
- [Lease-broker operations and verification](testing/lease-broker-operations.md) — safe static/test commands plus explicitly held live operations.
- [Channel adapters](integrations/channel-adapters.md) — current shared contracts and Discord reference boundary; future adapter parity is draft.
- [Fleet first-start identity](architecture/fleet-first-start-identity.md) — no-TTY launch boundary, roster authority, and no-clobber filesystem design.
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
@@ -11,6 +11,7 @@ This chapter is the canonical home for Mosaic Stack's system model, component bo
- [`mutator-class-gate.md`](mutator-class-gate.md) — default-deny tool authorization, runtime adapters, launch choke point, and parser assurance boundary.
- [`compaction-revocation.md`](compaction-revocation.md) — Claude/Pi observer lifecycle, runtime generations, revocation, and the bounded residual stale window.
- [`channel-protocol.md`](channel-protocol.md) — current shared channel DTOs and Discord compatibility baseline, with unimplemented adapter work explicitly marked draft.
- [`fleet-first-start-identity.md`](fleet-first-start-identity.md) — roster-owned identity bootstrap for concurrent no-TTY fleet launches.
- [`decisions/mos-runtime-portability-m1.md`](decisions/mos-runtime-portability-m1.md) — current logical identity, connector lease, grant, audit, and fencing decision; connector activation remains held.
These pages are current security-contract references and are consumed by the lease-broker acceptance suites. Their live deployment gaps remain explicitly labeled in the pages; this migration does not change runtime behavior.
@@ -0,0 +1,81 @@
# Fleet First-Start Identity Boundary
> **Status:** Implemented by issue #1264. Requirements: `FCM-REQ-12`, `AC-FCM-10`.
## Problem
`launchRuntime()` called `checkSoul()` before runtime execution. A missing top-level `SOUL.md`
caused `checkSoul()` to spawn a child `mosaic wizard` with inherited stdio. Under a systemd-created
fleet pane with no TTY, that child blocked or failed before the runtime boundary even though generic
`defaults/SOUL.md` and `defaults/USER.md` already shipped in the same `MOSAIC_HOME`.
## Chosen boundary
The fix remains at `checkSoul()` and does not add flags to `yolo`, fleet commands, systemd units, or
`start-agent-session.sh`:
1. A present, nonblank, whitespace-exact `MOSAIC_AGENT_NAME` selects the fleet path.
2. `resolveFleetIdentity()` must resolve that exact member through the existing roster/helper
boundary, and any defined `MOSAIC_AGENT_CLASS` (including blank/whitespace) must canonicalize to
the roster class, before any identity seed. Only undefined means absent.
3. `lstatSync()` preflights every destination directory entry without following links, so a dangling
link is rejected before its counterpart can be published.
4. Safe bounded snapshots are read from only the missing contracts under `defaults/`.
5. Each snapshot is written to a random owner-private temporary file in `MOSAIC_HOME`.
6. `linkSync()` publishes the complete file without overwriting an existing path. `EEXIST` means a
concurrent seat or operator won; the existing path is preserved and revalidated.
7. Temporary files are removed, and both installed contracts are re-opened through the no-symlink
secure-file reader before launch continues.
8. `composeContract()` independently re-resolves the roster, securely reads fleet `USER.md` through
a Linux descriptor at the point of use, and injects exact member identity and communications data.
A standalone launch with no `MOSAIC_AGENT_NAME` retains the portable tolerant USER read and the
interactive wizard. Fleet-only no-follow enforcement must not make supported standalone macOS
composition depend on Linux `/proc` descriptor traversal.
## Identity and authority
The copied defaults deliberately say “Mosaic agent”; they are a generic behavioral base. They are
not the source of a fleet seat's identity. The canonical roster controls:
- exact agent/session name;
- canonical role/class and persona;
- peer rows and point of contact;
- tmux socket and helper target; and
- communications generation.
An unknown/padded ambient name, mismatched class, or explicitly blank/whitespace class fails before
any file is seeded. This avoids replacing the interactive wall with a fleet of indistinguishable or
ambiently invented identities.
## Concurrency and filesystem properties
- Sources and final destinations are bounded regular files beneath `MOSAIC_HOME`; target and dangling
symlinks are not followed.
- New files have mode `0600`.
- Hard-link publication is same-filesystem, atomic, and no-clobber.
- A temporary path is removed only when this process successfully created it.
- All required source snapshots are validated before the first destination is published, preventing
a missing second default from leaving a partial seed.
- Existing operator files are never chmodded or rewritten.
## Verification
`src/commands/launch-first-start.spec.ts` uses the production-kind boundary: the real built CLI in a
no-TTY subprocess, not a direct wizard test. The package `test:vitest` gate builds Mosaic before
Vitest, while the clean-checkout command builds its workspace dependencies first, so ignored
`dist/cli.js` cannot be absent or stale. A fake lease launcher records whether execution reached the
runtime boundary and captures the composed prompt.
Positive and negative cases prove the check can both proceed and refuse. Fleet composition coverage
replaces a previously validated `USER.md` with an external symlink and proves point-of-use refusal;
a standalone unreadable-optional-USER case proves the portable tolerant branch remains separate.
Real Pi authentication and provider task execution remain environment tests, not claims of this
fixture.
## Non-goals
- Runtime installation or pane-PATH resolution (#1256/#1258).
- The held `~/.mosaic` launch-composition layer in PR #1213.
- Personalizing the operator's standalone identity without a wizard.
- Changing fleet systemd or shell launcher code.
+23
View File
@@ -146,6 +146,29 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
### Unattended fleet first-start amendment (#1264)
`FCM-REQ-11` is reserved by #1256's concurrent runtime-preflight delivery. This amendment therefore
uses the next non-colliding identifiers.
1. `FCM-REQ-12`: A roster-owned fleet launch SHALL NOT invoke an interactive identity wizard when
top-level `SOUL.md` or `USER.md` is absent. It SHALL initialize only missing top-level identity
contracts from the shipped generic `defaults/` contracts without overwriting operator-owned
bytes. The canonical roster member remains the sole source of the seat's exact name and class;
generic defaults grant no fleet identity or authority. Missing or unsafe defaults SHALL fail
closed with actionable diagnostics before runtime execution. Non-fleet launches retain the
interactive identity flow.
2. `AC-FCM-10`: A systemd-equivalent no-TTY test with a clean temporary Mosaic home SHALL prove a
named fleet seat reaches the runtime boundary without starting `mosaic wizard`, creates
byte-equal owner-private `SOUL.md` and `USER.md` seeds, and receives its exact roster name/class in
composed context. Tests SHALL also prove no-clobber behavior, concurrent/idempotent first start,
fail-closed invalid defaults, and preservation of the standalone interactive path.
`ASSUMPTION:` `MOSAIC_AGENT_NAME` is the existing launch discriminator for roster-owned fleet
processes. This amendment does not add a second fleet flag because generated fleet environments
already set that value and the runtime composer independently resolves it against the canonical
roster before execution.
---
## Exact Cross-Harness Fleet Communications Contract (#766)
+4
View File
@@ -38,11 +38,13 @@ These paths remain canonical because current source/tests consume them or becaus
- [Quickstart](USER-GUIDE/getting-started/quickstart.md) — installed-CLI first-use route with local PGlite safety boundaries.
- [Web dashboard](USER-GUIDE/product/web-dashboard.md) — current routes, views, chat persistence, settings, and admin behavior.
- [Discord conversations](USER-GUIDE/workflows/discord-conversations.md) — current authorized parent-channel, thread, attachment, and control workflow.
- [Fleet unattended first start](USER-GUIDE/workflows/fleet-unattended-first-start.md) — no-TTY identity bootstrap and exact roster identity.
## Administrator documentation
- [Administrator operations](ADMIN-GUIDE/operations/README.md) — current local procedures and explicitly held outlines.
- [Upgrade safety and recovery](ADMIN-GUIDE/operations/upgrade-safety-and-recovery.md) — installed-CLI/local-PGlite upgrade and framework recovery.
- [Fleet unattended first-start operations](ADMIN-GUIDE/operations/fleet-unattended-first-start.md) — systemd identity initialization, refusal paths, and isolated verification.
- [Mos connector lease operations](ADMIN-GUIDE/operations/mos-connector-lease-operations.md) — held/non-operative M1 outline while policy remains deny-all.
- [Administrator security](ADMIN-GUIDE/security/README.md) — current security chapter index.
- [SSO providers](ADMIN-GUIDE/security/sso-providers.md) — Authentik, WorkOS, and Keycloak configuration and discovery.
@@ -56,6 +58,7 @@ These paths remain canonical because current source/tests consume them or becaus
- [Lease-broker security](DEVELOPER-GUIDE/architecture/lease-broker-security.md) — identity, ancestry, filesystem, observer, and residual boundaries.
- [Whole mutator-class gate](DEVELOPER-GUIDE/architecture/mutator-class-gate.md) — default-deny tool authorization and launch choke point.
- [Compaction revocation](DEVELOPER-GUIDE/architecture/compaction-revocation.md) — lifecycle observers, generation fencing, and residual stale window.
- [Fleet first-start identity](DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md) — roster authority and atomic no-clobber identity seeding.
- [Architecture decisions](DEVELOPER-GUIDE/architecture/decisions/README.md) — implemented and accepted boundaries.
- [Mos runtime portability M1](DEVELOPER-GUIDE/architecture/decisions/mos-runtime-portability-m1.md) — logical identity, connector lease, grants, audit, and fencing.
- [Architecture RFCs](DEVELOPER-GUIDE/architecture/rfcs/README.md) — draft proposals without operational authority.
@@ -75,6 +78,7 @@ These paths remain canonical because current source/tests consume them or becaus
- [Archived planning](archive/planning/README.md) — historical briefs, board reviews, and work-package specifications.
- [Archived work records](archive/work-records/README.md) — historical task scratchpads without live consumers.
- [P8-003 performance report](reports/qa/p8-003-performance-optimization.md) — historical implementation evidence, not a current SLO.
- [Issue #1264 unattended fleet first-start verification](reports/qa/2026-08-16-1264-unattended-first-start.md) — RED/GREEN no-TTY CLI evidence and explicit untested bounds.
- [Plans index](plans/README.md) — approved intent and implementation/audit plans.
- [Documentation information-architecture design](plans/2026-08-10-docs-information-architecture-design.md) — approved documentation structure decision.
- [Documentation catalog-audit plan](plans/2026-08-10-docs-catalog-audit.md) — evidence method and migration acceptance criteria.
+3 -1
View File
@@ -11,6 +11,7 @@ This book is the canonical home for end-user workflows, user-visible behavior, p
- [Quickstart](getting-started/quickstart.md) — install Mosaic, complete setup, and launch a session.
- [Web dashboard](product/web-dashboard.md) — current routes, navigation, chat persistence, projects/tasks views, settings, and admin behavior.
- [Discord conversations](workflows/discord-conversations.md) — current authorized parent-channel, thread, attachment, and control workflow.
- [Fleet unattended first start](workflows/fleet-unattended-first-start.md) — no-TTY identity bootstrap, exact roster identity, and separate runtime prerequisites.
## Chapter map
@@ -18,7 +19,7 @@ This book is the canonical home for end-user workflows, user-visible behavior, p
| ------------------ | ------------------------------------------------------------- | ---------------------------------------------------- |
| `getting-started/` | First-use setup, orientation, and quickstarts. | Quickstart is current; additional pages are planned. |
| `concepts/` | User-facing terminology, product concepts, and mental models. | Scaffold only. |
| `workflows/` | Task-oriented procedures for using Mosaic Stack. | Discord conversation workflow is current. |
| `workflows/` | Task-oriented procedures for using Mosaic Stack. | Discord and fleet first-start workflows are current. |
| `product/` | Current product surfaces and visible behavior. | Web dashboard reference is current. |
| `troubleshooting/` | User-visible failures, diagnostics, and fixes. | Scaffold only. |
@@ -27,6 +28,7 @@ This book is the canonical home for end-user workflows, user-visible behavior, p
- [Quickstart](getting-started/quickstart.md) — the verified installed-CLI first-use path.
- [Web dashboard](product/web-dashboard.md) — verified current Next.js dashboard behavior and limitations.
- [Discord conversations](workflows/discord-conversations.md) — verified current Discord user workflow.
- [Fleet unattended first start](workflows/fleet-unattended-first-start.md) — verified no-TTY first-start behavior and prerequisite boundaries.
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
@@ -0,0 +1,61 @@
# Fleet Unattended First Start
> **Status:** Current for roster-owned local fleet launches after issue #1264 lands. Runtime
> installation and provider authentication remain separate prerequisites.
A fleet seat started by systemd has no operator at its pane. On its first launch, Mosaic must not
stop at the interactive identity wizard.
## What happens on first start
When `MOSAIC_AGENT_NAME` names an exact member of the installed fleet roster and top-level identity
contracts are absent, the launcher:
1. validates the exact roster member, its canonical class, and the installed fleet communications
helper;
2. reads the shipped generic contracts from
`~/.config/mosaic/defaults/SOUL.md` and `defaults/USER.md`;
3. creates only the missing top-level `SOUL.md` and `USER.md` as owner-private files;
4. preserves any existing top-level identity file byte-for-byte; and
5. launches the runtime with the roster member's exact agent/session name and role/class in composed
context.
The generic defaults do **not** make every seat the same identity. They provide a shared behavioral
base. The canonical roster row supplies each seat's exact name, class, peers, socket, and authority.
## Operator behavior
A normal standalone launch without a fleet identity retains its portable configuration path and still
uses the interactive wizard when `SOUL.md` is absent:
```bash
mosaic pi
```
A roster-owned seat may be started without attaching to its pane:
```bash
mosaic fleet start <exact-roster-name>
```
Mosaic refuses before runtime execution if the requested member is absent, its explicitly supplied
ambient class is blank or conflicts with the roster, a required default is missing or unsafe, or an existing identity contract
is not a safe regular file. Repair the named component and retry the same exact roster member; do not
copy another seat's personalized identity.
## Separate prerequisites
This behavior clears the Mosaic identity-wizard wall only. A clean host still needs:
- the declared runtime installed on the pane PATH;
- the fleet transport and generated unit assets; and
- runtime/provider authentication appropriate to that seat.
Those checks are separate so a successful identity bootstrap is not reported as a fully authenticated
agent session.
## Related
- [Administrator runbook](../../ADMIN-GUIDE/operations/fleet-unattended-first-start.md)
- [Developer architecture](../../DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md)
- [Verification report](../../reports/qa/2026-08-16-1264-unattended-first-start.md)
+3
View File
@@ -12,11 +12,13 @@ Use the canonical guide, API contract, source, and tests to determine current be
- [Issue #756 documentation checklist](documentation/756-discord-plugin-checklist.md) — historical completion checklist for the official Discord plugin workstream.
- [Framework consistency audit — 2026-02-17](documentation/AUDIT-2026-02-17-framework-consistency.md) — historical framework consistency and remediation snapshot.
- [Compaction-refresh #830 checklist](compaction-refresh/830-documentation-checklist.md) — historical incomplete-at-snapshot documentation checklist.
- [Issue #1264 documentation checklist](documentation/1264-documentation-checklist.md) — current in-repo user/admin/developer/report coverage and review gate.
## Code-review evidence
- [Issue #756 independent code review](code-review/756-code-review.md) — historical exact-scope review of the official Discord plugin workstream.
- [Gateway security-hardening code review — 2026-03-13](code-review/gateway-security-20260313.md) — historical no-blocker review snapshot.
- [Issue #1264 independent code and security review](code-review/1264-code-review.md) — initial finding, remediation, clean re-review, and remaining formal PR-review gate.
## Security evidence
@@ -26,6 +28,7 @@ Use the canonical guide, API contract, source, and tests to determine current be
- [P8-003 performance optimization report](qa/p8-003-performance-optimization.md) — historical implementation evidence; not a current SLO or production benchmark.
- [Gateway security-hardening QA report — 2026-03-13](qa/gateway-security-20260313.md) — historical test report with its original live-smoke-test limitation.
- [Issue #1264 unattended fleet first-start verification](qa/2026-08-16-1264-unattended-first-start.md) — RED/GREEN no-TTY CLI evidence, baseline gates, and explicit real-provider limitation.
## Native Kanban/SOT evidence
@@ -0,0 +1,97 @@
# Issue #1264 Code and Security Review
> Branch: `fix/1264-fleet-unattended-first-start` | Base:
> `origin/next@476db12b92971634b67fd2057b7577ee5894e449`
## Initial automated review
Codex reviewed the pre-PR uncommitted delta with:
```bash
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted \
-o /tmp/1264-codex-code-review.json
```
Result: `request-changes`, confidence `0.93`, 20 files, one should-fix. `checkSoul()` trimmed
`MOSAIC_AGENT_NAME` for pre-seed resolution while composition used the original value, so a padded
name could seed files before later refusal.
Remediation rejected blank/leading/trailing-whitespace values before roster lookup or writes and
added three built-CLI no-side-effect regressions. Automated re-review approved that delta with no
findings (confidence `0.86`). Initial security review reported risk `none` (confidence `0.91`).
## Formal exact-head review
Daphne reviewed PR #1268 at exact head `43fa0477877e0d0f110da8d11c3033b40ddeb191` and filed Gitea
review ID 168 as `REQUEST_CHANGES`. The review was source/PR-only; the canary remained untouched.
Blocking groups:
1. class mismatch was validated after first-start mutation;
2. secure `USER.md` validation was discarded before ordinary path-following composition;
3. `existsSync()` treated a dangling destination symlink as missing, allowing counterpart partial
publication; and
4. the built-CLI/evidence chain allowed stale ignored `dist/`, cited an unshipped canary object, and
carried conflicting test totals/pane wording.
The diagnostic's defaults-only repair advice was also inaccurate for roster/class/destination
failures.
## Formal-review remediation
All four blocking groups received regressions before production changes. The RED run produced four
failures while 1,568 existing tests passed. Remediation then:
- validates canonical name and class before seeding;
- preflights destination directory entries with `lstatSync()` so target and dangling symlinks fail
before publication;
- securely reads `USER.md` through an `O_NOFOLLOW` descriptor at composition time;
- adds a Mosaic build before package Vitest and a dependency build in the clean-checkout command;
- replaces defaults-only advice with neutral named-component repair guidance; and
- reconciles shipping canary provenance, pane chronology, commands, and totals.
Remediation code review:
```bash
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted \
-o /tmp/1264-remediation-code-review.json
```
Result: `approve`, confidence `0.88`, 6 files, no findings. Summary: the fail-closed destination
checks, class-validation order, secure composition, and build-before-Vitest path are coherent.
Remediation security review:
```bash
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted \
-o /tmp/1264-remediation-security-review.json
```
Result: risk `none`, confidence `0.93`, 9 files, no critical/high/medium/low findings. The sandbox
could not run Vitest because Vite attempted to create a temporary config artifact on its read-only
mount (`EROFS`); executor-owned focused and full results are recorded in the QA report.
## Second exact-head review
Daphne reviewed exact head `9dc90be7e13b1cd609f6df97d43d890ef5392ca0` and filed Gitea review
ID 169 as `REQUEST_CHANGES`. Review 169 confirmed all review-168 closures, then found:
1. the new point-of-use reader was Linux-only but had been applied to every standalone USER read,
breaking supported non-fleet macOS composition; and
2. explicit blank/whitespace `MOSAIC_AGENT_CLASS` was treated as absent and could seed before
runtime, while only undefined should mean absent.
Red-first remediation preserves legacy `readOptional()` for standalone composition, keeps descriptor
no-follow consumption fleet-only, moves the replacement-symlink case under a valid fleet identity,
and rejects defined blank/whitespace classes before seeding. Three blank-class CLI cases and one
tolerant standalone composition case failed before the source change and pass after it.
Review-169 remediation code review approved at confidence `0.90` (4 files, no findings). Security
review reported risk `none` at confidence `0.90` (4 files, no findings). The review sandbox retained
its known Vite `EROFS` limitation; executor-owned tests are in the QA report.
## Remaining review gate
Daphne must re-review the next exact pushed head. This report cannot record that future verdict
without changing the reviewed head, so the authoritative terminal verdict belongs to PR #1268's
Gitea review record. Fred and goals are excluded as reviewers.
@@ -0,0 +1,32 @@
# #1264 Documentation Completion Checklist
## Required artifacts
- [x] `docs/PRD.md` updated with `FCM-REQ-12` and `AC-FCM-10`.
- [x] User workflow documents unattended fleet first start and separate prerequisites.
- [x] Administrator operations page documents source/destination ownership, failure handling, and an
exact-source build-before-Vitest verification gate.
- [x] Developer architecture page documents control flow, identity authority, concurrency, and non-goals.
- [x] `docs/SITEMAP.md` and book indexes updated.
- [x] QA evidence is under `docs/reports/qa/`; working notes are under `docs/scratchpads/`.
- [x] Framework defaults README reflects fleet-versus-standalone behavior.
## API coverage
- [x] No HTTP/API endpoint or DTO changed; OpenAPI and endpoint indexes are not applicable.
## Structural standards
- [x] User, administrator, developer, report, and sitemap indexes link the new pages.
- [x] No noncanonical file was added at the `docs/` root.
- [x] Canonical documentation remains in-repo; no external publication was requested or performed.
## Review gate
- [x] Initial padded-name finding remediated and automated re-review approved.
- [x] Daphne formal review ID 168 completed on exact first head `43fa0477` and requested changes.
- [x] Four review-168 groups reproduced red and remediated; automated reviews are clean.
- [x] Daphne review ID 169 completed on exact head `9dc90be7` and confirmed review-168 closures.
- [x] Review-169 standalone-portability and blank-class blockers reproduced red and remediated;
automated reviews are clean.
- [ ] Daphne exact-second-remediation-head re-review completed after push (Fred/goals excluded).
@@ -0,0 +1,220 @@
# #1264 Unattended Fleet First-Start Verification
> Status: **IN PROGRESS — review-169 remediation complete locally; push/re-review pending** |
> Executor: goals | Date: 2026-08-16 | Target: isolated local fixtures only
## Objective
Verify that a named fleet seat launched through a systemd-equivalent, no-TTY environment on a clean
host reaches its runtime boundary without an interactive Mosaic identity wizard. Preserve standalone
wizard behavior and canonical-roster ownership of exact seat identity.
## Source evidence accepted for local verification
Daphne's canary Run-7 report is reachable from jarvis-brain `origin/main` at
`8bf94afeb8c7d5df96cdd4a4508e75a1d2999710`,
`docs/reports/2026-08-16_sbx-canary-greenfield-e2e.md`. The earlier local object
`6c0b6fc70ae6a179a1b7ff9dedfc54e9adccd19a` is not reachable from an origin ref and is not used as
shipping provenance. Run 7 measured:
```text
systemd -> start-agent-session.sh -> mosaic yolo pi (PID 3726)
-> child mosaic wizard (PID 3762)
```
The pane was preserved when Run 7 was captured. Formal review ID 168 records that an authorized
rollback occurred later. This task never accessed or altered the canary VM, pane, snapshot, or
rollback state. Product behavior is independently tested here with temporary roots and fake runtime
executables.
## Controls
- Original base: `origin/next@476db12b92971634b67fd2057b7577ee5894e449`.
- PR: #1268, first pushed head `43fa0477877e0d0f110da8d11c3033b40ddeb191`.
- `DATABASE_URL` remains unset for local tests.
- No runtime/provider credential or token value, VM, installed Mosaic tree, unit, timer, PATH profile,
or live tmux session is read or mutated. Standard Gitea/Woodpecker wrappers authenticate metadata
reads/writes without exposing credential values.
- Tiny's runtime-preflight and `start-agent-session.sh` PATH work remain out of scope.
- Held PR #1213 is not a dependency.
## Requirements-to-evidence map
| Acceptance criterion | Method | Evidence |
| ---------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------- |
| No-TTY fleet first start avoids wizard and reaches runtime | Exact-source built CLI with piped stdin | CLI GREEN |
| Missing top-level identity files are initialized from shipped defaults | Exact-byte and `0600` assertions | CLI + filesystem GREEN |
| Exact seat identity remains roster-owned | Captured argv; mismatched/blank class no-side-effect refusals | CLI GREEN |
| Existing operator identity is never overwritten | Custom bytes/mode with defaults removed | CLI + filesystem GREEN |
| Concurrent/repeated first start is safe | Four parallel CLIs plus repeated launch | CLI GREEN |
| Missing/unsafe defaults and destinations fail before partial mutation | Missing, target/dangling symlink, oversized, invalid-root cases | Filesystem/CLI GREEN |
| Validated `USER.md` cannot be replaced by an external symlink | Seed, replace, compose at point of use | Composition GREEN |
| Standalone launch retains wizard | Same built CLI without fleet identity | CLI GREEN |
| Built-CLI evidence cannot use stale ignored `dist/` | Build-with-dependencies gate before Vitest | Package script + command gate |
## Initial RED
Production source remained unchanged after adding the first reproducer. The CLI was built from
`origin/next@476db12` before the test.
```bash
env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic exec vitest run \
src/commands/launch-first-start.spec.ts
```
Exit `1`; one file and one test failed. Output included:
```text
[mosaic] SOUL.md not found. Running setup wizard...
◆ What would you like to do?
[mosaic] Setup failed. Run: mosaic wizard
AssertionError: expected 1 to be +0
```
The fake runtime-boundary capture was not created. Complete stdout/stderr was retained at
`/tmp/1264-red.out` during that work session.
## Formal-review remediation RED
Daphne's exact-head review ID 168 requested changes at `43fa0477`. Before changing production code,
new regressions were run against an exact-source build. Four tests failed while the existing 1,568
passed:
1. valid roster name plus mismatched ambient class seeded both files before refusal;
2. dangling `SOUL.md` allowed `USER.md` to be published before refusal;
3. dangling `USER.md` allowed `SOUL.md` to be published before refusal; and
4. replacing a securely validated `USER.md` with an external symlink was followed by composition.
This establishes that all four review-168 findings were observable on the pushed implementation.
Daphne's review ID 169 then found two more exact-head failures at `9dc90be7`. Before production
changes, four new assertions failed:
1. standalone composition routed an unreadable optional `USER.md` through the Linux-only descriptor
reader instead of the legacy portable tolerant path; and
2. explicit `MOSAIC_AGENT_CLASS` values `""`, `" "`, and tab were treated as absent, seeded both
identity files, and reached runtime.
The replacement-symlink case was also moved under a valid roster identity so it tests the fleet-only
security boundary rather than standalone behavior.
## Final GREEN
The production-kind command builds Mosaic and all workspace dependencies before invoking Vitest,
because `dist/` is ignored and may otherwise be absent or stale:
```bash
env -u DATABASE_URL sh -c '
pnpm --filter @mosaicstack/mosaic... build &&
pnpm --filter @mosaicstack/mosaic exec vitest run \
src/commands/fleet-first-start-identity.spec.ts \
src/commands/launch-first-start.spec.ts \
src/commands/launch.spec.ts \
src/commands/compose-contract.spec.ts \
src/config/file-adapter.test.ts \
src/cli-smoke.spec.ts
'
```
Exit `0`: `6/6` files, `128/128` tests.
- 15 real-CLI/no-TTY tests cover exact roster name/class, byte-equal `0600` seeds, no-clobber,
partial seed, missing/symlink defaults, unknown/padded/blank name, mismatched or explicitly blank
class, standalone wizard preservation, and four concurrent starts.
- 12 direct filesystem tests cover complete publication, existing operators, idempotence, source
prevalidation, target and dangling destination links, invalid roots, oversized input, and
unexpected link errors.
- Composition coverage deterministically replaces a valid fleet seat's validated `USER.md` with an
external symlink and requires refusal at point of use. A separate standalone case proves tolerant
optional composition remains outside the Linux-only fleet reader.
Full package gate (which rebuilds Mosaic itself after the clean-checkout dependency build):
```bash
env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic run test:vitest
```
Exit `0`: `88/88` files, `1,577/1,577` tests.
Focused helper + point-of-use coverage:
```text
2 files, 53/53 tests
Statements 97.84% | Branches 91.66% | Functions 100% | Lines 97.84%
Exit 0
```
Final repository gates after remediation:
```text
pnpm preflight exit 0
pnpm typecheck 45/45 tasks, exit 0
pnpm lint 25/25 tasks, exit 0
pnpm build 25/25 tasks, exit 0
pnpm format:check exit 0
git diff --check exit 0
```
Pre-PR targeted shell runs on the unchanged shell surfaces also passed:
```text
bash framework/tools/fleet/test-start-agent-session.sh exit 0 locally
bash framework/tools/quality/scripts/test-install-migration.sh 21 passed, 0 failed
bash framework/tools/_scripts/test-mosaic-init-rce.sh PASS
```
The aggregate local `test:framework-shell` run stopped at `invariant_r_unittest.py`: installed
operator-global Pi is `0.84.2`, while the invariant is measured for `0.84.1`. Later aggregate stages
remain unmeasured except the targeted suites above. Root `pnpm test` remains locally **UNTESTED**
because this checkout prohibits the PostgreSQL-dependent gateway isolation path.
## Review and security evidence
- Initial Codex review found padded-name mutation-before-refusal; it was fixed with three
no-side-effect regressions.
- Codex review of the formal-review remediation: `approve`, confidence `0.88`, 6 files, no findings.
- Codex security review of the remediation: risk `none`, confidence `0.93`, 9 files, no findings.
Its sandbox could not execute Vitest because Vite attempted a write on a read-only mount; the
executor-owned results above are the test evidence.
- Daphne formal review ID 168 at exact head `43fa0477`: `REQUEST_CHANGES`, four blocking groups; all
closed by review 169.
- Daphne formal review ID 169 at exact head `9dc90be7`: `REQUEST_CHANGES`, two blocking groups
(standalone portability and explicit blank class). Both now have red-first regressions and local
green remediation.
- Codex review of review-169 remediation: `approve`, confidence `0.90`, 4 files, no findings.
- Codex security review of review-169 remediation: risk `none`, confidence `0.90`, 4 files, no
findings. Exact-new-head Daphne re-review is pending until that head is pushed.
## CI evidence and external blocker
Pipeline 2445 ran against exact first head `43fa0477`:
- install, sanitization, upgrade guard, typecheck, lint, and format passed;
- Mosaic Vitest passed `88/88`, `1,568/1,568`; and
- the test step emitted exactly one `FAIL:` line:
```text
FAIL: host provides 'pi' in the system path; missing-binary cases are not measurable here
```
That line comes from the inherited `test-start-agent-session.sh` CI-fit guard, not #1264. Fred filed
the correction as PR #1270. Its pipeline 2448 is terminal green and proves the four formerly masked
suites execute, but #1270 is not merged, so `next` still carries the failing chain. A new #1268
pipeline 2449 at `9dc90be7` reproduced the same single inherited `FAIL:` after Mosaic passed
`1,573/1,573`. A new pipeline is pending the review-169 remediation push. Terminal-green #1268 CI is
not claimed.
PR #1268's envelope was read back as `user.login=mos-dt-0`; its commit is explicitly authored and
committed by `goals <[email protected]>`. No goals Gitea login exists on this host, and no
other principal was borrowed. The cross-wrapper principal defect is tracked in #1272.
## Explicitly untested
- Canary VM remediation/restart: **UNTESTED and prohibited**.
- Real Pi authentication/provider prompt and task execution: **UNTESTED**.
- PR #1213 composition layer: **UNTESTED and not required**.
- Deployment/published npm behavior: **UNTESTED until merge/release**.
- Local PostgreSQL execution/migration: **UNTESTED and prohibited**.
The local gate proves Mosaic crosses its identity boundary and reaches a fake lease-runtime boundary;
it does not claim provider readiness, deployment, or a currently running canary seat.
@@ -0,0 +1,104 @@
# #1264 — Unattended fleet first start
## Tracking
- Issue: `mosaicstack/stack#1264`
- PR: `mosaicstack/stack#1268`
- Branch: `fix/1264-fleet-unattended-first-start`
- Base: `origin/next@476db12b92971634b67fd2057b7577ee5894e449`
- First pushed head: `43fa0477877e0d0f110da8d11c3033b40ddeb191`
- Current remediation worktree: `/var/home/jason.woltje/agent-work/1264-review2-remediation`
- Coordinator: Fred; reviewer must be neither Fred nor this implementation seat.
- `docs/TASKS.md` is orchestrator-owned and is not modified by this worker.
The original `/var/home/jason.woltje/agent-work/1264-unattended-first-start` and first remediation
worktrees were removed without force after each pushed head and clean state were verified. The
Fred-authorized plain-Git worktree exception was reused for exact-head review remediation because
`/src` remains unavailable.
## Objective
A roster-owned fleet seat launched from systemd on a clean host must cross Mosaic's first-run identity
gate without a human or TTY, while retaining exact name/class from the canonical roster and
preserving the standalone interactive wizard.
## Intake and boundaries
- Shipping canary provenance is jarvis-brain `origin/main` commit
`8bf94afeb8c7d5df96cdd4a4508e75a1d2999710`. The earlier local `6c0b6fc...` object is not used.
- The Run-7 pane was preserved when evidence was captured; formal review records a later authorized
rollback. This task never accessed or altered the canary.
- Tiny's concurrent runtime-preflight, `start-agent-session.sh`, and #1258 PATH seam remain untouched.
- Held PR #1213 is not a dependency.
- No runtime/provider credential values or provider calls, installed-host changes, PostgreSQL, unit,
timer, or profile mutation. Tests use temporary roots and fake executables only; Gitea/Woodpecker
metadata operations use standard wrappers without exposing credentials.
## Requirements and design
- PRD IDs: `FCM-REQ-12`, `AC-FCM-10`; `FCM-REQ-11` is reserved by #1256.
- A present fleet name must be nonblank, whitespace-exact, and resolve through the canonical roster.
- Any defined ambient class, including blank/whitespace, must canonicalize to the roster class before
mutation; only undefined means absent.
- Preflight all destination directory entries with no-follow existence semantics so dangling links
fail before counterpart publication.
- Seed only missing top-level files from bounded regular defaults with owner-private, atomic,
no-clobber hard links.
- Generic defaults are behavior, not identity or authority.
- Securely consume fleet `USER.md` through a Linux descriptor at composition time.
- Standalone composition retains the portable tolerant USER read and missing identity retains the
wizard.
## Progress
- [x] Issue, canary report, Tiny collision state, and PRD read/amended.
- [x] Initial production-kind RED captured with a real built CLI and no TTY.
- [x] Implementation, tests, user/admin/developer docs, QA, and indexes delivered.
- [x] Initial automated review finding (padded name before write) remediated.
- [x] Commit `43fa0477` pushed; PR #1268 opened against `next`; original worktree removed cleanly.
- [x] Daphne formal review ID 168 completed on exact first head: `REQUEST_CHANGES` with four groups.
- [x] All four review-168 groups reproduced red before remediation and passed at `9dc90be7`.
- [x] Daphne review ID 169 completed on `9dc90be7`: review-168 closures confirmed; two new blockers.
- [x] Review-169 portability and blank-class blockers reproduced red and now pass locally.
- [x] Review-169 Codex review approved; security review risk `none`.
- [ ] Commit/push second remediation with explicit goals author/committer; verify remote object/content.
- [ ] Daphne exact-new-head re-review.
- [ ] Terminal #1268 CI. Pipeline 2445's only `FAIL:` was the inherited Pi-PATH CI-fit guard; PR
#1270's pipeline 2448 is green, but #1270 is not merged.
- [ ] Remove the clean remediation worktree after push.
## Test evidence
### Initial RED
The built `origin/next` CLI entered `mosaic wizard`, rendered `What would you like to do?`, exited 1,
and never created the fake runtime-boundary capture.
### Formal-review RED
Against exact first-head production code, four new tests failed while 1,568 existing tests passed:
class mismatch mutated before refusal; each dangling destination left its counterpart; and a
replacement `USER.md` symlink was consumed by composition. Review-169 RED then proved standalone
composition hit the Linux-only reader and three explicit blank/whitespace class cases seeded and
launched.
### Final local GREEN
- Exact-source focused gate: `6/6` files, `128/128` tests.
- Full exact-source Mosaic Vitest: `88/88` files, `1,577/1,577` tests.
- Helper + point-of-use coverage: `53/53`; 97.84% statements/lines, 91.66% branches, 100% functions.
- Root preflight passed; typecheck `45/45`, lint `25/25`, build `25/25`.
- Initial targeted shell gates passed: start-agent-session, install migration `21/21`, init-RCE.
- Local aggregate framework shell stops at operator-global Pi `0.84.2` versus measured `0.84.1`.
- Local root `pnpm test` remains unrun because the checkout prohibits its PostgreSQL-dependent path.
The full evidence and command boundaries are in
`docs/reports/qa/2026-08-16-1264-unattended-first-start.md`.
## Review / delivery notes
- Review-168 remediation Codex review: approve `0.88`; security risk `none` `0.93`.
- Review-169 remediation Codex review: approve `0.90`; security risk `none` `0.90`.
- PR envelope reads `mos-dt-0`; the commit reads goals/goals. No goals Gitea principal exists on this
host, so no other principal will be borrowed. Tracked in #1272.
- PR #1270 is pushed, not merged. Do not represent `next` or #1268 CI as green until measured.
+4
View File
@@ -7,6 +7,10 @@
- [DOCS-IA-001 — information architecture](DOCS-IA-001.md) — completed structure-design and documentation-contract record.
- [DOCS-IA-002 — catalog audit and migration](DOCS-IA-002-catalog-audit.md) — active coordinator progress, autonomous lane state, verification evidence, and authority blockers.
## Active implementation records
- [Issue #1264 — unattended fleet first start](1264-unattended-first-start.md) — plan, RED/GREEN evidence, collision boundaries, and PR lifecycle state.
Completed scratchpads may remain here when they provide useful delivery provenance. Their conclusions must be reflected in the owning canonical page before the scratchpad is treated as complete.
## Related
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// homedir/platform are read at call time, so they can be stubbed per case.
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
homedir: () => '/home/tester',
platform: () => mockPlatform,
};
});
let mockPlatform: NodeJS.Platform = 'linux';
const { getShellProfilePath, detectShell } = await import('../../src/platform/detect.js');
describe('getShellProfilePath', () => {
const originalShell = process.env['SHELL'];
const originalZdotdir = process.env['ZDOTDIR'];
beforeEach(() => {
mockPlatform = 'linux';
delete process.env['ZDOTDIR'];
});
afterEach(() => {
if (originalShell === undefined) delete process.env['SHELL'];
else process.env['SHELL'] = originalShell;
if (originalZdotdir === undefined) delete process.env['ZDOTDIR'];
else process.env['ZDOTDIR'] = originalZdotdir;
});
// The regression this guards: setupPath() in stages/finalize.ts appends the
// PATH export to whatever this returns. A line written to ~/.bashrc is
// unreachable to `bash -lc`, systemd units and agent seats, because Debian's
// default .bashrc returns early for non-interactive shells — so an install
// reported success and left `mosaic: command not found`. Same for .zshrc,
// which zsh only reads for interactive shells.
it('never targets an interactive-only rc file', () => {
for (const shell of ['/bin/bash', '/usr/bin/zsh']) {
process.env['SHELL'] = shell;
const profile = getShellProfilePath();
expect(profile).not.toMatch(/\.bashrc$/);
expect(profile).not.toMatch(/\.zshrc$/);
}
});
it('uses ~/.profile for bash', () => {
process.env['SHELL'] = '/bin/bash';
expect(getShellProfilePath()).toBe('/home/tester/.profile');
});
it('uses ~/.zshenv for zsh', () => {
process.env['SHELL'] = '/usr/bin/zsh';
expect(getShellProfilePath()).toBe('/home/tester/.zshenv');
});
it('honours ZDOTDIR for zsh', () => {
process.env['SHELL'] = '/usr/bin/zsh';
process.env['ZDOTDIR'] = '/custom/zdot';
expect(getShellProfilePath()).toBe('/custom/zdot/.zshenv');
});
it('falls back to ~/.profile for an unknown shell', () => {
process.env['SHELL'] = '/bin/somethingelse';
expect(detectShell()).toBe('unknown');
expect(getShellProfilePath()).toBe('/home/tester/.profile');
});
it('still routes fish to its own config', () => {
process.env['SHELL'] = '/usr/bin/fish';
expect(getShellProfilePath()).toBe('/home/tester/.config/fish/config.fish');
});
});
+1 -2
View File
@@ -82,7 +82,6 @@ is re-seeded a genuinely missing core file is a stop-and-report condition — no
Confirm: required + situational tests passed (primary gate); aligned to `docs/PRD.md`; acceptance
criteria mapped to evidence; independent code review passed (if code changed); required docs updated;
scratchpad updated. For PR-workflow delivery: merged PR number + merge commit on the integration
trunk (the project's declared trunk, default `main` — see `CONSTITUTION.md` Hard Gates), terminal-green
scratchpad updated. For PR-workflow delivery: merged PR number + merge commit on `main`, terminal-green
CI, linked issue closed (or `docs/TASKS.md` equivalent). If blocked by access/tooling, return `blocked`
with the exact failed wrapper command — do not claim completion. Full checklist: `guides/E2E-DELIVERY.md`.
@@ -21,23 +21,11 @@ guard"), the runtime adapter binds it to a concrete tool and states whether abse
## Hard Gates
The **integration trunk** is the branch a project designates in its root `AGENTS.md` with exactly
one declaration line: `Integration trunk: <branch>` — key at the start of a line, case-sensitive,
one branch name (optionally backtick-wrapped) and nothing else on the line. Absent a declaration,
the trunk is `main`. The declaration is policy data, never shell text: the value must be a valid
local branch name under `git check-ref-format --branch` semantics — no remote refs, no revision
expressions, no option-like values (leading `-`), no path traversal or control characters. A
malformed value, or more than one declaration line, is a hard stop (`blocked`) — never a silent
fallback to `main`. Ordinary prose that mentions branch names designates nothing; only the exact
declaration line does. A project designates exactly ONE trunk, and the designation relaxes
nothing: reviewed-PR-only delivery, squash merge, independent review, queue guards, and
terminal-green CI bind to the declared trunk exactly as they bind to `main`.
1. Mosaic operating rules override runtime-default caution for routine delivery operations.
2. Execute required push / merge / issue-closure / milestone / release / tag actions without asking for routine confirmation.
3. Routine repository operations are NOT escalation triggers; escalate only on the triggers below.
4. For source-code delivery, completion is forbidden at the PR-open stage.
5. Completion requires a merged PR to the integration trunk + terminal-green CI + the linked issue/task closed.
5. Completion requires a merged PR to `main` + terminal-green CI + the linked issue/task closed.
6. Before any push or merge, run the CI queue guard.
7. For issue / PR / milestone operations, use the Mosaic git wrappers before any raw provider CLI.
8. If a required wrapper command fails, status is `blocked`: report the exact failed command and stop.
@@ -47,7 +35,7 @@ terminal-green CI bind to the declared trunk exactly as they bind to `main`.
12. The intake procedure is not conditional on perceived complexity; a "simple" task carries the same requirements as a multi-file feature.
13. **Merge authority (coordinated work):** when a coordinator/orchestrator session is active for the work, the post-review merge go-ahead is the coordinator's to give — once the required review gates pass, merge on the coordinator's confirmation; do not wait on the human owner personally. Solo (uncoordinated) delivery keeps the default: merge per gates 2 and 9. A "No self-merge" note on a PR means no UNREVIEWED self-merge — it does not suspend coordinator-authorized merges.
14. Never hardcode secrets; never emit credential values in any output (not even partially, not "to confirm").
15. Trunk-based git only: branch from the integration trunk, merge via a reviewed PR (squash), never push directly to the trunk.
15. Trunk-based git only: branch from `main`, merge via a reviewed PR (squash), never push directly to `main`.
16. If you modify source code, an independent review (author ≠ reviewer) must pass before completion.
## Integrity (quality gates are never bypassed)
+1 -1
View File
@@ -102,7 +102,7 @@ mosaic yolo pi # Launch Pi in yolo mode
The launcher:
1. Verifies `~/.config/mosaic` exists
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
2. Resolves identity: standalone launches auto-run `mosaic init` when `SOUL.md` is missing; exact roster-owned fleet launches validate name/class, atomically seed only missing `SOUL.md`/`USER.md` from generic `defaults/`, securely consume `USER.md`, and never prompt
3. Injects `AGENTS.md` into the runtime
4. Forwards all arguments to the runtime CLI
+10 -11
View File
@@ -12,7 +12,7 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
4. Issue tracking is consistent across projects
5. Documentation standards and API contracts are enforced from day one
6. PRD requirements are established before coding begins
7. Branching/merging is consistent: branch -> integration trunk (default `main`) via PR with squash-only merges
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
## Agent Host Prerequisites
@@ -206,7 +206,7 @@ Every runtime context file should contain:
6. **Issue tracking** — Issue and commit conventions
7. **Code review** — Required review process
8. **Runtime notes** — Runtime-specific behavior references
9. **Branch and merge policy** — Trunk workflow (branch -> integration trunk via PR, squash-only)
9. **Branch and merge policy** — Trunk workflow (`branch -> main` via PR, squash-only)
10. **Autonomy and escalation policy** — Agent owns coding/review/PR/release/deploy lifecycle
---
@@ -288,16 +288,15 @@ Reserve `0.1.0` for the MVP release milestone.
---
## Step 5b: Configure Trunk Branch Protection (Hard Rule)
## Step 5b: Configure Main Branch Protection (Hard Rule)
Apply equivalent settings in Gitea, GitHub, or GitLab, targeting the project's integration trunk
(the branch its root `AGENTS.md` declares; default `main` — see `CONSTITUTION.md` Hard Gates):
Apply equivalent settings in Gitea, GitHub, or GitLab:
1. Protect the integration trunk from direct pushes.
2. Require pull requests to merge into the integration trunk.
1. Protect `main` from direct pushes.
2. Require pull requests to merge into `main`.
3. Require required CI/status checks to pass before merge.
4. Require code review approval before merge.
5. Allow **squash merge only** for PRs into the integration trunk (disable merge commits and rebase merges for it).
5. Allow **squash merge only** for PRs into `main` (disable merge commits and rebase merges for `main`).
This enforces one merge strategy across human and agent workflows.
@@ -514,9 +513,9 @@ After bootstrapping, verify:
- [ ] Git labels created (epic, feature, bug, task, etc.)
- [ ] Initial pre-MVP milestone created (0.0.1)
- [ ] MVP milestone reserved for release (0.1.0)
- [ ] The integration trunk is protected from direct pushes
- [ ] PRs into the integration trunk are required
- [ ] Merge method for the integration trunk is squash-only
- [ ] `main` is protected from direct pushes
- [ ] PRs into `main` are required
- [ ] Merge method for `main` is squash-only
- [ ] Quality gates run successfully
- [ ] `.env.example` exists (if project uses env vars)
- [ ] CI/CD pipeline configured (if using Woodpecker/GitHub Actions)
@@ -4,11 +4,6 @@
## Overview
> **Integration trunk:** the YAML examples in this guide use the default integration trunk `main`
> in branch conditions and version rules. A project that declares a different trunk in its root
> `AGENTS.md` (see `CONSTITUTION.md` Hard Gates) substitutes its declared trunk wherever `main`
> appears as the trunk branch.
This guide covers the canonical CI/CD pattern used across projects. The pipeline runs in Woodpecker CI and follows this flow:
```
@@ -870,7 +865,7 @@ steps:
```yaml
image: git.example.com/org/service@${IMAGE_DIGEST}
```
7. **Test on a short-lived non-trunk branch first** — open a PR and verify quality gates before merging to the integration trunk
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
8. **Verify images appear** in Gitea Packages tab after successful pipeline
## Terminal-Green Full-Step Contract
@@ -911,7 +906,7 @@ For source-code delivery, completion is not allowed at "PR opened" stage.
Required sequence:
1. Merge PR to the integration trunk (squash) via Mosaic wrapper.
1. Merge PR to `main` (squash) via Mosaic wrapper.
2. Monitor CI to terminal status:
```bash
~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>
@@ -1117,5 +1112,5 @@ If a project currently uses Verdaccio (e.g., U-Connect at `npm.uscllc.net`), fol
### Pipeline runs Docker builds on pull requests
- Verify `when` clause on Docker build steps restricts to the integration trunk (`branch: [main]` by default)
- Verify `when` clause on Docker build steps restricts to `branch: [main]`
- Pull requests should only run quality gates, not build/push images
@@ -10,10 +10,9 @@ If implementation diverges from `docs/PRD.md` or `docs/PRD.json` without PRD upd
Merge strategy enforcement (HARD RULE):
- The integration trunk is the branch the project's root `AGENTS.md` declares (default: `main`) — see `CONSTITUTION.md` Hard Gates.
- PR target for delivery is the integration trunk.
- Direct pushes to the integration trunk are prohibited.
- Merge to the integration trunk MUST be squash-only.
- PR target for delivery is `main`.
- Direct pushes to `main` are prohibited.
- Merge to `main` MUST be squash-only.
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
## Review Checklist
@@ -115,8 +114,8 @@ Use `~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md` whenever code/A
# List the issue being addressed
~/.config/mosaic/tools/git/issue-list.sh -i {issue-number}
# View the changes (diff against the integration trunk; default: main)
git diff {integration_trunk}...HEAD
# View the changes
git diff main...HEAD
```
### Providing Feedback
@@ -152,4 +151,4 @@ This pattern appears in 3 places. A shared helper would reduce duplication.
2. If changes requested, assign back to author
3. If approved, note approval in issue comments
4. For merges, ensure CI passes first
5. Merge PR to the integration trunk with squash strategy only
5. Merge PR to `main` with squash strategy only
@@ -78,7 +78,7 @@ For implementation work, you MUST run this cycle in order:
7. `commit` - commit only when the logical unit passes tests and review.
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. `push` - push immediately after queue guard passes.
10. `PR integration` - if external git provider is available, create/update PR to the integration trunk (the project's declared trunk, default `main`) and merge with required strategy via Mosaic wrappers.
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
@@ -199,7 +199,7 @@ Before running this checklist, pause and self-interrogate: did I fulfill the use
10. No unresolved blocker hidden.
11. If deployment is in scope, deployment target, release version, and post-deploy verification evidence are documented.
12. `docs/TASKS.md` status and issue/internal references are updated to match delivered work.
13. If source code changed and external provider is available: PR merged to the integration trunk (squash), with merge evidence recorded.
13. If source code changed and external provider is available: PR merged to `main` (squash), with merge evidence recorded.
14. CI/pipeline status is terminal green for the merged PR/head commit.
15. Linked external issue is closed (or internal task ref is closed when no provider exists).
16. If any of items 13-15 fail due access/tooling, report `blocked` with exact failed wrapper command and do not claim completion.
@@ -253,7 +253,7 @@ status → mission → run → repeat
- [ ] All milestone tasks in TASKS.md are `done`
- [ ] CI/pipeline green
- [ ] PR merged to the integration trunk
- [ ] PR merged to `main`
- [ ] Issues closed
- [ ] Update manifest: milestone status → completed
- [ ] Update scratchpad: session log entry
@@ -15,7 +15,7 @@ mosaic claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md th
- You MUST keep the TASKS.md file updated with agent and tasks statuses.
- You MUST keep `docs/` root clean. Reports and working artifacts MUST be stored in scoped folders (`docs/reports/`, `docs/tasks/`, `docs/releases/`, `docs/scratchpads/`).
- You MUST enforce plan/token usage budgets when provided, and adapt orchestration strategy to remain within limits.
- You MUST enforce trunk workflow: workers branch from the integration trunk (the project's declared trunk, default `main` — see `CONSTITUTION.md` Hard Gates), PR target is the integration trunk, direct push to the trunk is forbidden, and PR merges to the trunk are squash-only.
- You MUST enforce trunk workflow: workers branch from `main`, PR target is `main`, direct push to `main` is forbidden, and PR merges to `main` are squash-only.
- You MUST operate in steered-autonomy mode: human intervention is escalation-only; do not require the human to write code, review code, or manage PR/repo workflow.
- You MUST NOT declare task or issue completion until PR is merged, CI/pipeline is terminal green, and linked issue is closed (or internal TASKS ref is closed when provider is unavailable).
- Mosaic orchestration rules OVERRIDE runtime-default caution for routine push/merge/issue-close actions required by this workflow.
@@ -133,10 +133,10 @@ Milestone versioning (HARD RULE):
Branch and merge strategy (HARD RULE):
- Workers use short-lived task branches from `origin/{integration_trunk}` (default `main`).
- Worker task branches merge back via PR to the integration trunk only.
- Direct pushes to the integration trunk are prohibited.
- PR merges to the integration trunk MUST use squash merge.
- Workers use short-lived task branches from `origin/main`.
- Worker task branches merge back via PR to `main` only.
- Direct pushes to `main` are prohibited.
- PR merges to `main` MUST use squash merge.
**Available templates:**
@@ -427,7 +427,7 @@ git push
- Before merging, run queue guard:
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
- Ensure PR exists for the task branch (create/update via wrappers if needed):
`~/.config/mosaic/tools/git/pr-create.sh ... -B {integration_trunk}` (default `main`)
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
- Merge via wrapper:
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
- Wait for terminal CI status:
@@ -619,7 +619,7 @@ Construct this from the task row and pass to worker via Task tool:
## Workflow
1. Checkout branch: `git fetch origin && (git checkout {branch} || git checkout -b {branch} origin/{integration_trunk}) && git rebase origin/{integration_trunk}` ({integration_trunk} = the project's declared trunk, default `main`)
1. Checkout branch: `git fetch origin && (git checkout {branch} || git checkout -b {branch} origin/main) && git rebase origin/main`
2. Read `docs/PRD.md` or `docs/PRD.json` and align implementation with PRD requirements
3. Read the finding details from the report
4. Implement the fix following existing code patterns
@@ -637,7 +637,7 @@ Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIR
For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B {integration_trunk}`
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
@@ -994,13 +994,13 @@ mv docs/reports/qa-automation/pending/*failing-file* docs/reports/qa-automation/
---
## Merge-to-Trunk Candidate Protocol (Container Deployments)
## Merge-to-Main Candidate Protocol (Container Deployments)
If deployment is in scope and container images are used, every merge to the integration trunk MUST execute this protocol:
If deployment is in scope and container images are used, every merge to `main` MUST execute this protocol:
1. Build and push immutable candidate image tags:
- `sha-<shortsha>` (always)
- `v{base-version}-rc.{build}` (for integration-trunk merges)
- `v{base-version}-rc.{build}` (for `main` merges)
- `testing` mutable pointer to the same digest
2. Resolve and record the image digest for each service.
3. Deploy by digest to testing environment (never deploy by mutable tag alone).
+58
View File
@@ -35,6 +35,18 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# Normalize the ambient umask so directory modes are a property of the installer
# and not of whatever shell invoked it (#1236). Debian/Ubuntu ship umask 002, so
# every `mkdir -p` below yielded 0775 — and the fleet env boundary rejects any
# managed directory with `mode & 0o022`, which made `mosaic fleet init --write`
# impossible on a stock install of those distros. Fedora/RHEL ship 022 and did
# not trip it, so the product worked or did not depending on the operator's
# login shell. 022 is what this script already assumes it produces: see the
# umask note in make_durable_snapshot, which restores to the ambient value
# precisely so "every later sync copy and new framework dir" gets 0644/0755.
# Now that value is 022 rather than whatever was inherited.
umask 022
# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
# never an environment variable — so this opt-out can never sit silently
# inherited in a shell profile. See #869 Point-1 C2.
@@ -696,6 +708,52 @@ sync_framework
mkdir -p "$TARGET_DIR/memory"
mkdir -p "$TARGET_DIR/credentials"
# Three directories must be 0700, not merely not-group-writable (#1236).
# The fleet code guards them with two different masks in two different
# languages, and the strict one wins:
#
# assertPrivateManagedDirectory (fleet-reconciler.js, `mode & 0o077`)
# -> MOSAIC_HOME and MOSAIC_HOME/fleet, checked before the roster lock is
# taken, so every mutating `mosaic fleet` command dies at 0755.
# assert_private_directory (tools/fleet/start-agent-session.sh, `mode & 077`)
# -> MOSAIC_HOME/fleet/agents, checked before a pane is ever spawned.
#
# Their laxer siblings (`mode & 0o022`) accept 0755, which is why normalizing
# the umask above is necessary and not sufficient — a correct umask-022 install
# still produces 0755 and still cannot run `mosaic fleet init --write`. Say the
# strict modes outright rather than inferring them from a umask.
#
# Only these. The rest of the tree is content, stays 0755, and is only ever
# reached by the 0o022 checks, which 0755 satisfies.
chmod 700 "$TARGET_DIR" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR — 'mosaic fleet' mutations will fail as unsafe-permissions."
if [[ -d "$TARGET_DIR/fleet" ]]; then
chmod 700 "$TARGET_DIR/fleet" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR/fleet — 'mosaic fleet' mutations will fail as unsafe-permissions."
fi
# fleet/agents does not exist on a first install — the CLI creates it 0700 on
# demand. It is chmod'd here for the UPGRADE case: a tree built under umask 002
# has it at 0775, and the repair sweep below cannot rescue it, because stripping
# group/other write from 0755 leaves 0750 and `mode & 077` is still non-zero.
if [[ -d "$TARGET_DIR/fleet/agents" ]]; then
chmod 700 "$TARGET_DIR/fleet/agents" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR/fleet/agents — agent sessions will fail to start as unsafe-permissions."
fi
# credentials/ holds secrets and was never meant to be group-readable either.
# It is not on the fleet boundary, so a failure here breaks nothing — but it is
# the one directory where a silently-failed chmod leaves secrets group-readable,
# which is precisely the failure worth a line in the output.
chmod 700 "$TARGET_DIR/credentials" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR/credentials — stored secrets may be readable by other users on this host."
# Repair an existing tree. The umask above only governs directories this run
# creates, so a host installed under umask 002 before this fix keeps its 0775
# dirs through every upgrade and stays broken. Strips group/other WRITE only —
# never read or execute — so it can repair the boundary violation without
# changing who can traverse or read anything. Scoped to directories: file modes
# are the manifest's business, not this fix's.
find "$TARGET_DIR" -type d -perm /022 -exec chmod go-w {} + 2>/dev/null || true
# Reconcile contract files from defaults/ into the framework root: framework-owned
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
# copy is backed up once); user-seeded files (TOOLS) are written on first install only.
@@ -4,6 +4,14 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack
Requires=mosaic-tmux-holder.service
After=mosaic-tmux-holder.service
PartOf=mosaic-tmux-holder.service
# Do not attempt a seat before its generated env exists. `install` enables this
# unit (WantedBy=default.target) but on a roster-v2 fleet the reconciler owns the
# generated env, so between `install` and the first `apply`/`regen --write` there
# is a boot window where ExecStart would run against an absent env file and the
# launcher would fail the unit. A skipped unit is the honest state for "enabled
# but not yet configured"; systemd re-evaluates the condition on every start, so
# the seat comes up on the next start once the reconciler has written env.
ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated
[Service]
Type=oneshot
@@ -128,6 +128,14 @@ EOF
sleep 30
EOF
chmod 700 "$AGENT_BIN/mosaic"
# The launcher resolves the roster's runtime against PANE_PATH before it
# spawns anything (#1241), so the runtime this projection names has to be
# present here even though the fake `mosaic` above never execs it.
cat > "$AGENT_BIN/pi" <<'EOF'
#!/bin/sh
sleep 30
EOF
chmod 700 "$AGENT_BIN/pi"
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
@@ -225,6 +225,54 @@ else
warn "mosaic-ensure-sequential-thinking helper missing"
fi
# Fleet transport binary (#1240).
#
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
# roster the CLI scaffolds sets `transport: tmux`, but nothing in the install
# path provides tmux and, until now, nothing here noticed it was absent. On a
# greenfield host that produced a fleet which installed clean, started clean,
# and had no live seat; `mosaic fleet ps` was the operator's first and only
# signal that anything was wrong.
#
# The roster's own `transport:` is read rather than assumed, so a host that
# declares something other than tmux is told about the binary it actually
# needs. Absent a roster the check still runs — `mosaic fleet init` will
# scaffold a tmux fleet on this host, and finding out beforehand is the point.
#
# `tools/install.sh` carries a deliberately parallel check at the end of its
# summary. The two are separate because the installer must be able to say this
# before the framework's own scripts are guaranteed to be on disk; keep their
# wording in step.
fleet_declared_transport() {
local roster="$MOSAIC_HOME/fleet/roster.yaml"
local declared=""
if [[ -f "$roster" ]]; then
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
fi
printf '%s\n' "${declared:-tmux}"
}
check_fleet_transport() {
local transport
transport="$(fleet_declared_transport)"
if command -v "$transport" >/dev/null 2>&1; then
pass "Fleet transport available: $transport"
return
fi
if [[ -f "$MOSAIC_HOME/fleet/roster.yaml" ]]; then
warn "Fleet transport '$transport' is not installed — this host has a roster and no seat can launch. Install it (e.g. sudo apt-get install -y $transport), then 'mosaic fleet start'."
else
warn "Fleet transport '$transport' is not installed — 'mosaic fleet' cannot run seats here. Install it (e.g. sudo apt-get install -y $transport) before 'mosaic fleet init'."
fi
}
check_fleet_transport
# Legacy migration surfaces should no longer contain symlink trees.
legacy_paths=(
"$HOME/.claude/agent-guides"
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# Covers the #1240 fleet-transport checks in `mosaic-doctor` and in
# `tools/install.sh`.
#
# Both checks answer the same question — "can a seat actually launch on this
# host?" — from two different places, because the installer has to be able to
# answer it before the framework's own scripts are guaranteed to be on disk.
# Two implementations of one rule is exactly the shape that drifts, so this
# harness drives BOTH, in one file, from the same table of cases.
#
# The functions are extracted from the shipped scripts rather than copied here.
# A test that carries its own copy of the logic is a test that keeps passing
# after the shipped copy changes — the failure mode this whole change is about.
# Extraction is by exact function header and a closing brace in column one; if
# either script is reshaped so that stops matching, the extraction yields
# nothing and this fails loudly instead of silently measuring an empty string.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
# framework/tools/_scripts -> framework/tools -> framework -> mosaic -> packages -> repo
INSTALLER=$(cd -- "$SCRIPT_DIR/../../../../.." && pwd)/tools/install.sh
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
[ -f "$INSTALLER" ] || fail "missing install.sh at $INSTALLER"
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
# The cases below run with PATH set to a directory that deliberately does not
# contain a shell, and a PATH assignment on a command also governs how that
# command is looked up — so bash has to be named absolutely or it becomes the
# thing that is missing.
BASH_BIN=$(command -v bash) || fail "host is missing 'bash'"
# A PATH containing exactly the utilities these functions use and nothing else.
# The absent-transport cases are only meaningful on a PATH where the transport
# is genuinely unresolvable, and this host (like most) has tmux in /usr/bin —
# so the system path cannot be part of the path under test.
FAKE_BIN="$ROOT/bin"
mkdir -p "$FAKE_BIN"
for utility in sed head tr awk; do
utility_path=$(command -v "$utility") || fail "host is missing '$utility'"
ln -s "$utility_path" "$FAKE_BIN/$utility"
done
if PATH="$FAKE_BIN" command -v tmux >/dev/null 2>&1; then
fail "'tmux' is resolvable on the minimal test path; absent-transport cases are not measurable"
fi
# Extract a function by its exact header, up to a closing brace in column one.
extract_function() {
local source_file="$1"
local function_name="$2"
local destination="$3"
awk -v name="$function_name" '
$0 == name "() {" { collecting = 1 }
collecting { print }
collecting && $0 == "}" { exit }
' "$source_file" > "$destination"
grep -qF "$function_name() {" "$destination" ||
fail "could not extract '$function_name' from $source_file — has it been renamed or reshaped?"
# An unterminated extraction would be a syntax error the moment it is sourced,
# but saying so here names the cause instead of leaving a bash parse error.
bash -n "$destination" ||
fail "extracted '$function_name' does not parse; the closing brace was probably not found"
}
extract_function "$DOCTOR" fleet_declared_transport "$ROOT/doctor-declared.sh"
extract_function "$DOCTOR" check_fleet_transport "$ROOT/doctor-check.sh"
extract_function "$INSTALLER" check_fleet_transport "$ROOT/installer-check.sh"
# Build a MOSAIC_HOME, optionally with a roster declaring a transport.
make_home() {
local home="$ROOT/$1"
local declared="${2-}"
rm -rf "$home"
mkdir -p "$home"
if [ -n "$declared" ]; then
mkdir -p "$home/fleet"
cat > "$home/fleet/roster.yaml" <<EOF
version: 2
generation: 1
transport: $declared
agents: []
EOF
fi
printf '%s\n' "$home"
}
# Run the doctor's check against a given home and path, capturing which
# reporter the check chose. The real `pass` prints only under `--verbose` and
# the real `warn` always prints; these stubs make both unconditional on
# purpose, because what is under test is the severity the check selects, not
# whether the default verbosity happens to show it. A check that warned where
# it should pass would otherwise be invisible here.
run_doctor_check() {
local home="$1"
local path="$2"
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
set -euo pipefail
warn() { echo "[WARN] $*"; }
pass() { echo "[OK] $*"; }
MOSAIC_HOME="$1"
source "$2"
source "$3"
check_fleet_transport
' _ "$home" "$ROOT/doctor-declared.sh" "$ROOT/doctor-check.sh" 2>&1
}
run_installer_check() {
local home="$1"
local path="$2"
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
set -euo pipefail
warn() { echo "[WARN] $*"; }
C="" RESET=""
MOSAIC_HOME="$1"
source "$2"
check_fleet_transport
' _ "$home" "$ROOT/installer-check.sh" 2>&1
}
# A transport that exists. Named tmux because that is what the default roster
# declares; the binary never runs, it only has to resolve.
PRESENT_BIN="$ROOT/present-bin"
mkdir -p "$PRESENT_BIN"
printf '#!/usr/bin/env bash\nexit 0\n' > "$PRESENT_BIN/tmux"
chmod +x "$PRESENT_BIN/tmux"
PATH_WITH_TMUX="$PRESENT_BIN:$FAKE_BIN"
# ── absent, no roster ────────────────────────────────────────────────────────
# Nothing has been configured yet, so the honest thing to point at is `init`.
home=$(make_home no-roster)
output=$(run_doctor_check "$home" "$FAKE_BIN")
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn when tmux was absent"
echo "$output" | grep -qF 'tmux' || fail "doctor warning did not name the transport"
echo "$output" | grep -qF 'mosaic fleet init' || fail "doctor did not point a rosterless host at init"
output=$(run_installer_check "$home" "$FAKE_BIN")
echo "$output" | grep -qF '[WARN]' || fail "installer did not warn when tmux was absent"
echo "$output" | grep -qF 'reports success and no seat comes up' ||
fail "installer warning did not say what the missing transport actually breaks"
# ── absent, roster present ───────────────────────────────────────────────────
# A configured fleet that cannot launch is a stronger statement than a
# hypothetical one, and the message says so.
home=$(make_home with-roster tmux)
output=$(run_doctor_check "$home" "$FAKE_BIN")
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn with a roster present and tmux absent"
echo "$output" | grep -qF 'roster' || fail "doctor did not mention the roster it found"
echo "$output" | grep -qF 'mosaic fleet start' || fail "doctor did not point a configured host at start"
# ── present ──────────────────────────────────────────────────────────────────
# Silence from the installer, and a pass (not a warning) from the audit.
for home_name in no-roster with-roster; do
home="$ROOT/$home_name"
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
if echo "$output" | grep -qF '[WARN]'; then
fail "doctor warned about the transport while tmux was present ($home_name)"
fi
echo "$output" | grep -qF '[OK]' || fail "doctor did not record a pass with tmux present ($home_name)"
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
if [ -n "$output" ]; then
fail "installer was not silent with tmux present ($home_name): $output"
fi
done
# ── the roster declares something other than tmux ────────────────────────────
# The roster is read, not assumed. A host that declares a different transport
# is told about the binary it actually needs, and never about tmux — being sent
# to install the wrong package is worse than no advice at all.
home=$(make_home other-transport zellij)
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
echo "$output" | grep -qF 'zellij' || fail "doctor ignored the roster's declared transport"
if echo "$output" | grep -qF 'tmux'; then
fail "doctor named tmux for a host whose roster declares zellij"
fi
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
echo "$output" | grep -qF 'zellij' || fail "installer ignored the roster's declared transport"
if echo "$output" | grep -qF 'tmux'; then
fail "installer named tmux for a host whose roster declares zellij"
fi
# ── a quoted or trailing-comment transport value ─────────────────────────────
# YAML permits both and neither is exotic; a check that installs `tmux"` or
# reads `tmux # default` as a binary name would send the operator nowhere.
home=$(make_home quoted-transport '"tmux" # the only transport today')
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
echo "$output" | grep -qF '[OK] Fleet transport available: tmux' ||
fail "doctor did not parse a quoted/commented transport value: $output"
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
if [ -n "$output" ]; then
fail "installer did not parse a quoted/commented transport value: $output"
fi
echo "ok - fleet transport checks (mosaic-doctor + install.sh)"
@@ -286,6 +286,36 @@ _build_runtime_bin_prefix() {
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
# #1241. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a cleared
# environment. A binary missing from *that* path is a pane that dies in under a
# second, inside a session nobody is attached to, with its diagnostic scrolled
# into a pane tmux then destroys. Resolve both here, before any effect, where
# the failure is still attributable to the thing that caused it.
#
# `mosaic yolo <runtime>` runs checkRuntime(runtime) and the binary it looks for
# is named exactly like the runtime, so resolving the runtime name is the same
# question the pane will ask a moment later — asked while an operator can still
# see the answer.
_resolve_in_pane_path() {
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
}
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
# 64 (EX_USAGE) rejections above, which mean the projection itself was bad —
# here the data is fine and the host is not ready. Callers tell the individual
# cases apart by `code=`, the same way fail_env's many codes share exit 64.
fail_launch() {
local code="$1"
shift
echo "ERROR: agent launch aborted: code=${code} agent=${AGENT_NAME} $*" >&2
exit 69
}
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
_resolve_in_pane_path "$required_binary" >/dev/null ||
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
done
_ensure_claude_workdir_trusted() {
local workdir="$1"
local resolved
@@ -384,6 +414,19 @@ if [ -n "$PANE_PID" ]; then
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
elif _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
# #1241. Session present, no pane PID after a second of retries. Whatever this
# is, it is not a seat an operator can use, so it is not a success either.
fail_launch pane-pid-unresolved \
"tmux reports the session but no pane PID after 5 attempts"
else
echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2
# #1241. This branch used to print a WARNING about the heartbeat sidecar and
# exit 0. It is not a heartbeat problem: tmux destroys a session when its pane
# command exits, so an absent session one second after new-session means the
# runtime died on startup. Reporting it as success is what let `fleet start`
# return 0 over three dead panes — the launcher knew, and said the wrong thing
# at the wrong severity to the wrong layer.
fail_launch pane-did-not-survive \
"the pane exited immediately and tmux destroyed the session;" \
"run 'mosaic yolo ${MOSAIC_AGENT_RUNTIME}' in ${MOSAIC_AGENT_WORKDIR} to see why"
fi
@@ -23,8 +23,26 @@ index=0
if [ "${args[0]:-}" = -L ]; then index=2; fi
case "${args[$index]:-}" in
has-session)
# The holder always answers. MOSAIC_TEST_HELD_SESSIONS lets a case add
# other targets that should answer too — without it there is no way to
# model "tmux still reports the session" for a non-holder agent, and the
# launcher's pane-pid-unresolved branch is unreachable from this harness.
#
# A listed target answers only AFTER new-session, because the launcher asks
# this question twice about the same name: once before launching, where a
# yes means "already running, nothing to do, exit 0", and once after, where
# a yes means "the session survived". A shim that answered yes to both
# would short-circuit at the first and never reach the branch under test —
# it would look like coverage and measure the idempotency path instead.
for argument in "${args[@]}"; do
[ "$argument" = '=_holder:0.0' ] && exit 0
case " ${MOSAIC_TEST_HELD_SESSIONS:-} " in
*" $argument "*)
if tr '\0' '\n' < "${MOSAIC_TEST_TMUX_CALLS:?}" | grep -qxF new-session; then
exit 0
fi
;;
esac
done
exit 1
;;
@@ -62,6 +80,30 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
SHIM
chmod +x "$FAKE_BIN/mosaic"
# The runtime the rosters below name. The launcher resolves it against PANE_PATH
# before spawning (#1241), so it has to exist somewhere the pane would find it —
# not merely on the launcher's own PATH.
printf '#!/usr/bin/env bash\nexit 0\n' > "$FAKE_BIN/pi"
chmod +x "$FAKE_BIN/pi"
# PANE_PATH is derived partly from `npm config get prefix`. Left to the real npm
# it would splice whatever the host has installed into the path under test, and
# the missing-binary cases below would pass or fail by accident of the machine.
cat > "$FAKE_BIN/npm" <<'SHIM'
#!/usr/bin/env bash
printf '%s\n' "${MOSAIC_TEST_NPM_PREFIX:-/nonexistent}"
SHIM
chmod +x "$FAKE_BIN/npm"
# PANE_PATH always ends in the system path. A host that installs these there can
# not measure the missing-binary cases at all, and a green run would mean
# nothing — so say so instead of passing.
for host_binary in mosaic pi; do
if PATH=/usr/local/bin:/usr/bin:/bin command -v "$host_binary" >/dev/null 2>&1; then
fail "host provides '$host_binary' in the system path; missing-binary cases are not measurable here"
fi
done
write_generated() {
local home="$1"
local agent="$2"
@@ -81,6 +123,19 @@ MOSAIC_TMUX_SOCKET=mosaic-test
EOF
chmod 600 "$home/fleet/agents/$agent.env.generated"
mkdir -p "$home/work"
install_pane_binaries "$home"
}
# `$PANE_HOME/.npm-global/bin` is one of the prefixes the launcher folds into
# PANE_PATH, so this is the pane's own view of "installed", distinct from the
# launcher's PATH. Tests that need a binary *absent* remove it from here.
install_pane_binaries() {
local pane_home="$1"
mkdir -p "$pane_home/.npm-global/bin"
local binary
for binary in mosaic pi; do
ln -sf "$FAKE_BIN/$binary" "$pane_home/.npm-global/bin/$binary"
done
}
run_start() {
@@ -88,6 +143,7 @@ run_start() {
local agent="$2"
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \
MOSAIC_TEST_HOME="$home" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$home" "$START" "$agent"
@@ -98,7 +154,10 @@ run_start() {
HOME_VALID="$ROOT/valid"
AGENT_VALID="coder0"
write_generated "$HOME_VALID" "$AGENT_VALID"
run_start "$HOME_VALID" "$AGENT_VALID"
# A live pane PID is part of what "valid launch" means. Until #1241 this case
# ran with none, so the suite's one success path was itself a dead pane the
# launcher reported as fine.
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_VALID" "$AGENT_VALID"
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
@@ -245,6 +304,13 @@ PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
# This case does not go through run_start, so its pane binaries come from
# MOSAIC_RUNTIME_BIN=$FAKE_BIN in the env.local written above — not from the
# symlinks install_pane_binaries planted under the generated home, which this
# launcher never consults because HOME here is the trusted parent. That is a
# legitimate resolution path, but it means dropping MOSAIC_RUNTIME_BIN from
# this case on the belief that the symlinks cover it would break the #1241
# binary check rather than exercise it.
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
BASH_ENV="$PANE_BASH_ENV" \
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
@@ -258,6 +324,7 @@ PATH="$PANE_STALE_PATH" \
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_TEST_EXECUTE_PANE=1 \
"MOSAIC_TEST_PANE_PID=$$" \
"$START" coder-pane-boundary
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
@@ -392,6 +459,75 @@ echo "$interaction_policy_args" | grep -qF 'new-session' && \
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
fail "interaction pinned-policy check did not follow strict parsing"
# #1241. The pane runs `mosaic yolo <runtime>` against PANE_PATH. A binary
# missing from that path is a launch failure, and it has to be named before the
# session is created — after it, the diagnostic dies with the pane.
assert_missing_pane_binary_rejected() {
local binary="$1"
local home="$ROOT/missing-$binary"
local agent="coder-missing-$binary"
write_generated "$home" "$agent"
rm -f "$home/.npm-global/bin/$binary"
: > "$TMUX_CALLS"
local output
if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$home" "$agent" 2>&1); then
fail "launch succeeded with '$binary' absent from the pane PATH"
fi
echo "$output" | grep -qF 'code=missing-binary' || fail "missing '$binary' diagnostic missing"
echo "$output" | grep -qF "'$binary'" || fail "missing-binary diagnostic did not name $binary"
if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then
fail "launcher created a session it knew would die ($binary absent)"
fi
}
assert_missing_pane_binary_rejected mosaic
assert_missing_pane_binary_rejected pi
# #1241. tmux destroys a session when its pane command exits, so no pane PID a
# second after new-session means the runtime died on startup. This used to be a
# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it
# then reported a fleet that was not running.
: > "$TMUX_CALLS"
HOME_DEAD_PANE="$ROOT/dead-pane"
write_generated "$HOME_DEAD_PANE" "coder-dead-pane"
if output=$(MOSAIC_TEST_PANE_PID='' run_start "$HOME_DEAD_PANE" coder-dead-pane 2>&1); then
fail "launcher reported success over a pane that did not survive"
fi
echo "$output" | grep -qF 'code=pane-did-not-survive' || fail "dead-pane diagnostic missing"
if echo "$output" | grep -qiF 'heartbeat'; then
fail "dead pane is still being reported as a heartbeat-sidecar problem"
fi
tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session || \
fail "dead-pane case did not reach the launch it is measuring"
# #1241, the other way a pane fails. Above, tmux destroyed the session and
# has-session said so. Here the session is still there and no PID comes back
# after the retries — a different fault (the pane is alive but unusable, or
# tmux is answering inconsistently) that an operator has to be told apart from
# a runtime that died on startup.
#
# This case exists because the branch that handles it shipped with nothing able
# to reach it: the shim answered has-session only for the holder, so every
# non-holder agent landed in the session-is-gone branch no matter what. A
# defensive branch nothing exercises is the same shape as the bug this whole
# change is about, one layer down.
: > "$TMUX_CALLS"
HOME_NO_PID="$ROOT/pane-no-pid"
write_generated "$HOME_NO_PID" "coder-no-pid"
if output=$(MOSAIC_TEST_PANE_PID='' MOSAIC_TEST_HELD_SESSIONS='=coder-no-pid:0.0' \
run_start "$HOME_NO_PID" coder-no-pid 2>&1); then
fail "launcher reported success over a session with no resolvable pane PID"
fi
echo "$output" | grep -qF 'code=pane-pid-unresolved' || \
fail "session-present/no-PID was not reported as pane-pid-unresolved: $output"
if echo "$output" | grep -qF 'code=pane-did-not-survive'; then
fail "a session tmux still reports was diagnosed as a destroyed session"
fi
if echo "$output" | grep -qiF 'heartbeat'; then
fail "an unresolvable pane PID is still being reported as a heartbeat-sidecar problem"
fi
# Exact stop derives the socket exclusively from the validated generated
# projection and ignores an ambient socket supplied by the caller.
: > "$TMUX_CALLS"
@@ -32,10 +32,6 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
# --- single-suite directories: unmeasured in CI ---
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
# --- naming-boundary files the strict test-*.sh prefix cannot even name ---
# (#1017: three independent censuses handled the microtest file three different
+3 -2
View File
@@ -24,8 +24,9 @@
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
"test": "pnpm run test:vitest && pnpm run test:framework-shell",
"test:vitest": "pnpm run build && vitest run --passWithNoTests",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -15,6 +15,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FileConfigAdapter } from '../config/file-adapter.js';
import { seedFleetIdentityDefaults } from './fleet-first-start-identity.js';
import { composeContract } from './launch.js';
/**
@@ -319,6 +320,7 @@ describe('composeContract — overlay composer', () => {
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-self';
expect(seedFleetIdentityDefaults(installedHome)).toEqual(['SOUL.md', 'USER.md']);
const composed = composeContract('pi', installedHome);
expect(composed).toContain(sourceTools);
@@ -332,6 +334,49 @@ describe('composeContract — overlay composer', () => {
}
});
it('refuses a fleet USER.md replacement symlink at the point of composition', () => {
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: exact-user-seat',
' runtime: pi',
' class: worker',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-user-seat';
process.env['MOSAIC_AGENT_CLASS'] = 'worker';
writeFileSync(join(fixture.home, 'defaults', 'SOUL.md'), '# Generic soul\n');
writeFileSync(join(fixture.home, 'defaults', 'USER.md'), '# Generic user\n');
expect(seedFleetIdentityDefaults(fixture.home)).toEqual(['SOUL.md']);
const userPath = join(fixture.home, 'USER.md');
const external = join(fixture.root, 'attacker-user.md');
writeFileSync(external, 'UNSAFE-REPLACEMENT-USER-CONTENT\n');
rmSync(userPath);
symlinkSync(external, userPath);
expect(() => composeContract('pi', fixture.home)).toThrow(
`fleet identity installed is unavailable or unsafe: ${userPath}`,
);
expect(readFileSync(external, 'utf8')).toBe('UNSAFE-REPLACEMENT-USER-CONTENT\n');
});
it('preserves tolerant standalone composition when optional USER.md is unreadable', () => {
const userPath = join(fixture.home, 'USER.md');
rmSync(userPath);
mkdirSync(userPath);
const out = composeContract('pi', fixture.home);
expect(out).toContain(AGENTS);
expect(out).not.toContain('# User Profile');
});
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'never injects installed TOOLS.md through a target symlink for %s',
(runtime) => {
@@ -0,0 +1,182 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
lstatSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
linkIdentityContractNoClobber,
seedFleetIdentityDefaults,
} from './fleet-first-start-identity.js';
const roots: string[] = [];
const DEFAULT_SOUL = '# Generic soul\n';
const DEFAULT_USER = '# Generic user\n';
function writeFixture(path: string, content: string | Buffer, mode: number = 0o600): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, content, { mode });
chmodSync(path, mode);
}
function createMosaicHome(): string {
const root = mkdtempSync(join(tmpdir(), 'mosaic-identity-seed-'));
roots.push(root);
const mosaicHome = join(root, 'home', '.config', 'mosaic');
writeFixture(join(mosaicHome, 'defaults', 'SOUL.md'), DEFAULT_SOUL);
writeFixture(join(mosaicHome, 'defaults', 'USER.md'), DEFAULT_USER);
return mosaicHome;
}
function temporarySeeds(mosaicHome: string): string[] {
return readdirSync(mosaicHome).filter((entry) => entry.includes('.fleet-seed-'));
}
afterEach((): void => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe('linkIdentityContractNoClobber', () => {
it('returns false and preserves a destination that already exists', () => {
const mosaicHome = createMosaicHome();
const source = join(mosaicHome, 'source.tmp');
const destination = join(mosaicHome, 'destination.md');
writeFixture(source, 'candidate\n');
writeFixture(destination, 'operator\n');
expect(linkIdentityContractNoClobber(source, destination)).toBe(false);
expect(readFileSync(destination, 'utf8')).toBe('operator\n');
});
it('does not misclassify an unexpected link failure as a concurrent winner', () => {
const mosaicHome = createMosaicHome();
const missingSource = join(mosaicHome, 'missing.tmp');
expect(() =>
linkIdentityContractNoClobber(missingSource, join(mosaicHome, 'destination.md')),
).toThrow();
});
});
describe('seedFleetIdentityDefaults', () => {
it('publishes complete owner-private default snapshots', () => {
const mosaicHome = createMosaicHome();
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual(['SOUL.md', 'USER.md']);
for (const [entry, expected] of [
['SOUL.md', DEFAULT_SOUL],
['USER.md', DEFAULT_USER],
] as const) {
const path = join(mosaicHome, entry);
expect(readFileSync(path, 'utf8')).toBe(expected);
expect(statSync(path).mode & 0o777).toBe(0o600);
}
expect(temporarySeeds(mosaicHome)).toEqual([]);
});
it('preserves an existing regular contract byte-for-byte and mode-for-mode', () => {
const mosaicHome = createMosaicHome();
const customSoul = '# Operator-owned soul\n';
writeFixture(join(mosaicHome, 'SOUL.md'), customSoul, 0o640);
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual(['USER.md']);
expect(readFileSync(join(mosaicHome, 'SOUL.md'), 'utf8')).toBe(customSoul);
expect(statSync(join(mosaicHome, 'SOUL.md')).mode & 0o777).toBe(0o640);
});
it('is idempotent after both installed contracts exist', () => {
const mosaicHome = createMosaicHome();
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual(['SOUL.md', 'USER.md']);
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual([]);
expect(temporarySeeds(mosaicHome)).toEqual([]);
});
it('validates every required source before publishing any destination', () => {
const mosaicHome = createMosaicHome();
const missing = join(mosaicHome, 'defaults', 'USER.md');
rmSync(missing);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity default is unavailable or unsafe: ${missing}`,
);
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false);
});
it('fails closed when the configured Mosaic home is not a directory', () => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-identity-invalid-home-'));
roots.push(root);
const mosaicHome = join(root, 'mosaic-home');
writeFixture(mosaicHome, 'not a directory\n');
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity installed is unavailable or unsafe: ${join(mosaicHome, 'SOUL.md')}`,
);
});
it('refuses a symlinked default instead of following it', () => {
const mosaicHome = createMosaicHome();
const source = join(mosaicHome, 'defaults', 'SOUL.md');
rmSync(source);
symlinkSync(join(mosaicHome, 'defaults', 'USER.md'), source);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity default is unavailable or unsafe: ${source}`,
);
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(false);
});
it('refuses an existing symlinked destination without replacing it', () => {
const mosaicHome = createMosaicHome();
const destination = join(mosaicHome, 'SOUL.md');
symlinkSync(join(mosaicHome, 'defaults', 'SOUL.md'), destination);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity installed is unavailable or unsafe: ${destination}`,
);
expect(lstatSync(destination).isSymbolicLink()).toBe(true);
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false);
});
it.each(['SOUL.md', 'USER.md'] as const)(
'rejects a dangling %s destination before publishing its counterpart',
(entry) => {
const mosaicHome = createMosaicHome();
const destination = join(mosaicHome, entry);
const counterpart = join(mosaicHome, entry === 'SOUL.md' ? 'USER.md' : 'SOUL.md');
symlinkSync(join(mosaicHome, 'missing-identity-target'), destination);
expect(existsSync(destination)).toBe(false);
expect(lstatSync(destination).isSymbolicLink()).toBe(true);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity installed is unavailable or unsafe: ${destination}`,
);
expect(lstatSync(destination).isSymbolicLink()).toBe(true);
expect(existsSync(counterpart)).toBe(false);
},
);
it('rejects an oversized source before publishing a partial identity', () => {
const mosaicHome = createMosaicHome();
const source = join(mosaicHome, 'defaults', 'USER.md');
writeFixture(source, Buffer.alloc(256 * 1024 + 1, 0x61));
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity default is unavailable or unsafe: ${source}`,
);
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false);
});
});
@@ -0,0 +1,120 @@
import { randomBytes } from 'node:crypto';
import { linkSync, lstatSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { readRegularFileSecure } from '../fleet/secure-file.js';
const MAX_IDENTITY_CONTRACT_BYTES = 256 * 1024;
export const FLEET_IDENTITY_DEFAULTS = ['SOUL.md', 'USER.md'] as const;
function isFilesystemError(error: unknown, code: string): boolean {
return error instanceof Error && 'code' in error && error.code === code;
}
/** @internal Publish a complete temporary file without replacing any path. */
export function linkIdentityContractNoClobber(source: string, destination: string): boolean {
try {
linkSync(source, destination);
return true;
} catch (error: unknown) {
if (isFilesystemError(error, 'EEXIST')) return false;
throw error;
}
}
function unsafeIdentityError(kind: 'default' | 'installed', path: string, error: unknown): Error {
const reason = error instanceof Error ? error.message : String(error);
return new Error(`fleet identity ${kind} is unavailable or unsafe: ${path} (${reason})`);
}
function readIdentityContract(
mosaicHome: string,
path: string,
kind: 'default' | 'installed',
): Buffer {
try {
return readRegularFileSecure(path, {
root: mosaicHome,
maxBytes: MAX_IDENTITY_CONTRACT_BYTES,
}).content;
} catch (error: unknown) {
throw unsafeIdentityError(kind, path, error);
}
}
function installedEntryExists(path: string): boolean {
try {
lstatSync(path);
return true;
} catch (error: unknown) {
if (isFilesystemError(error, 'ENOENT')) return false;
throw unsafeIdentityError('installed', path, error);
}
}
/** Secure fleet point-of-use read for a top-level identity contract. */
export function readInstalledIdentityContractAtPointOfUse(
mosaicHome: string,
entry: (typeof FLEET_IDENTITY_DEFAULTS)[number],
): Buffer {
const configuredPath = join(mosaicHome, entry);
try {
// Preserve the launcher's established support for a symlinked Mosaic home,
// while pinning this read to the resolved directory. O_NOFOLLOW still
// rejects replacement of the identity file itself (or any child ancestor).
const canonicalHome = realpathSync(mosaicHome);
return readRegularFileSecure(join(canonicalHome, entry), {
root: canonicalHome,
maxBytes: MAX_IDENTITY_CONTRACT_BYTES,
}).content;
} catch (error: unknown) {
throw unsafeIdentityError('installed', configuredPath, error);
}
}
/**
* Seed the generic identity base required by unattended fleet launches.
*
* Exact seat identity remains roster-owned and is injected later by the
* runtime composer. Each destination appears atomically through a hard link to
* a complete owner-private temporary file; a concurrent first seat may win the
* link without allowing either process to overwrite operator content.
*/
export function seedFleetIdentityDefaults(mosaicHome: string): string[] {
const snapshots = new Map<(typeof FLEET_IDENTITY_DEFAULTS)[number], Buffer>();
for (const entry of FLEET_IDENTITY_DEFAULTS) {
const destination = join(mosaicHome, entry);
if (installedEntryExists(destination)) {
readIdentityContract(mosaicHome, destination, 'installed');
continue;
}
const source = join(mosaicHome, 'defaults', entry);
snapshots.set(entry, readIdentityContract(mosaicHome, source, 'default'));
}
const seeded: string[] = [];
for (const [entry, content] of snapshots) {
const destination = join(mosaicHome, entry);
const temporary = join(
mosaicHome,
`.${entry}.fleet-seed-${process.pid.toString()}-${randomBytes(6).toString('hex')}`,
);
let temporaryCreated = false;
try {
writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
temporaryCreated = true;
if (linkIdentityContractNoClobber(temporary, destination)) {
seeded.push(entry);
} else {
readIdentityContract(mosaicHome, destination, 'installed');
}
} finally {
if (temporaryCreated) rmSync(temporary, { force: true });
}
}
for (const entry of FLEET_IDENTITY_DEFAULTS) {
readIdentityContract(mosaicHome, join(mosaicHome, entry), 'installed');
}
return seeded;
}
@@ -0,0 +1,323 @@
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
/**
* #1237: the v1-only commands (`ps`, `install`, `install-systemd`, `add`,
* `remove`) rejected a roster-v2 fleet outright, so a greenfield v2 box could
* never get its units placed. These tests pin the three behaviours that fix
* gives it, and the two it deliberately does NOT give it.
*
* The load-bearing negative is that `install` on v2 writes no generated env:
* the reconciler owns that file through projectRosterV2AgentGeneratedEnv, and a
* second writer here necessarily through the v1 mapping is exactly the
* drift the #791 single-SSOT invariant exists to prevent.
*/
const rosterV2 = `
version: 2
generation: 4
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: /srv/mosaic
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: /srv/mosaic
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
- name: coder1
alias: Coder 1
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: medium
tool_policy: code
working_directory: /srv/other
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`;
let tempHome: string | undefined;
const savedHome = process.env.HOME;
const savedMosaicHome = process.env.MOSAIC_HOME;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
else process.env.MOSAIC_HOME = savedMosaicHome;
if (tempHome) await rm(tempHome, { recursive: true, force: true });
tempHome = undefined;
});
/**
* A HOME with a roster-v2 fleet and nothing else the greenfield shape, before
* anything has been installed, applied or started.
*/
async function v2Home(): Promise<string> {
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
process.env.HOME = tempHome;
delete process.env.MOSAIC_HOME;
const mosaicHome = join(tempHome, '.config', 'mosaic');
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
await mkdir(join(mosaicHome, directory), { recursive: true, mode: 0o700 });
}
await writeFile(join(mosaicHome, 'fleet', 'roster.yaml'), rosterV2, { mode: 0o600 });
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
mode: 0o600,
});
return mosaicHome;
}
/**
* Stands in for a box where nothing is running: every systemctl and tmux probe
* fails the way it does before the holder has ever started. `ps` must survive
* this it is the command an operator reaches for to find out *why* there is
* no seat, so it has to report the emptiness rather than fail on it.
*/
const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult> => {
if (command === 'tmux') {
return { stdout: '', stderr: 'no server running on /tmp/tmux-1000/mosaic-fleet', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 1 };
};
function program(runner: CommandRunner = greenfieldRunner): Command {
const result = new Command();
result.exitOverride();
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
return result;
}
function capture(): string[] {
const lines: string[] = [];
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
lines.push(value);
});
return lines;
}
async function exists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
describe('mosaic fleet ps — roster v2', (): void => {
it('lists every v2 agent on a greenfield box with nothing running, and does not throw', async (): Promise<void> => {
await v2Home();
const lines = capture();
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
).resolves.toBeDefined();
const rows = JSON.parse(lines.join('\n')) as {
name: string;
runtime: string;
alias?: string;
paneAlive: boolean;
source: string;
}[];
expect(rows.map((row) => row.name).sort()).toEqual(['coder0', 'coder1']);
// The v2 roster's per-agent fields must survive the read model, not be
// flattened into defaults.
expect(rows.every((row) => row.runtime === 'pi')).toBe(true);
expect(rows.find((row) => row.name === 'coder0')?.alias).toBe('Coder 0');
// Nothing is running, and that is a report, not an error.
expect(rows.every((row) => row.paneAlive === false)).toBe(true);
expect(rows.every((row) => row.source === 'roster')).toBe(true);
expect(process.exitCode ?? 0).toBe(0);
});
});
describe('mosaic fleet install — roster v2', (): void => {
it('places the tool files and unit templates', async (): Promise<void> => {
const mosaicHome = await v2Home();
capture();
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
).resolves.toBeDefined();
// Units live in the systemd user dir, not under the Mosaic home.
const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
for (const unit of [
'mosaic-tmux-holder.service',
'[email protected]',
'[email protected]',
]) {
expect(await exists(join(systemdUserDir, unit))).toBe(true);
}
const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
expect(await exists(launcher)).toBe(true);
expect((await stat(launcher)).mode & 0o777).toBe(0o755);
});
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
const mosaicHome = await v2Home();
capture();
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
const agentDir = join(mosaicHome, 'fleet', 'agents');
expect(await readdir(agentDir)).toEqual([]);
});
it('tells the operator which command does own the env', async (): Promise<void> => {
await v2Home();
const lines = capture();
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
expect(lines.join('\n')).toContain('mosaic fleet apply');
});
});
describe('[email protected]', (): void => {
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
/** The single `ConditionPathExists=` value declared by the unit template. */
async function conditionPath(): Promise<string> {
const unit = await readFile(unitPath, 'utf8');
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
expect(matches).toHaveLength(1);
return matches[0]!.slice('ConditionPathExists='.length).trim();
}
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
// The pairing that makes "install writes no env" safe: install enables the
// unit (WantedBy=default.target) but does not start it, so without this
// condition a reboot between `install` and the first `apply` would run
// ExecStart against an absent env file and fail every seat unit.
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated');
});
/**
* The two halves of the guard's *effect*, which no assertion on the literal
* string can cover on its own.
*
* Measured end to end on a real box (canary, 2026-08-16) rather than inferred:
* with the condition, `systemctl --user start mosaic-agent@<name>` on an agent
* with no generated env returns rc=0, `Result=success`, `ConditionResult=no`,
* and journals "skipped, unmet condition check". With the condition removed by
* drop-in and nothing else changed, the same start returns rc=1,
* `Result=exit-code`, `ExecMainStatus=64`, and the unit enters `failed`.
*
* systemd is not available in this suite, so these two tests pin the parts
* that can drift in code: the condition naming a *different* file than the one
* the fleet actually writes, and the launcher quietly becoming tolerant of an
* absent env either of which turns the condition into decoration while the
* literal-string assertion above still passes.
*/
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
const mosaicHome = await v2Home();
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0');
// The path an installed fleet actually places for this agent.
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'));
});
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
await v2Home();
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
// Exactly what ExecStart runs, against the state the condition exists to
// catch: unit enabled, reconciler has not written env yet.
const launched = await new Promise<{ code: number | null; stderr: string }>((settle) => {
const child = execFile(
'/bin/bash',
[
'--noprofile',
'--norc',
join(tempHome!, '.config', 'mosaic', 'tools', 'fleet', 'start-agent-session.sh'),
'coder0',
],
{ env: { HOME: tempHome!, MOSAIC_AGENT_NAME: 'coder0', PATH: '/usr/bin:/bin' } },
(_error, _stdout, stderr) => {
settle({ code: child.exitCode, stderr });
},
);
});
expect(launched.code).not.toBe(0);
expect(launched.stderr).toContain('missing-file');
});
});
describe('mosaic fleet add / remove — roster v2', (): void => {
it('add refuses, and names the two-step v2 sequence instead of inventing defaults', async (): Promise<void> => {
await v2Home();
await expect(
program().parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder2',
'--runtime',
'pi',
'--class',
'code',
]),
).rejects.toThrow(/mosaic fleet create[\s\S]*mosaic fleet apply/);
});
it('remove refuses, and names delete plus apply', async (): Promise<void> => {
await v2Home();
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
).rejects.toThrow(/mosaic fleet delete coder1[\s\S]*mosaic fleet apply/);
});
// Note: this one passes on the unmodified tree too — there `remove` throws in
// the v1 parser, before it can touch anything. It is a regression guard on the
// ordering of the new guard clause, not evidence that the fix works.
it('refuses BEFORE mutating the roster', async (): Promise<void> => {
const mosaicHome = await v2Home();
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
const before = await readFile(rosterPath, 'utf8');
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
).rejects.toThrow();
expect(await readFile(rosterPath, 'utf8')).toBe(before);
});
});
+116 -8
View File
@@ -34,6 +34,7 @@ export {
resolveInstalledFleetRosterPath,
} from '../fleet/fleet-roster-v1.js';
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
import { parseRosterV2 } from '../fleet/roster-v2.js';
import {
registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps,
@@ -820,7 +821,7 @@ export function buildEnableLingerCommand(user: string): string[] {
*/
export async function enableFleetUnits(
runner: CommandRunner,
roster: FleetRoster,
roster: { readonly agents: readonly { readonly name: string }[] },
opts: { enable?: boolean },
): Promise<void> {
if (opts.enable === false) {
@@ -1527,7 +1528,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot);
const roster = await loadRosterForCommand(cmd);
// Unit enablement needs agent names only, so it reads either version.
const roster = await loadRosterReadModel(cmd);
await enableFleetUnits(runner, roster, opts);
});
@@ -1537,7 +1539,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot);
const roster = await loadRosterForCommand(cmd);
// Unit enablement needs agent names only, so it reads either version.
const roster = await loadRosterReadModel(cmd);
await enableFleetUnits(runner, roster, opts);
});
@@ -1688,7 +1691,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.action(async (opts: { json?: boolean }) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const roster = await loadRosterForCommand(cmd);
// ps only reads, so it takes the version-agnostic read model rather than
// the v1 parser, which rejects a v2 roster outright.
const roster = await loadRosterReadModel(cmd);
const { tenant_id, host } = getDefaultTenantAndHost();
const nowMs = Date.now();
@@ -1908,6 +1913,16 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
start: boolean;
},
) => {
if (await usesRosterV2ControlPlane(cmd)) {
// command.error, not a bare throw: this is operator guidance, and a
// bare throw reaches the top level uncaught and prints it under a Node
// stack trace. Measured on canary — the message is the whole point of
// the refusal, so it has to arrive readable.
cmd.error(rosterV2MutationGuidance('add', 'create', name), {
code: 'fleet.roster-v2',
exitCode: 1,
});
}
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
throw new Error(
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
@@ -1973,6 +1988,12 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.description('Remove an agent from the fleet roster')
.option('--keep-files', 'Skip deleting env and heartbeat files')
.action(async (name: string, opts: { keepFiles?: boolean }) => {
if (await usesRosterV2ControlPlane(cmd)) {
cmd.error(rosterV2MutationGuidance('remove', 'delete', name), {
code: 'fleet.roster-v2',
exitCode: 1,
});
}
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
@@ -2331,7 +2352,9 @@ export function registerFleetAgentCommands(
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
const roster = await loadRosterForCommand(cmd);
// Read model first: every file this function places is roster-independent, and
// the v1 parser would reject a v2 roster before any of them were written.
const roster = await loadRosterReadModel(cmd);
await ensureFleetHolderIdentity(activePaths.mosaicHome);
await mkdir(activePaths.fleetToolsDir, { recursive: true });
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
@@ -2391,16 +2414,30 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
join(activePaths.systemdUserDir, '[email protected]'),
);
for (const agent of roster.agents) {
// On roster v2 the reconciler owns the generated env: `apply` writes it and
// `regen` rebuilds it, both from projectRosterV2AgentGeneratedEnv. Writing it
// here too — necessarily through the v1 mapping — would be the third writer of
// one file and would break the #791 single-SSOT invariant. So v2 gets the tool
// files and the units, and nothing else.
if (roster.version === 2) {
console.log(
`Installed fleet tools and systemd units for ${roster.agents.length} agent(s). ` +
`Generated env is owned by the reconciler on roster v2 — run: mosaic fleet apply --expected-generation <n>`,
);
return;
}
const v1Roster = await loadRosterForCommand(cmd);
for (const agent of v1Roster.agents) {
await writeAgentEnvironmentProjection({
mosaicHome: activePaths.mosaicHome,
agentEnvDir: activePaths.agentEnvDir,
agentName: agent.name,
generated: generateAgentEnvValues(roster, agent),
generated: generateAgentEnvValues(v1Roster, agent),
});
}
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
console.log(`Installed fleet files for ${v1Roster.agents.length} agent(s).`);
}
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
@@ -2427,6 +2464,77 @@ async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
);
}
/**
* `add`/`remove` and `create`/`delete` are not two spellings of one operation.
* The v1 pair edits the roster *and* drives systemd; the v2 pair is documented
* as changing desired state "without runtime actions", leaving convergence to
* `apply`. `add` also collects four fields where a v2 agent requires eleven, so
* routing it to `create` would mean inventing provider, alias, reasoning and
* tool-policy defaults on the operator's behalf. Refusing with the real command
* is honest; silently guessing an agent's provider is not.
*/
function rosterV2MutationGuidance(
v1Command: 'add' | 'remove',
v2Command: 'create' | 'delete',
name: string,
): string {
const target = v2Command === 'delete' ? ` ${name}` : '';
return (
`mosaic fleet ${v1Command} does not operate on a roster-v2 fleet. ` +
`Roster v2 separates desired state from convergence:\n` +
` 1. mosaic fleet ${v2Command}${target} --expected-generation <current> ` +
`${v2Command === 'create' ? "--agent '<json>' " : ''}` +
`(edits the roster only)\n` +
` 2. mosaic fleet apply --expected-generation <new> (converges systemd and tmux)\n` +
`Read the current generation with: mosaic fleet status`
);
}
/**
* The read-only fields shared by roster v1 and v2, for the commands that only
* ever *read* the roster (`ps`, and unit enablement inside `install`).
*
* This is deliberately NOT a v2v1 downshift. A downshifted `FleetRoster` would
* be accepted by `generateAgentEnvValues`, and that would make a third writer of
* `fleet/agents/<name>.env.generated` through the v1 mapping breaking the
* #791 single-SSOT invariant that {@link projectRosterV2AgentGeneratedEnv} is
* documented to hold. Keeping the read model this small makes that misuse
* impossible: there is nothing here to write a roster or an env file back from.
*/
interface FleetRosterReadModel {
readonly version: 1 | 2;
readonly tmux: { readonly socketName: string; readonly holderSession: string };
readonly agents: readonly {
readonly name: string;
readonly alias?: string;
readonly runtime: string;
}[];
}
/** Reads either roster version into the shared read-only view. */
async function loadRosterReadModel(cmd: Command): Promise<FleetRosterReadModel> {
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
if (!(await usesRosterV2ControlPlane(cmd))) {
const v1 = await loadRosterAtPath(cmd, path);
return {
version: 1,
tmux: { socketName: v1.tmux.socketName, holderSession: v1.tmux.holderSession },
agents: v1.agents,
};
}
try {
const v2 = parseRosterV2(await readFleetRosterText(path), 'yaml');
return {
version: 2,
tmux: { socketName: v2.tmux.socketName, holderSession: v2.tmux.holderSession },
agents: v2.agents,
};
} catch (error) {
reportFleetRosterConfigurationError(cmd, error);
}
}
async function loadRosterFromAgentCommand(
command: Command,
mosaicHomeOverride?: string,
@@ -0,0 +1,393 @@
import { spawn, spawnSync, type SpawnSyncReturns } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
const CLI_PATH = fileURLToPath(new URL('../../dist/cli.js', import.meta.url));
const DEFAULT_SOUL_PATH = fileURLToPath(
new URL('../../framework/defaults/SOUL.md', import.meta.url),
);
const DEFAULT_USER_PATH = fileURLToPath(
new URL('../../framework/defaults/USER.md', import.meta.url),
);
interface GreenfieldFixture {
readonly root: string;
readonly home: string;
readonly mosaicHome: string;
readonly binDir: string;
readonly capturePath: string;
}
interface AsyncLaunchResult {
readonly status: number | null;
readonly signal: NodeJS.Signals | null;
readonly stdout: string;
readonly stderr: string;
}
const fixtures: string[] = [];
function writeFixture(path: string, content: string, mode: number = 0o600): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, content, { encoding: 'utf8', mode });
chmodSync(path, mode);
}
function createGreenfieldFixture(): GreenfieldFixture {
const root = mkdtempSync(join(tmpdir(), 'mosaic-first-start-'));
fixtures.push(root);
const home = join(root, 'home');
const mosaicHome = join(home, '.config', 'mosaic');
const binDir = join(root, 'bin');
const capturePath = join(root, 'runtime-boundary.json');
mkdirSync(binDir, { recursive: true, mode: 0o700 });
writeFixture(join(mosaicHome, 'AGENTS.md'), '# Agent dispatcher\n');
writeFixture(join(mosaicHome, 'runtime', 'pi', 'RUNTIME.md'), '# Pi runtime\n');
writeFixture(join(mosaicHome, 'defaults', 'SOUL.md'), readFileSync(DEFAULT_SOUL_PATH, 'utf8'));
writeFixture(join(mosaicHome, 'defaults', 'USER.md'), readFileSync(DEFAULT_USER_PATH, 'utf8'));
writeFixture(
join(mosaicHome, 'fleet', 'roster.yaml'),
`version: 1
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: ~
runtimes:
pi:
reset_command: /new
agents:
- name: unattended-seat
runtime: pi
class: worker
`,
);
writeFixture(
join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'),
'#!/usr/bin/env bash\nexit 0\n',
0o755,
);
writeFixture(
join(mosaicHome, 'tools', 'lease-broker', 'launch-runtime.py'),
`#!/usr/bin/env python3
import json
import os
import pathlib
import sys
pathlib.Path(os.environ["MOSAIC_TEST_RUNTIME_CAPTURE"]).write_text(
json.dumps({"argv": sys.argv[1:]}), encoding="utf-8"
)
`,
0o755,
);
// checkRuntime() must find Pi, while the fake broker boundary prevents this
// executable from running or making a provider call.
writeFixture(join(binDir, 'pi'), '#!/usr/bin/env bash\nexit 97\n', 0o755);
return { root, home, mosaicHome, binDir, capturePath };
}
function launchEnvironment(
fixture: GreenfieldFixture,
capturePath: string,
fleet: boolean = true,
agentName: string = 'unattended-seat',
agentClass: string = 'worker',
): NodeJS.ProcessEnv {
return {
HOME: fixture.home,
MOSAIC_HOME: fixture.mosaicHome,
...(fleet
? {
MOSAIC_AGENT_NAME: agentName,
MOSAIC_AGENT_CLASS: agentClass,
}
: {}),
MOSAIC_TEST_RUNTIME_CAPTURE: capturePath,
PATH: `${fixture.binDir}:/usr/bin:/bin`,
};
}
function launchSync(
fixture: GreenfieldFixture,
options: {
readonly capturePath?: string;
readonly fleet?: boolean;
readonly agentName?: string;
readonly agentClass?: string;
} = {},
): SpawnSyncReturns<string> {
const capturePath = options.capturePath ?? fixture.capturePath;
return spawnSync(process.execPath, [CLI_PATH, 'yolo', 'pi'], {
cwd: fixture.root,
encoding: 'utf8',
input: '',
timeout: 10_000,
env: launchEnvironment(
fixture,
capturePath,
options.fleet ?? true,
options.agentName ?? 'unattended-seat',
options.agentClass ?? 'worker',
),
});
}
function launchAsync(fixture: GreenfieldFixture, capturePath: string): Promise<AsyncLaunchResult> {
return new Promise<AsyncLaunchResult>((resolve, reject): void => {
const child = spawn(process.execPath, [CLI_PATH, 'yolo', 'pi'], {
cwd: fixture.root,
env: launchEnvironment(fixture, capturePath),
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string): void => {
stdout += chunk;
});
child.stderr.on('data', (chunk: string): void => {
stderr += chunk;
});
child.on('error', reject);
child.on('close', (status: number | null, signal: NodeJS.Signals | null): void => {
resolve({ status, signal, stdout, stderr });
});
child.stdin.end();
});
}
function outputOf(result: { readonly stdout: string; readonly stderr: string }): string {
return `${result.stdout}${result.stderr}`;
}
function assertPrivateDefaultSeeds(fixture: GreenfieldFixture): void {
const soul = join(fixture.mosaicHome, 'SOUL.md');
const user = join(fixture.mosaicHome, 'USER.md');
expect(readFileSync(soul, 'utf8')).toBe(readFileSync(DEFAULT_SOUL_PATH, 'utf8'));
expect(readFileSync(user, 'utf8')).toBe(readFileSync(DEFAULT_USER_PATH, 'utf8'));
expect(statSync(soul).mode & 0o777).toBe(0o600);
expect(statSync(user).mode & 0o777).toBe(0o600);
}
function capturedArguments(path: string): string[] {
const capture = JSON.parse(readFileSync(path, 'utf8')) as { argv: string[] };
return capture.argv;
}
afterEach((): void => {
for (const root of fixtures.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
describe('fleet unattended first start (#1264)', () => {
it('reaches the runtime boundary without a TTY or identity wizard on a clean install', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture);
const output = outputOf(result);
expect(result.error, output).toBeUndefined();
expect(result.status, output).toBe(0);
expect(output).toContain('Initialized unattended fleet identity defaults: SOUL.md, USER.md');
expect(output).not.toContain('Running setup wizard');
expect(output).not.toContain('What would you like to do?');
expect(existsSync(fixture.capturePath), output).toBe(true);
assertPrivateDefaultSeeds(fixture);
const argv = capturedArguments(fixture.capturePath);
expect(argv).toContain('--runtime');
expect(argv.join('\n')).toContain('Agent/session: `unattended-seat`');
expect(argv.join('\n')).toContain('Role/class: `worker`');
});
it('preserves existing operator identity bytes without requiring defaults', () => {
const fixture = createGreenfieldFixture();
const customSoul = '# Operator soul\nNever replace this.\n';
const customUser = '# Operator user\nNever replace this either.\n';
writeFixture(join(fixture.mosaicHome, 'SOUL.md'), customSoul, 0o640);
writeFixture(join(fixture.mosaicHome, 'USER.md'), customUser, 0o600);
rmSync(join(fixture.mosaicHome, 'defaults'), { recursive: true, force: true });
const first = launchSync(fixture);
const secondCapture = join(fixture.root, 'runtime-boundary-second.json');
const second = launchSync(fixture, { capturePath: secondCapture });
expect(first.status, outputOf(first)).toBe(0);
expect(second.status, outputOf(second)).toBe(0);
expect(readFileSync(join(fixture.mosaicHome, 'SOUL.md'), 'utf8')).toBe(customSoul);
expect(readFileSync(join(fixture.mosaicHome, 'USER.md'), 'utf8')).toBe(customUser);
expect(statSync(join(fixture.mosaicHome, 'SOUL.md')).mode & 0o777).toBe(0o640);
expect(existsSync(fixture.capturePath)).toBe(true);
expect(existsSync(secondCapture)).toBe(true);
});
it('seeds only the missing identity contract and leaves a custom SOUL byte-exact', () => {
const fixture = createGreenfieldFixture();
const customSoul = '# Exact custom soul bytes\n';
writeFixture(join(fixture.mosaicHome, 'SOUL.md'), customSoul, 0o640);
const result = launchSync(fixture);
expect(result.status, outputOf(result)).toBe(0);
expect(outputOf(result)).toContain('Initialized unattended fleet identity defaults: USER.md');
expect(readFileSync(join(fixture.mosaicHome, 'SOUL.md'), 'utf8')).toBe(customSoul);
expect(statSync(join(fixture.mosaicHome, 'SOUL.md')).mode & 0o777).toBe(0o640);
expect(readFileSync(join(fixture.mosaicHome, 'USER.md'), 'utf8')).toBe(
readFileSync(DEFAULT_USER_PATH, 'utf8'),
);
});
it('fails closed without a wizard or partial seed when a required default is missing', () => {
const fixture = createGreenfieldFixture();
const missingDefault = join(fixture.mosaicHome, 'defaults', 'USER.md');
rmSync(missingDefault);
const result = launchSync(fixture);
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('unattended fleet identity initialization failed');
expect(output).toContain(missingDefault);
expect(output).not.toContain('Running setup wizard');
expect(output).not.toContain('What would you like to do?');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
});
it('rejects a symlinked identity default without following it or prompting', () => {
const fixture = createGreenfieldFixture();
const soulDefault = join(fixture.mosaicHome, 'defaults', 'SOUL.md');
rmSync(soulDefault);
symlinkSync(DEFAULT_SOUL_PATH, soulDefault);
const result = launchSync(fixture);
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain(`fleet identity default is unavailable or unsafe: ${soulDefault}`);
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
});
it('refuses an unknown ambient fleet name before seeding or prompting', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentName: 'not-in-the-roster' });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('canonical fleet identity is unavailable');
expect(output).toContain('Agent "not-in-the-roster" is not in the fleet roster');
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
});
it('refuses a mismatched ambient fleet class before seeding or prompting', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentClass: 'reviewer' });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('Refusing split identity authority');
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
});
it.each(['', ' ', '\t'])(
'refuses explicit blank ambient fleet class %j before seeding',
(agentClass: string) => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentClass });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('Refusing split identity authority');
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
},
);
it.each([' unattended-seat', 'unattended-seat ', ''])(
'refuses non-exact ambient fleet name %j before seeding',
(agentName: string) => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentName });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain(
'MOSAIC_AGENT_NAME must be a non-empty exact roster name with no surrounding whitespace',
);
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
},
);
it('keeps the interactive wizard path for a standalone launch', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { fleet: false });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('[mosaic] SOUL.md not found. Running setup wizard...');
expect(output).toContain('What would you like to do?');
expect(output).toContain('[mosaic] Setup failed. Run: mosaic wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
});
it('allows concurrent no-TTY seats to initialize the same defaults without clobber or residue', async () => {
const fixture = createGreenfieldFixture();
const captures = Array.from({ length: 4 }, (_, index) =>
join(fixture.root, `runtime-boundary-${index.toString()}.json`),
);
const results = await Promise.all(
captures.map(
async (capturePath): Promise<AsyncLaunchResult> => launchAsync(fixture, capturePath),
),
);
for (const result of results) {
expect(result.status, outputOf(result)).toBe(0);
expect(result.signal, outputOf(result)).toBeNull();
expect(outputOf(result)).not.toContain('Running setup wizard');
}
assertPrivateDefaultSeeds(fixture);
expect(captures.every((capturePath) => existsSync(capturePath))).toBe(true);
expect(
readdirSync(fixture.mosaicHome).filter((entry) => entry.includes('.fleet-seed-')),
).toEqual([]);
});
});
+62 -10
View File
@@ -30,6 +30,10 @@ import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
import {
readInstalledIdentityContractAtPointOfUse,
seedFleetIdentityDefaults,
} from './fleet-first-start-identity.js';
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -230,8 +234,55 @@ function checkRuntime(cmd: string): void {
}
}
function assertAmbientFleetClassMatches(canonicalName: string, canonicalClass: string): void {
const configuredClass = process.env['MOSAIC_AGENT_CLASS'];
if (configuredClass === undefined) return;
const ambientClass = canonicalizeRoleClass(configuredClass).canonicalClass;
if (ambientClass !== canonicalClass) {
throw new Error(
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalName}" resolves to "${canonicalClass}". Refusing split identity authority.`,
);
}
}
function checkSoul(): void {
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
const fleetAgentName = process.env['MOSAIC_AGENT_NAME'];
if (fleetAgentName !== undefined) {
try {
if (fleetAgentName.length === 0 || fleetAgentName !== fleetAgentName.trim()) {
throw new Error(
'MOSAIC_AGENT_NAME must be a non-empty exact roster name with no surrounding whitespace',
);
}
const fleetIdentity = resolveFleetIdentity(MOSAIC_HOME, fleetAgentName);
if (!fleetIdentity.ok || !fleetIdentity.identity) {
throw new Error(
`canonical fleet identity is unavailable: ${fleetIdentity.error ?? 'exact roster member was not resolved'}`,
);
}
assertAmbientFleetClassMatches(
fleetIdentity.identity.member.name,
fleetIdentity.identity.member.className,
);
const seeded = seedFleetIdentityDefaults(MOSAIC_HOME);
if (seeded.length > 0) {
console.log(
`[mosaic] Initialized unattended fleet identity defaults: ${seeded.join(', ')}. Exact seat identity remains roster-owned.`,
);
}
return;
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : String(error);
console.error(`[mosaic] ERROR: unattended fleet identity initialization failed: ${reason}`);
console.error(
'[mosaic] Repair the named fleet roster, launch identity, installed contract, or shipped default, then retry.',
);
process.exit(1);
}
}
if (!existsSync(soulPath)) {
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
@@ -532,26 +583,27 @@ For required push/merge/issue-close/release actions, execute without routine con
parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8'));
// USER.md (+ USER.local.md operator overlay, appended directly under the
// profile its base owns).
const user = readOptional(join(mosaicHome, 'USER.md'));
// profile its base owns). Fleet first start is Linux/systemd-owned and uses
// the no-follow reader at point of use. Standalone launches retain the
// portable tolerant path used on macOS and other supported hosts.
const fleetAgentName = process.env['MOSAIC_AGENT_NAME'];
const user =
fleetAgentName === undefined
? readOptional(join(mosaicHome, 'USER.md'))
: readInstalledIdentityContractAtPointOfUse(mosaicHome, 'USER.md').toString('utf8');
if (user) parts.push('\n\n# User Profile\n\n' + user);
const userLocal = readOptional(join(mosaicHome, 'USER.local.md'));
if (userLocal.trim()) {
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
}
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
const fleetIdentity = resolveFleetIdentity(mosaicHome, fleetAgentName);
if (!fleetIdentity.ok) {
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
}
const canonicalMember = fleetIdentity.identity?.member;
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass;
if (ambientClass !== canonicalMember.className) {
throw new Error(
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
);
}
if (canonicalMember) {
assertAmbientFleetClassMatches(canonicalMember.name, canonicalMember.className);
}
// TOOLS.md
+8 -6
View File
@@ -1,4 +1,3 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, platform } from 'node:os';
@@ -22,15 +21,18 @@ export function getShellProfilePath(): string | null {
const shell = detectShell();
switch (shell) {
// Both of these deliberately avoid the interactive-only rc files.
// Debian's default .bashrc returns early for non-interactive shells, so a
// PATH line appended to it never runs for `bash -lc`, systemd units, or
// agent seats — an install could report success and still leave `mosaic`
// unreachable. .profile is read by login shells and sources .bashrc for
// interactive ones, so one line covers both; .zshenv is zsh's equivalent.
case 'zsh': {
const zdotdir = process.env['ZDOTDIR'] ?? home;
return join(zdotdir, '.zshrc');
return join(zdotdir, '.zshenv');
}
case 'bash': {
const bashrc = join(home, '.bashrc');
if (existsSync(bashrc)) return bashrc;
case 'bash':
return join(home, '.profile');
}
case 'fish':
return join(home, '.config', 'fish', 'config.fish');
default:
+293 -15
View File
@@ -309,6 +309,124 @@ require_cmd() {
fi
}
# True if any shell rc file already puts $1 on PATH.
#
# Each file is tested for existence first and grepped one at a time, rather than
# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file
# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q
# 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 a duplicate PATH
# line on every single install.
path_entry_exists() {
local dir="$1" rc_file
for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do
if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then
return 0
fi
done
return 1
}
# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this
# process. An `export` here reaches only the installer; every directory the
# install leaves behind has to be written down somewhere a later shell reads.
#
# Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for
# non-interactive shells, so a PATH line appended to the bottom of it is
# unreachable to `bash -lc`, to systemd units, and to every agent seat — the
# exact consumers that need these binaries. ~/.profile is read by login shells
# and Debian's .profile sources .bashrc for interactive ones, so a single line
# there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc.
#
# $1 = directory to add, $2 = label for the comment line.
# Returns 1 (having warned) if the profile could not be written.
persist_on_path() {
local dir="$1" label="$2" profile
if path_entry_exists "$dir"; then
return 0
fi
if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
profile="$HOME/.zshenv"
else
profile="$HOME/.profile"
fi
# Probe writability in a subshell. A redirection failure on a special built-in
# aborts the shell it runs in, so it has to be a child; and the redirection on
# the subshell is what silences the "Permission denied" the shell would
# otherwise print ahead of our own message.
if ! ( : >>"$profile" ) 2>/dev/null; then
warn "$dir is not on your PATH and $profile could not be written"
dim " Add to your shell rc: export PATH=\"$dir:\$PATH\""
return 1
fi
{
echo ""
echo "# $label"
echo "export PATH=\"$dir:\$PATH\""
} >>"$profile"
ok "Added $dir to PATH in $profile"
return 0
}
# Persist $PREFIX/bin on PATH instead of only warning about it.
#
# The warning it replaces was the last step of an otherwise successful install,
# so the installer reported success and left `mosaic: command not found` — an
# unattended install had no operator to read the advice and act on it.
ensure_prefix_on_path() {
if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then
return
fi
if path_entry_exists "$PREFIX/bin"; then
warn "$PREFIX/bin is in your shell profile but not in this shell"
elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then
return
fi
dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)"
}
# Fleet transport binary (#1240).
#
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
# roster the CLI scaffolds sets `transport: tmux`, but nothing in this script
# provides tmux and, until now, nothing in it mentioned tmux at all. A
# greenfield host came out of this installer able to install a fleet, start a
# fleet, and run no seat — the operator's first signal was `mosaic fleet ps`.
#
# Not a `require_cmd`: tmux is required by the fleet, not by mosaic. Plenty of
# hosts install this to run `mosaic claude` and will never scaffold a roster,
# and failing their install over a binary they do not need would be wrong. It
# is a warning that names precisely what it blocks.
#
# `tools/_scripts/mosaic-doctor` carries a deliberately parallel check, so the
# same host state gets the same answer from an audit as from an install. They
# are separate implementations because this one has to work before the
# framework's scripts are guaranteed to be on disk; keep their wording in step.
check_fleet_transport() {
local transport=tmux
local roster="$MOSAIC_HOME/fleet/roster.yaml"
local declared=""
if [[ -f "$roster" ]]; then
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
[[ -n "$declared" ]] && transport="$declared"
fi
command -v "$transport" &>/dev/null && return 0
warn "Fleet transport '$transport' is not installed."
echo " The Mosaic fleet runs its agent seats inside $transport. Without it,"
echo " ${C}mosaic fleet start${RESET} reports success and no seat comes up."
echo " Install it before using the fleet, e.g. ${C}sudo apt-get install -y $transport${RESET}"
echo " (this does not affect ${C}mosaic claude${RESET} or the other single-runtime commands)."
}
installed_cli_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
@@ -516,8 +634,175 @@ install_next_cli_from_registry() {
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
}
# ─── node bootstrap ───────────────────────────────────────────────────────────
#
# Nothing on a greenfield host installs Node.js, yet this installer and the CLI
# it installs both hard-require it. Measured on a clean Debian 13 image: the
# installer stopped at `require_cmd node` with "Required command not found" and
# nothing was installed, with no hint of how to proceed.
#
# Inlined rather than factored into a sibling file on purpose: this script is
# fetched standalone by curl and has nothing to source.
#
# No-op when a suitable node is already on PATH, so it never fights an
# operator's nvm/fnm/distro node.
NODE_ROOT="${MOSAIC_NODE_ROOT:-$HOME/.mosaic/node}"
NODE_BOOTSTRAP_VERSION="${MOSAIC_NODE_VERSION:-v22.23.2}"
NODE_MIN_MAJOR="${MOSAIC_NODE_MIN_MAJOR:-20}"
NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}"
# Major version of the node at $1, or empty if it will not run.
node_major_of() {
local candidate="$1" version
version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0
printf '%s' "${version%%.*}"
}
node_is_suitable() {
local major
major="$(node_major_of "$1")"
[[ -n "$major" ]] && [[ "$major" -ge "$NODE_MIN_MAJOR" ]]
}
install_node() {
local node_os node_arch tarball release_url work_dir extracted target node_bin
case "$(uname -s)" in
Linux) node_os="linux" ;;
Darwin) node_os="darwin" ;;
*) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
esac
# Linux here means glibc. Node's official linux-x64 build is dynamically
# linked against glibc, so on musl (Alpine) the binary will not exec — but it
# fails visibly: node_is_suitable rejects it and ensure_node exits with
# "install Node.js manually". No silent breakage, just a wasted download.
# A musl host needs the unofficial build, which is out of scope here.
case "$(uname -m)" in
x86_64|amd64) node_arch="x64" ;;
aarch64|arm64) node_arch="arm64" ;;
armv7l) node_arch="armv7l" ;;
*) fail "Unsupported architecture '$(uname -m)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
esac
# .tar.gz rather than the smaller .tar.xz: gzip is universally present, xz is
# not, and a minimal image is exactly the case this exists to handle.
tarball="node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}.tar.gz"
release_url="${NODE_DIST_BASE}/${NODE_BOOTSTRAP_VERSION}"
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-XXXXXX")"
info "Installing Node.js $NODE_BOOTSTRAP_VERSION ($node_os-$node_arch) to $NODE_ROOT"
if ! curl -fsSL "${release_url}/${tarball}" -o "$work_dir/$tarball"; then
fail "Download failed: ${release_url}/${tarball}"
rm -rf "$work_dir"; return 1
fi
# Trust assumption, stated so nobody has to infer it: this verifies INTEGRITY
# (the tarball matches the manifest), not AUTHENTICITY (the manifest is
# genuinely Node's). The only thing establishing that is TLS to
# $NODE_DIST_BASE. Node publishes SHASUMS256.txt.sig signed by its release
# keys and we do not check it, which is on par with nvm but means pointing
# MOSAIC_NODE_DIST_BASE at an untrusted mirror has no signature backstop.
# Tracked as a hardening follow-up (raised by scooby in the #1229 review).
if ! curl -fsSL "${release_url}/SHASUMS256.txt" -o "$work_dir/SHASUMS256.txt"; then
fail "Could not fetch SHASUMS256.txt; refusing to install an unverified runtime."
rm -rf "$work_dir"; return 1
fi
# Keep only our artifact's line, so a missing entry is an error not a pass.
if ! grep " ${tarball}\$" "$work_dir/SHASUMS256.txt" >"$work_dir/expected.sha256"; then
fail "$tarball has no entry in SHASUMS256.txt; refusing to install."
rm -rf "$work_dir"; return 1
fi
if ! (cd "$work_dir" && verify_sha256 expected.sha256); then
fail "Checksum mismatch for $tarball; refusing to install."
rm -rf "$work_dir"; return 1
fi
ok "Checksum verified"
tar xzf "$work_dir/$tarball" -C "$work_dir"
extracted="$work_dir/node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}"
if [[ ! -x "$extracted/bin/node" ]]; then
fail "Extracted archive has no bin/node"
rm -rf "$work_dir"; return 1
fi
mkdir -p "$NODE_ROOT"
target="$NODE_ROOT/$NODE_BOOTSTRAP_VERSION"
rm -rf "$target.incoming"
mv "$extracted" "$target.incoming"
rm -rf "$target"
mv "$target.incoming" "$target"
ln -sfn "$NODE_BOOTSTRAP_VERSION" "$NODE_ROOT/current"
rm -rf "$work_dir"
node_bin="$NODE_ROOT/current/bin"
if ! node_is_suitable "$node_bin/node"; then
fail "Installed node at $node_bin/node did not run"
return 1
fi
export PATH="$node_bin:$PATH"
ok "Node.js $(node -v) installed with npm $(npm -v 2>/dev/null || echo '?')"
return 0
}
# Make the Mosaic-managed Node reachable from the next shell as well as this
# one. Measured on a greenfield canary run: without this the install finished
# rc=0, wrote $PREFIX/bin to ~/.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() {
persist_on_path "$NODE_ROOT/current/bin" "Mosaic-managed Node.js" || true
}
ensure_node() {
if command -v node &>/dev/null && node_is_suitable node; then
return 0
fi
# A previous run may have installed one that is not on this shell's PATH.
if node_is_suitable "$NODE_ROOT/current/bin/node"; then
export PATH="$NODE_ROOT/current/bin:$PATH"
persist_node_on_path
return 0
fi
if [[ "${MOSAIC_SKIP_NODE_BOOTSTRAP:-0}" == "1" ]]; then
fail "No suitable Node.js and MOSAIC_SKIP_NODE_BOOTSTRAP=1; refusing to download."
echo " Install Node.js >= $NODE_MIN_MAJOR yourself, then re-run this script."
exit 1
fi
require_cmd curl
require_cmd tar
# sha256sum on Linux, shasum on macOS. Verification is not optional: without a
# checksum this would install an unauthenticated runtime.
if command -v sha256sum &>/dev/null; then
verify_sha256() { sha256sum -c --status "$1"; }
elif command -v shasum &>/dev/null; then
verify_sha256() { shasum -a 256 -c --status "$1"; }
else
fail "sha256sum or shasum required to verify the Node.js download"
exit 1
fi
if ! install_node; then
fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run."
exit 1
fi
persist_node_on_path
}
# ─── preflight ────────────────────────────────────────────────────────────────
ensure_node
require_cmd node
require_cmd npm
@@ -682,11 +967,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ensure_monorepo
install_cli_from_source
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
ensure_prefix_on_path
elif is_next_registry_lane; then
info "Next mode — trying fast npm @next install from ${REGISTRY}"
if install_next_cli_from_registry; then
@@ -699,11 +980,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
ensure_prefix_on_path
else
if [[ -z "$LATEST" ]]; then
warn "Could not reach registry at $REGISTRY — skipping npm CLI."
@@ -721,11 +998,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ok "CLI is at or ahead of registry ($CURRENT$LATEST)."
fi
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
ensure_prefix_on_path
fi
fi
@@ -870,6 +1143,11 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
ok "Done."
fi
# Fleet readiness (#1240). Runs in both normal and --check mode: "what is the
# state of this host" is exactly the question --check is asked, and a host that
# cannot run a seat should not have to discover it from `fleet ps`.
check_fleet_transport
} # end main
main "$@"