Compare commits

..
Author SHA1 Message Date
fred b7a6179a58 installer: harden the Node provisioning path against its own inputs
ci/woodpecker/pr/ci Pipeline is pending approval
Answers the review on #1228. Each item below was measured against the pre-change
code, and where the review's stated consequence did not reproduce, that is recorded
rather than repeated.

BLOCKER -- `mapfile` is a Bash 4 builtin and macOS ships Bash 3.2, which this
installer supports (node_platform names Darwin). newest_matching_file was therefore
unavailable on macOS, and an empty answer is exactly what sends the uninstaller down
its delete-the-destination branch. The lookup no longer renders candidates as text at
all: the glob output is compared in-shell by mtime, via a stat helper that probes for
GNU -c vs BSD -f once. That removes the Bash 4 dependency, the `ls | head` SIGPIPE
failure, and the newline-splitting bug together, because all three came from turning
filenames into lines.

The function now distinguishes three outcomes instead of two: found, nothing matched,
and could-not-tell. Callers act destructively on the answer, so the third case had to
stop being indistinguishable from the second. The uninstaller leaves the file in place
on an unanswerable lookup, and the manifest builder refuses to record a null backup it
cannot vouch for.

HIGH -- writing ~/.profile does not reach the shells that matter. A bash login shell
reads the first of .bash_profile / .bash_login / .profile that exists and never looks
at the rest, so on a host with either of the first two the entry was a silent no-op; a
non-interactive remote zsh reads .zshenv and neither .zprofile nor .zshrc, which is
what the previous version wrote; and a systemd --user unit reads no shell file at all,
which is how a Mosaic agent seat starts. All four are now covered, with .bash_profile
and .bash_login appended to only when they already exist -- creating one would itself
start shadowing .profile. The systemd case is an environment.d drop-in.

MEDIUM -- the checksum lookup interpolated the filename into a grep pattern. A Node
tarball name is mostly dots, and a dot matches any character, so a manifest line for a
different-but-regex-equivalent name was accepted as this file's checksum. Confirmed
against the old function: it accepted the decoy. Filenames are now compared exactly,
every line is read so a duplicate entry is refused rather than silently resolved, and
the digest must look like a SHA-256.

MEDIUM -- the PATH line is executed by every future shell that reads the file, and the
directory was interpolated unescaped. A path containing shell syntax is now refused
with a message instead of written.

MEDIUM -- the idempotence check was an unanchored substring match, so a commented-out
example of the same export made the installer skip the real entry. Reproduced against
the old function, and now anchored with grep -Fqx.

MEDIUM -- MOSAIC_NODE_DIST accepted any scheme. https:// and file:// only. The
narrower point in the review stands and is not fixed by this: when the dist is
overridden, the tarball and the checksum that vouches for it come from the same place,
so the gate is integrity and not authenticity.

HIGH, with a correction -- MOSAIC_NODE_VERSION is now validated before it becomes a
path, but the review's specific consequence does not reproduce. `rm -rf` on a path
ending in `..` is refused by rm itself, and a traversal version mangles the download
URL so the run dies at curl long before the removal. Both were measured. The check is
defence in depth and a clearer error, not a demonstrated hole being closed.

Also removed a second `| head -1` in node_resolve_version, the same SIGPIPE shape as
the one this PR already fixed, and the index result is validated before it becomes a
path.

Tests. The review was right that several existing cases passed on the unpatched code.
The version-selection case now lists a higher major first and an older release of the
right major after the right answer, so "first entry" and "last match" both fail it.
The PATH case starts a real login shell and asks it to resolve node, rather than
grepping for text the installer just wrote. The checksum-failure case asserts nothing
survives, including the staging directory. New cases cover the empty manifest, the
regex-equivalent decoy, the duplicate entry, the invalid version, the non-https dist,
the shell-syntax path, the commented-out profile line, the .bash_profile shadow, and
the environment.d drop-in. Each new case was run against the pre-change installer:
the decoy, the commented-out line, the .bash_profile shadow and environment.d all go
red there, which is the evidence that they test something.

Bash 3.2 cannot be executed here, so the portability guard is a lint over install.sh
for Bash 4 syntax. It is a weaker instrument than a run and is not claimed otherwise
-- but every Bash 4 construct that has broken macOS in this file was added by someone
who was not running it there either.

test:installer passes.
2026-08-15 13:46:48 -05:00
Jason WoltjeandClaude Opus 5 06c714ddf3 installer: stop newest_matching_file from dying on SIGPIPE
ci/woodpecker/pr/ci Pipeline is pending approval
newest_matching_file() piped `ls -1t` into `head -1`. Under `set -o pipefail`
head closes the pipe after the first line, ls dies on SIGPIPE, and the function
returns 141 having printed nothing. Its callers assign it at top level under
`set -e`, so that 141 aborts the install.

It takes roughly 1600 matching names to fill the pipe buffer, which is why this
has sat unnoticed: with two or three files the old code is correct. Measured on
origin/next with 5001 matches, the function returns 141 and prints nothing; with
this change it returns rc=0 and the right filename.

Two of the four callers are the "find the newest .mosaic-bak-* backup" lookup,
which is the path a restore leans on.

Reading the listing into an array through process substitution has no pipeline,
so there is nothing for pipefail to catch. This also clears the one remaining
violation `scripts/pipefail-early-exit.test.mjs` reports against tools/install.sh
-- that test lives on main, not on next, so it starts failing the moment main is
merged into next for the 0.0.50 integration.

tools/install-newest-matching-file.test.sh pins it, including the large-population
case that is the whole point. Red on origin/next (rc=141), green here.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-15 12:51:59 -05:00
Jason WoltjeandClaude Opus 5 cb2bf4e4a4 installer: provision Node instead of refusing to run without it
ci/woodpecker/pr/ci Pipeline is pending approval
The installer's promise is that one command turns a bare host into a working
one, but Node was carved out of that: it was checked as a prerequisite and the
run died on a greenfield host. That made the documented one-command install a
two-command install whose first command always failed.

It now installs a user-local Node under ~/.mosaic/node when the system Node is
missing or too old, from the official nodejs.org tarballs, verified against
SHASUMS256.txt. User-local rather than apt/dnf/brew: no root, one code path on
every distro, and it works on an immutable host. A system Node that is already
new enough is preferred and left untouched. --no-node-install (or
MOSAIC_NO_NODE_INSTALL=1) keeps the old refuse-and-explain behaviour, and
neither --check nor --uninstall provisions anything.

PATH now lands in the login profile as well as the interactive rc. Writing only
~/.bashrc looked right interactively and was invisible to every way an agent
seat actually starts -- bash -lc, ssh host cmd, a systemd unit -- because
Debian's .bashrc returns early when non-interactive.

Verified end to end on mosaic-sbx-dev rolled back to its greenfield snapshot:
red on origin/next (rc=1, "Required command not found: node"), green with this
change (Node v22.23.2 fetched and verified, CLI 0.0.50-next.2413 installed), and
a fresh `bash -lc` finds both. tools/install-node-provisioning.test.sh pins the
behaviour offline against a file:// dist fixture, including the refusals and the
checksum gate.

The next-lane test's Node 20 case moves to --no-node-install: the >= 22 gate must
still fire before anything is installed, but refusing is no longer the outcome
when provisioning is allowed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-15 12:44:39 -05:00
57 changed files with 1309 additions and 4278 deletions
+2 -2
View File
@@ -22,9 +22,9 @@ steps:
image: gcr.io/kaniko-project/executor:debug
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
from_secret: gitea_username
REGISTRY_PASS:
from_secret: REGISTRY_PASSWORD
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
+1 -24
View File
@@ -30,19 +30,6 @@ steps:
# the baked pnpm store.
- pnpm install --frozen-lockfile --prefer-offline
# ---------------------------------------------------------------------------
# The steps below (sanitization, upgrade-guard, typecheck, lint, format,
# test) are the COMPLETE mandatory verification set. SDLC-D-034 mirrors them
# one-for-one in the canonical terminal verification command — root
# `pnpm verify:release` (scripts/verify-release.mjs) — which the publish
# pipeline (.woodpecker/publish.yml `verify` step) runs before ANY publish
# effect. These lines stay direct (not routed through the runner) because the
# #1017 test-enumeration guard audits framework tool paths through THIS
# surface; scripts/verify-release.test.mjs enforces that the runner's stage
# table keeps matching these commands exactly, so the two cannot drift.
# ---------------------------------------------------------------------------
# Canonical verify:release stage `sanitization`.
# Blocking gate: public framework package must contain no operator-specific
# personal data or private $HOME defaults. Runs early (no node_modules needed).
sanitization:
@@ -60,7 +47,6 @@ steps:
# with everything it guards; this direct line keeps one instrument running.
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
# Canonical verify:release stage `upgrade-guard`.
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
@@ -82,8 +68,6 @@ steps:
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
# Canonical verify:release stage `typecheck` — the same `pnpm typecheck`
# invocation (which runs the checkout preflight first, then turbo).
typecheck:
image: *node_image
commands:
@@ -94,8 +78,7 @@ steps:
- sanitization
- upgrade-guard
# lint, format, and test are independent — run in parallel after typecheck.
# Each runs exactly its canonical verify:release stage command.
# lint, format, and test are independent — run in parallel after typecheck
lint:
image: *node_image
commands:
@@ -112,12 +95,6 @@ steps:
depends_on:
- typecheck
# Canonical verify:release stage `test` — the `pnpm test` line below is the
# shared command; everything else in this step is PIPELINE-LEVEL
# prerequisite the canonical command expects its caller to provide (SDLC-D-034):
# the ci-postgres service + pg_isready wait + db:migrate (postgres path),
# `apk add openssl`, and the pinned pi install. None of those can move into
# the runner (it must also work locally on the PGlite path with no database).
test:
image: *node_image
environment:
+6 -66
View File
@@ -1,19 +1,5 @@
# Build, publish npm packages, and push Docker images
# Runs on main for stable publishes and on next for integration-line prereleases/images
#
# SDLC-D-034 publish gate: every publish effect (publish-npm, publish-next-npm,
# and every image build/push step) depends DIRECTLY on the `verify` step below.
# `verify` (a) asserts the provider's commit identity matches the actual
# checkout (CI_COMMIT_SHA == git rev-parse HEAD, fail closed on mismatch or
# emptiness) and (b) runs the canonical terminal verification command
# (`pnpm verify:release`), which mirrors the PR CI pipeline's complete
# mandatory set (sanitization, upgrade-guard, preflight+typecheck, lint,
# format:check, test, build) — see scripts/verify-release.mjs. A missing,
# failed, skipped, cancelled, or inconclusive verification therefore skips the
# dependent publish effects (fail closed). Path-filtered short-circuits may
# skip publish EFFECTS (e.g. docs-only merges) but never bypass `verify` for a
# publish that does run: `verify` itself carries no path filter.
# scripts/verify-release.test.mjs enforces this DAG invariant at checkout time.
variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
@@ -62,45 +48,6 @@ steps:
# Resolve from the baked pnpm store instead of a cold network fetch.
- pnpm install --frozen-lockfile --prefer-offline
# SDLC-D-034 exact-commit publish gate. No `when`/path filter on purpose: it
# runs for every event this pipeline serves so no publish effect can ever
# start without it. Fails closed on commit-identity mismatch (or either SHA
# being empty) and on any incomplete verification.
verify:
image: *node_image
commands:
- *enable_pnpm
# (a) Commit identity: the provider's claimed SHA must equal the actual
# checkout HEAD — verification of anything else must never authorize a
# publish of this commit.
- |
if [ -z "$CI_COMMIT_SHA" ]; then
echo "[verify] FATAL: CI_COMMIT_SHA is empty — cannot certify commit identity" >&2
exit 1
fi
CHECKOUT_SHA="$(git rev-parse HEAD 2>/dev/null || true)"
if [ -z "$CHECKOUT_SHA" ]; then
echo "[verify] FATAL: git rev-parse HEAD returned nothing — cannot certify commit identity" >&2
exit 1
fi
if [ "$CI_COMMIT_SHA" != "$CHECKOUT_SHA" ]; then
echo "[verify] FATAL: provider commit ($CI_COMMIT_SHA) != checkout HEAD ($CHECKOUT_SHA)" >&2
exit 1
fi
echo "[verify] commit identity confirmed: $CHECKOUT_SHA"
# (b) Canonical terminal verification. Caller-provided prerequisites the
# runner expects (see .woodpecker/ci.yml comments): bash/rsync for the
# guard stages, openssl + the pinned pi binary for the test stage. git is
# baked into ci-base but re-asserted here so the identity check above can
# never silently depend on a stale baked image. DATABASE_URL is
# deliberately NOT set: the canonical command must hold on the PGlite
# path too and never sets or requires a database itself.
- apk add --no-cache bash rsync openssl git
- npm install -g @earendil-works/[email protected]
- pnpm verify:release
depends_on:
- install
build:
image: *node_image
commands:
@@ -108,7 +55,6 @@ steps:
- pnpm build
depends_on:
- install
- verify
publish-npm:
image: *node_image
@@ -168,7 +114,6 @@ steps:
exit 1
depends_on:
- build
- verify
publish-next-npm:
image: *node_image
@@ -247,7 +192,6 @@ steps:
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
depends_on:
- build
- verify
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
@@ -261,7 +205,6 @@ steps:
# - bash scripts/publish-npmjs.sh
# depends_on:
# - build
# - verify
# when:
# - event: [tag]
@@ -270,9 +213,9 @@ steps:
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
from_secret: gitea_username
REGISTRY_PASS:
from_secret: REGISTRY_PASSWORD
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
@@ -299,16 +242,15 @@ steps:
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
depends_on:
- build
- verify
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
from_secret: gitea_username
REGISTRY_PASS:
from_secret: REGISTRY_PASSWORD
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
@@ -326,16 +268,15 @@ steps:
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
depends_on:
- build
- verify
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
from_secret: gitea_username
REGISTRY_PASS:
from_secret: REGISTRY_PASSWORD
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
@@ -353,4 +294,3 @@ steps:
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
depends_on:
- build
- verify
+2 -2
View File
@@ -138,9 +138,9 @@ mosaic brain tasks
mosaic brain conversations
# Agent forge pipeline
mosaic forge run [--simulate] # fails closed (FORGE_NO_EXECUTOR) with no executor wired; --simulate for typed simulated runs
mosaic forge run
mosaic forge status
mosaic forge resume [--simulate] # same fail-closed rule as forge run
mosaic forge resume
mosaic forge personas
# Structured logging
@@ -190,13 +190,7 @@ beforeEach((ctx) => {
});
afterAll(async () => {
// Cleanup only when the fixture actually installed rows. `handle` is set
// before the first query (createDb connects lazily), so on an unreachable
// database `handle` is truthy while nothing was inserted — cleanup must
// honor `dbAvailable` or the skip path fails the file with ECONNREFUSED in
// afterAll (caught live by the publish pipeline's no-DATABASE_URL verify
// step, pipeline 2486).
if (!handle || !dbAvailable) return;
if (!handle) return;
const db = handle.db;
// Delete in dependency order (FK constraints)
-35
View File
@@ -1368,38 +1368,3 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
---
## Release Integrity Workstream (RI, #1275)
### Problem and objective
At `next` 476db12b (review of 2026-08-17), publication from `next` is not bound to the full verification pipeline for the same commit: the publish pipeline's publish steps depend on `build` only, while ordinary push CI excludes `next`. Public Forge/MACP paths contain false-success placeholders: a stub executor that reports `completed` with exit zero, planning/remediation gates that execute literal `true`, a review gate that echoes an approving verdict, and a gate runner that treats empty commands and unimplemented CI-provider gates as passing. Shipping UI surfaces can render a failed fetch as an empty, healthy collection.
Objective: for alpha 0.0.50, the release cannot publish, report, or display work state that the repository has not actually verified. Decisions SDLC-D-033 through SDLC-D-038 (Jason, 2026-08-17) scope this floor; full decision text and required-behavior lists live in jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` and `data/decisions/mosaic-stack-sdlc-protocol.json`. This section restates only the normative requirements.
### Normative requirements
1. **RI-N1 Exact-commit publication verification (SDLC-D-034).** One canonical terminal verification command performs self-contained re-verification in the publish pipeline against the job's checked-out commit before any external publication effect. The command contains or invokes the complete mandatory verification set (semantic parity with the PR merge gate, including sanitization, upgrade-guard, typecheck, lint, format check, tests, and build); CI and publication do not maintain separate semantic checklists. Every publish step depends on the verification step in the executable pipeline DAG. Provider commit identity and `git rev-parse HEAD` must identify the same commit. Missing, skipped, cancelled, stale, or inconclusive checks fail closed. Documentation-only runs may skip publication but cannot bypass verification when a publication effect will occur. A negative control must prove that a broken check blocks every publish step.
2. **RI-N2 Fail-closed Forge/MACP with explicit simulation (SDLC-D-035).** Simulation requires explicit caller intent (e.g. `--simulate`) and produces a distinct typed `simulated` state that can never satisfy dependencies, acceptance criteria, gates, merge, or release. Normal execution exits nonzero with a typed capability failure when a required executor, reviewer, command, or CI provider is absent — no stub completion, no literal-`true` gates, no synthetic approvals, no empty-command passes. A manual gate with no automation enters a waiting state; it does not pass. Positive tests prove explicit simulation still works; negative controls prove simulation and every missing-provider case cannot advance lifecycle state.
3. **RI-N3 One transitional PRD authority (SDLC-D-036).** `@mosaicstack/prdy` structured storage under `docs/prdy/`, driven by `mosaic mission --plan`, is the authoritative PRD representation for the alpha. `mosaic prdy` either routes through the same application service or operates only as an explicit, named Markdown import/export adapter; `docs/PRD.md` is not a peer authority. `mission --plan` must persist the mission↔PRD linkage (mission id/version, PRD id/version, selected requirements). Markdown output is a generated view carrying source identity; editing it cannot mutate authority silently. Import is explicit, validated, and conflict-aware (proposed successor, never overwrite). Structural validity is separate from approval.
4. **RI-N4 One quality-rails evaluator (SDLC-D-037).** The TypeScript quality-rails package is the sole authoritative evaluator. A complete probe inventory maps every current TypeScript and shell check to one canonical check with disposition (preserve/strengthen/retire, each named). Effective shell enforcement probes are absorbed before their independent paths retire; expected-file presence alone is not parity. The evaluator returns typed results (`passed`/`failed`/`blocked`/`error`/`not-applicable`) with check version, subject, and reason; missing implementation, missing input, unknown check, process error, timeout, or malformed output can never become `passed` or an unqualified skip. Check definitions and policy are versioned and digested. Shell commands become thin adapters with no separate verdict logic. The canonical terminal verification command (RI-N1) invokes this evaluator rather than duplicating its logic. Contract, parity, and negative-control tests are required, plus independent review of probe equivalence.
5. **RI-N5 Consequence-aware stale UI (SDLC-D-038).** Mission Control distinguishes typed freshness states (`current`, `stale`, `partial`, `unknown`, `unavailable`) rather than inferring from empty arrays or null. A failed fetch never renders as an empty healthy collection. Last-known data may display for situational awareness only with source identity, version, and age visibly labeled; any derived completion/assurance/release verdict whose inputs are stale becomes `unknown`; all state-changing actions are disabled until fresh state loads and is revalidated. With no verified snapshot, surfaces show an explicit unavailable state. Cache corruption, cross-workspace data, schema mismatch, and version regression invalidate the snapshot. Tests cover the failure matrix (network, auth, malformed, partial, corruption, stale age, schema mismatch, recovery, stale-action rejection) with negative controls proving no case yields a current green verdict or enabled mutation.
### Acceptance criteria
- AC-RI-1: A push to `next` that fails any mandatory verification step publishes nothing (no npm package, no image), demonstrated by a checked-in negative control and by pipeline evidence on a real `next` publish run where the verification step is green and every publish step depends on it.
- AC-RI-2: With no executor/reviewer/CI provider wired, Forge and MACP normal runs exit nonzero with typed capability failures; with `--simulate`, runs complete but every result is typed `simulated` and cannot satisfy any gate, dependency, or completion state — proven by unit tests including negative controls.
- AC-RI-3: A PRD created or revised through either `mosaic mission --plan` or `mosaic prdy` resolves to one authority under `docs/prdy/` with stable identities and versions; the mission↔PRD linkage survives restart; a Markdown export is labeled as generated and cannot silently become a second writer; divergent legacy content blocks baseline claims until explicitly resolved — proven by contract tests.
- AC-RI-4: `quality-rails check` through any entry point (TS CLI, framework shell adapter) returns the same typed verdict for the same subject; the probe inventory names every legacy check's disposition; a deliberately broken probe fails closed — proven by contract/parity/negative-control tests and independent review of probe equivalence.
- AC-RI-5: No shipping surface renders a failed fetch as an empty healthy state; stale/partial/unavailable states are typed, labeled, and mutation-disabled — proven by the failure-matrix tests.
- AC-RI-6: All cards merged to `next` via squash PR with terminal-green CI; release evidence for 0.0.50 records commit, verification run, and published artifacts.
### Out of scope
The canonical dispatcher/control-plane vertical slice (work graph, execution attempts, fenced leases, typed check-in, independent verifier dispatch) is decided post-alpha (SDLC-D-033, option B). Multi-pipeline verification certificates (SDLC-D-034 option B) are post-alpha. Full AF-1..AF-4 objective matrices and Mission Control portfolio surfaces are post-alpha.
-42
View File
@@ -1,42 +0,0 @@
# Tasks — Release Integrity Workstream (RI-050, #1275)
> Single-writer: the RI-050 orchestrator (jarvis, dragon-lin) only. Workers read but never modify.
>
> **Mission:** alpha 0.0.50 release-integrity floor (decisions SDLC-D-033..038).
> **PRD:** [docs/PRD.md § Release Integrity Workstream](../PRD.md#release-integrity-workstream-ri-1275)
> **Issue:** #1275 (remains open until RI-V-001 closes)
> **Base branch:** `next` (all cards branch from `origin/next`, squash-merge via PR)
>
> **Execution note:** the `agent` column uses `pi-glm-5.3` — outside the pipeline-cron model
> table on purpose. This workstream is executed by jarvis on dragon-lin with local pi workers
> (`pi --model zai/glm-5.3:high`); pipeline crons must not auto-claim these rows.
>
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
> `done` requires: repo quality gates green, independent review recorded, terminal-green CI on
> the PR head, squash merge to `next`, and acceptance evidence in notes.
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| -------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---------- | ----------------- | --------------------------------- | ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| RI-0-001 | done | Bootstrap: issue #1275, PRD section, this DAG, scratchpad (docs only) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-mission-bootstrap | — | 6K | PR #1276 (head 758659dd): docs-only, CI green (2475). Review requested from fargo. Merges first (no publish run). |
| RI-1-001 | done | RI-N1: canonical terminal verification command + publish-pipeline exact-commit gate (every publish step depends on verify; commit identity check; fail closed) | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-publish-gate | RI-0-001 | 25K | PR #1277 (head 46784c8d): CI GREEN at head after serialized retry (pipeline 2476, 2026-08-18) - earlier red was CI-agent contention (web SPA timeouts under concurrent pipelines), not code. Review requested from fargo at pinned head (comms 20260818T021025Z). |
| RI-1-002 | done | RI-N1 negative control: checked-in tests proving a broken mandatory check blocks every publish step and that DAG edges cannot be bypassed | #1275 | pi-glm-5.3 | mosaicstack/stack | test/ri-050-publish-gate-negative | RI-1-001 | 12K | |
| RI-2-001 | done | RI-N2 (Forge): remove stub-executor false success; `--simulate` typed `simulated` results that satisfy nothing; literal-`true` gates and echo-review replaced with real gates or typed waiting-for-authority | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-forge-fail-closed | RI-0-001 | 20K | Independent review APPROVED 2026-08-17 (Gitea review 172 on PR #1278, head 99b8f6ea; reviewing seat fargo — recorded under shared host principal mos-dt-0, provenance correction posted by fred; wrapper gap filed by fred). Executed at head: forge tests 116/116, lint green, typecheck green after building macp dist (minimal-install artifact, not a defect), workspace typecheck 45/45, no external type consumers of the changed interfaces. CI red = known lane-wide fleet-test failure only, carries no information about this change (fred, log-content analysis, pipelines 2456-2458). Non-blocking finding: README L141-143 + skills/mosaic-forge/SKILL.md document bare forge run/resume, which now fails closed — fast-follow docs touch. Merge queued behind #1270. UPDATE 2026-08-18: #1270 merged; CI GREEN at head 4917df1f via serialized retry (pipeline 2477) - root cause of prior reds was CI-agent contention (web SPA timeouts under concurrent pipelines), superseding the fleet-test-failure theory. |
| RI-2-002 | done | RI-N2 (MACP): gate runner fails closed on empty commands, stub executors, and unimplemented CI-provider gates unless explicit simulate; typed capability failures | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-macp-fail-closed | RI-0-001 | 15K | PR #1293 (head 2097379e): CI green (pipeline 2465), independent review APPROVED (Gitea review 173, jarvis seat, 2026-08-17) - macp 109/109 verified at head. Merge queued behind #1276/#1277/#1278. |
| RI-3-001 | in-progress | RI-N4: complete probe inventory mapping every TS and shell quality-rail check to one canonical check with disposition (preserve/strengthen/retire, each named) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-qr-probe-inventory | RI-0-001 | 12K | |
| RI-3-002 | not-started | RI-N4: TS evaluator absorbs effective shell probes; typed results (passed/failed/blocked/error/not-applicable) with versioned digested check definitions; shell commands become thin adapters; contract/parity/negative-control tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-qr-evaluator | RI-3-001 | 30K | |
| RI-4-001 | in-progress | RI-N3: one PRD application service — `mission --plan` persists mission↔PRD linkage (ids/versions/selected requirements); `mosaic prdy` routes through the service or becomes a named import/export adapter; Markdown is a labeled generated view; explicit conflict-aware import | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-prd-authority | RI-0-001 | 35K | PR #1294 (head 8d258e1d): CI green (pipeline 2466), independent review APPROVED (Gitea review 174, jarvis seat, 2026-08-17) - prdy 20/20 + command specs 9/9 at head. Merge queued behind #1276/#1277/#1278. |
| RI-5-001 | done | RI-N5: typed freshness states (current/stale/partial/unknown/unavailable); no failed-fetch-renders-empty; stale derived verdicts → unknown; mutations disabled when stale; failure-matrix tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-web-stale-safety | RI-0-001 | 25K | |
| RI-V-001 | not-started | Final verification + release evidence: all cards verified merged, negative controls demonstrated, real `next` publish run green on exact commit, evidence pack recorded | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-release-evidence | RI-1-002, RI-2-001, RI-2-002, RI-3-002, RI-4-001, RI-5-001 | 10K | |
## Dispatch waves (max 2 parallel workers)
1. RI-1-001 + RI-2-001
2. RI-2-002 + RI-4-001
3. RI-3-001 + RI-5-001
4. RI-1-002 + RI-3-002
5. RI-V-001
## Budget
Derived soft cap: 250K tokens (no explicit cap given). Projected total: 190K.
Conservative mode (1 worker) above 70% projected; freeze above 90%.
-242
View File
@@ -1,242 +0,0 @@
# Scratchpad — RI-050 orchestrator (jarvis, dragon-lin)
Mission: alpha 0.0.50 release-integrity floor. Issue #1275. Base `next` @ 476db12b.
Design SSOT: jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` (SDLC-D-033..038).
## Mode (Jason's directives)
- Orchestrator: jarvis (this session, dragon-lin). NOT mos-claude; work stays on this host.
- Workers: local pi headless — `pi --model zai/glm-5.3:high -p` in the card's worktree, tools read,bash,edit,write.
- Delegation override of stack AGENTS.md `agent` column: rows carry `pi-glm-5.3` (outside cron table so no auto-claim).
- Target branch: `next`. Cards branch from `origin/next`, squash-merge via PR.
## Operational constraints (measured this session)
- Main checkout at `/home/jwoltje/src/mosaic-stack` is a dirty diverged `main` (ahead 1139/behind 711) — NEVER touched. All work in `/home/jwoltje/src/mosaic-stack-worktrees/<branch>`.
- Disk: /home 187G free. /tmp only 8.7G — keep pnpm stores/node_modules under /home.
- `main` and `next` have DIVERGED; PRs target `next`.
- Identity: pin `GITEA_LOGIN=mosaicstack-jarvis` for all wrapper ops. Issue #1275 verified authored by @jarvis.
- `ci-queue-wait.sh` on this host is fail-open (board: fix #1032 not installed) — substitute SHA-status checks via `/commits/{sha}/status` and diff failing step names.
- CI on PRs runs `pull_request` pipelines (any branch) incl. ci-postgres service. Push CI runs on main only; publish runs on push/tag to next + manual.
- Wrapper gaps on this host per board (7 gaps; e.g. no pr-review-list, issue-assign broken, pr-merge makes no trailers): verify outcomes by reading back provider state, never trust rc alone.
- Publish pipeline currently: install → build → publish-npm/publish-next-npm (+image). No verify. CI steps: install, sanitization, upgrade-guard, typecheck, lint, format, test, ci-postgres.
## Budget
Soft cap 250K. Projected 190K across 10 cards. Track per-card used vs estimate in TASKS.md notes.
## Progress log
- 2026-08-16 23:52 — Issue #1275 created (@jarvis verified).
- 2026-08-16 23:5x — Bootstrap branch `docs/ri-050-mission-bootstrap` from origin/next@476db12b; PRD section + TASKS.md + this scratchpad written. RI-0-001 in-progress.
## Wave 1 dispatched (2026-08-17 00:35)
- RI-1-001 worker: pi glm-5.3:high, pid 2322125, worktree ri-1-001, log /var/tmp/ri-050/ri-1-001-run.log
- RI-2-001 worker: pi glm-5.3:high, pid 2322126, worktree ri-2-001, log /var/tmp/ri-050/ri-2-001-run.log
- Gotcha recorded: pi has no -f flag (that's pi-do.sh); pass brief as positional message. First launch died "Unknown option: -f" — relaunched.
- CI lane: PR #1276 (bootstrap) fails `test` at base like every next PR — fred's green #1270 unblocks (comms sent 2026-08-17T05:21Z, `comms/20260817T052148Z__from-jarvis__650fe8.md`). Merge gate for all RI PRs queues behind #1270.
- Live RI-N1 evidence posted to #1275 (comment 22915): pipeline 2439 publish-next-npm SUCCESS beside build-gateway FAILURE.
---
# HANDOFF — RI-050 continuation (written 2026-08-17 ~08:45 UTC, jarvis/dragon-lin)
You are taking over the alpha 0.0.50 release-integrity workstream in place. Everything you
need is on the remote. Read this whole file, then `docs/release-integrity/TASKS.md` (same
branch), then the PRD section (`docs/PRD.md` § Release Integrity Workstream, same branch).
## Identity / mode
- Orchestrator identity: `jarvis` (dragon-lin). You continue as the RI-050 orchestrator under
whatever identity Jason gives you — if you are NOT jarvis, say so in comms and PR bodies.
- Jason's standing directives for this mission: work happens on THIS repo (mosaicstack/stack),
PRs target `next` (NOT main), workers are local pi headless sessions on
`zai/glm-5.3:high`. Do not hand this to mos-claude. Do not borrow other seats' lanes.
- All wrapper ops: pin `GITEA_LOGIN=mosaicstack-jarvis` (issue #1275 was verified authored by
@jarvis; keep identity consistent or verify yours with issue-view and READ BACK user.login).
- CI substitution rule (this host's ci-queue-wait.sh is fail-open; fix #1032 not installed):
judge CI by SHA-status via `/api/v1/repos/mosaicstack/stack/commits/{sha}/status` or the
woodpecker API (`pipeline-status.sh -r mosaicstack/stack -n N -f json`), and DIFF THE
FAILING STEP NAMES rather than trusting rc.
## Mission state at handoff
Mission: alpha 0.0.50 release-integrity floor. Issue #1275 (open, has live-evidence comment).
Decisions SDLC-D-033..038 live in jarvis-brain
`docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` (normative text also mirrored in the
PRD section on this branch, so this repo is self-sufficient).
Base: `origin/next` @ 476db12b. NOTE: `main` and `next` have DIVERGED — never base on main.
Branches (all pushed, all clean trees):
- `docs/ri-050-mission-bootstrap` @ 5114faa2 → PR #1276 (open, mergeable) — bootstrap docs +
this scratchpad + TASKS.md DAG. STATUS: CI red on `test` only, which is the known lane-wide
failure (see blocker below); own prettier issue already fixed.
- `feat/ri-050-publish-gate` @ 0aa5ed35 → PR #1277 (open, mergeable) — RI-1-001 COMPLETE
(worker reported success, orchestrator review PASSED: verify step asserts CI_COMMIT_SHA ==
git rev-parse HEAD then runs canonical `pnpm verify:release`; every publish/image step
depends_on verify directly, confirmed by parsing the DAG: publish-npm, publish-next-npm,
build-gateway/appservice/web all -> [build, verify]; invariant test
scripts/verify-release.test.mjs passes 7/7 locally with negative fixtures). CI: same known
lane-red `test` step only.
- `fix/ri-050-forge-fail-closed` @ 99b8f6ea → PR #1278 (open, mergeable) — RI-2-001 worker
reported success (typed `FORGE_*` capability errors, --simulate typed simulated everywhere,
vacuous true/echo gates replaced, closed ForgeOutcome set, 116 tests green incl. 16 new).
ORCHESTRATOR REVIEW NOT YET DONE — your first job. Review the diff
(1391 insertions across forge src), check the fail-closed paths and that simulated
results cannot satisfy any consumer, run `pnpm --filter @mosaicstack/forge test`.
## The one blocker
Every `next` PR pipeline is red on ONE assertion:
`packages/mosaic/framework/tools/fleet/test-start-agent-session.sh:103` ("host provides 'pi'
in the system path"). Pre-existing at base; affects PRs #1276/#1277/#1278 identically.
fred's PR #1270 ("unblocks every PR on next") is green and open — it is HIS to merge; do not
merge it yourself. jarvis sent comms (`comms/20260817T052148Z__from-jarvis__650fe8.md` in
jarvis-brain) asking merge timing; no reply yet as of handoff. Merge gates for ALL RI PRs
queue behind #1270 landing. Until then: review/develop freely, merge nothing that needs the
green gate (docs-only #1276 arguably could merge red-lane with Jason's explicit call — ask,
don't assume).
## Remaining DAG (docs/release-integrity/TASKS.md is canonical)
Wave 2 (next): RI-2-002 MACP fail-closed (brief pattern: mirror RI-2-001 for
packages/macp/src/gate-runner.ts — empty commands, stub executors, unimplemented CI-provider
gates fail closed; explicit simulate) and RI-4-001 PRD authority (one PRD service;
@mosaicstack/prdy docs/prdy authoritative via `mosaic mission --plan`; `mosaic prdy` routes
or becomes named Markdown adapter; mission<->PRD linkage persists — see PRD RI-N3).
Wave 3: RI-3-001 probe inventory (docs), RI-5-001 web stale-safety.
Wave 4: RI-1-002 negative-control tests, RI-3-002 TS evaluator absorbs shell probes.
Final: RI-V-001 evidence pack (real green next publish run post-gate + all cards verified).
## Worker mechanics (measured, reuse)
- Dispatch: create worktree `git -C /home/jwoltje/src/mosaic-stack worktree add
/home/jwoltje/src/mosaic-stack-worktrees/<id> -b <branch> origin/next`, write a brief to
/var/tmp/ri-050/, then run from INSIDE the worktree:
`pi -p --no-session --model zai/glm-5.3:high --tools read,bash,edit,write "$(cat brief.md)"`
(pi has NO -f flag — pass the brief as a positional message; first dispatch died on that).
- Briefs for 1-001/2-001 are at /var/tmp/ri-050/ on dragon-lin (may not survive; the
pattern is fully described above and in TASKS.md).
- Briefs must carry: worktree path, branch, base, requirements, known base-red list (so the
worker doesn't chase it), gates to run, PR creation command with GITEA_LOGIN pin, "do NOT
merge, do NOT touch docs/TASKS.md", and the JSON report format.
- Verify worker claims: read the PR, run their tests yourself, parse pipeline step names.
## Do-not-touch
- Main checkout at /home/jwoltje/src/mosaic-stack (dirty diverged main) — never touch.
- fred's open PRs (#1270 and others) — review evidence welcome, merging his is not yours.
- Other RI PRs' authors' lanes: #1277/#1278 are yours to gate and merge ONCE lane is green
and review is recorded.
- Never `--no-verify`; never bypass the wrapper-fails-closed rule (wrapper failure ⇒
`blocked + report exact command + stop`).
## Session-restore command sequence
1. `git -C /home/jwoltje/src/mosaic-stack-worktrees/ri-050 fetch origin --prune`
2. Read this file + `docs/release-integrity/TASKS.md` + PRD section.
3. Check PR states (#1270, #1276, #1277, #1278) and lane CI (SHA-status per above).
4. Review RI-2-001 (PR #1278) if not yet done; then dispatch wave 2.
— jarvis, 2026-08-17
---
# CONTINUATION — fargo (sb-it-1-dt)
Orchestrator seat is now **fargo** on sb-it-1-dt (Jason, 2026-08-17): Claude seat, worktree discipline
per fred's ruling (`~/agent-work/<slug>`, create → work → commit → push → remove as one act; the
helper's `/src` refusal is a web1 convention, does not bind here). fred supports; lane rulings are
his. Workers remain local pi `zai/glm-5.3:high` + limited Claude per Jason.
## 2026-08-17 — RI-2-001 independent review DONE
- **PR #1278 APPROVED** (Gitea review 172, pinned to head 99b8f6ea). Executed evidence, not read-only:
forge suite 116/116 at head (matches PR claim), forge lint green, forge typecheck green after
building `@mosaicstack/macp` dist (TS2307 on bare `pnpm install --frozen-lockfile` is a
minimal-install build-order artifact — the macp import is type-only, vitest passes unbuilt; CI
installs build workspace deps, hence green there), **workspace typecheck 45/45 at head**,
consumer sweep: no external type consumers of RunManifest/StageStatus/ForgeTaskResult/
TaskExecutor; only importer of the package is packages/mosaic via registerForgeCommand
(smoke test asserts registration/help only — cannot break). Digest gate (shaggy's) before==after
with both-arm reactivity controls.
- CI red on #1276/#1277/#1278: lane-wide `test` failure only
(test-start-agent-session.sh:103, fred's guard mis-wired; #1270 unwires it). Fred measured log
content: one real byte-identical failure per pipeline (2456/2457/2458); 13 of ~14 `FAIL` grep
hits are passing fail-loud test NAMES. **The red carries no information about the RI changes.**
- Non-blocking finding: README L141-143 + skills/mosaic-forge/SKILL.md document bare
`mosaic forge run`/`resume`, which now exits 1 FORGE_NO_EXECUTOR — fast-follow docs touch.
- **Identity incident, ruled on by fred:** review 172 recorded under shared host principal
mos-dt-0, not fargo. Mechanism (measured, wrapper source): pr-review.sh resolves its acting login
from the tea login list only; no fargo tea login on this host → silent host-default fallback;
MOSAIC_GIT_IDENTITY is only read in detect-platform.sh get_gitea_token's fallback arm, never
reached. Exact-id read-back verifies against the writing token, so it passed while attribution
was wrong — durable-provenance machinery proves the write, not the seat. Fred's ruling: review
172 stands (substance/verdict/pin correct; label wrong); NO re-approval (one approval,
annotated, is the stronger record); fred posts the provenance correction under @fred with
--login fred-ms (hard-fail path); no fargo tea login ever (freeze + Jason's to authorize);
tooling gap filed by fred. Also explains (does not reopen) #1228's mos-dt-0 attribution.
- Merge gate: all RI PRs queue behind fred's green #1270 (Jason's call).
## Next
1. Wave 2 dispatch: RI-2-002 (MACP fail-closed, mirror RI-2-001 pattern for
packages/macp/src/gate-runner.ts) + RI-4-001 (PRD authority). Two parallel workers max.
2. Docs fast-follow (README + mosaic-forge skill) — fold into #1276 or a tiny docs card.
3. RI-V-001 evidence at the end.
— fargo, 2026-08-17
---
# RESUMPTION + DAILY-HANDOFF PROTOCOL (Jason, 2026-08-17)
Orchestrator seat is back with **jarvis** (dragon-lin). Expect daily handoff between jarvis
and fargo. Protocol (both seats, every handoff):
1. **This file is the shared mission log.** Append a dated section per session: state
measured, actions taken, PR/review states, next actions. Never rewrite prior sections.
2. **TASKS.md stays current within one session** — status, PR number in notes, review
evidence. Stale rows are handoff debt.
3. **Cross-review rule (SDLC-D-011 in practice):** the reviewing seat must differ from the
producing seat. jarvis reviews fargo-dispatched PRs, fargo reviews jarvis-dispatched
PRs. Producers are always pi workers; dispatching seats verify before push; the other
seat records the Gitea review.
4. Handoff = append here + push + (optional) issue #1275 comment if a decision changed.
## RESUMED — jarvis/dragon-lin, 2026-08-17 (afternoon)
- Measured: next = 8199261c (#1270 merged — lane unblocked for new PRs). #1293/#1294
(fargo, wave 2) CI-green, mergeable, no recorded reviews. #1276/#1277/#1278 still based
on 476db12b with stale red CI → need rebase onto 8199261c. #1278 review pinned to old
head 99b8f6ea by @mos-dt-0 (fargo's, mis-attributed per his note) — rebase will dismiss
it; re-approval must come from fargo/fred (author is @jarvis, cannot self-approve).
- Live evidence #2: push pipeline 2462 (the #1270 merge itself) ran publish-next-npm
SUCCESS beside build-gateway FAILURE again.
- Plan: rebase the three original branches; independently review #1293/#1294; merge order
once green+reviewed: #1276 (docs) → #1277 (publish gate) → #1278/#1293/#1294 (code).
After #1277 merges, watch the next push pipeline prove the verify gate live.
- fargo's non-RI PRs (#1291/#1296/#1297/#1281) stay strictly his lane.
## jarvis session 2026-08-17 (evening) — reviews, rebases, merge plan
- Rebased #1276/#1277/#1278 onto 8199261c (heads 59e2c460 / 46784c8d / 4917df1f);
invariant tests 7/7 and forge 116/116 re-run green at new heads. #1270 touched
test-enumeration-exclusions.txt + package.json, NOT ci.yml — no semantic overlap with
#1277's ci.yml changes (checked, was a real concern).
- Independent reviews recorded: #1293 APPROVED (review 173; macp 109/109; fail-closed paths
+ aggregate state machine verified), #1294 APPROVED (review 174; prdy 20/20 + command
specs 9/9; single-writer + linkage persistence + labeled export + conflict-aware import
verified). Note: 19 unrelated mosaic suites fail on bare minimal install (known workspace
build-order artifact, documented by fargo) — not this change.
- Measured: `next` has NO branch protection (API: only main listed). Cross-seat review
discipline is protocol-enforced, not Gitea-enforced. Flagged to fargo for Jason: direct
pushes to next trigger ungated publishes; protection is Jason's call (#1231 adjacent).
- Merge order planned: #1276 (docs-only — no publish run) -> #1277 (first gated publish)
-> #1278 -> #1293 -> #1294. Sent fargo review requests with pinned head SHAs
(comms/20260818T011932Z__from-jarvis__a9c02b.md). Not merging #1293/#1294 before my three
clear fargo's review — order optimality beats speed; every pre-#1277 merge publishes ungated.
- CI on the three rebased heads: pending at time of this entry.
-1
View File
@@ -34,7 +34,6 @@ export default tseslint.config(
'packages/storage/vitest.config.ts',
'packages/mosaic/vitest.config.ts',
'packages/mosaic/__tests__/*.ts',
'packages/forge/__tests__/*.ts',
'tools/federation-harness/*.ts',
],
},
+1 -2
View File
@@ -9,10 +9,9 @@
"preflight": "node scripts/preflight.mjs",
"clean:generated": "node scripts/clean-generated.mjs",
"typecheck": "pnpm preflight && turbo run typecheck",
"verify:release": "node scripts/verify-release.mjs",
"test:checkout": "node --test scripts/*.test.mjs",
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
"test:installer": "bash tools/install-next-lane.test.sh",
"test:installer": "bash tools/install-next-lane.test.sh && bash tools/install-node-provisioning.test.sh && bash tools/install-newest-matching-file.test.sh",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "node scripts/install-hooks.mjs"
-40
View File
@@ -539,43 +539,3 @@ Not every brief needs full Board of Directors review. The classification system
### Backward compatibility
Existing briefs without a `class` field are auto-classified. The default (no matching keywords) is `strategic`, so all existing runs get the full pipeline unless keywords trigger `technical`.
---
## Fail-Closed Execution & Explicit Simulation (SDLC-D-035)
**Added:** 2026-08-17
Forge fails closed when a required capability is missing. It never runs a
pipeline with a stub executor and reports success.
### Normal mode (default)
- No task executor wired → the CLI exits nonzero with the typed capability
error `FORGE_NO_EXECUTOR`. No run is created.
- A stage whose gate is approval-based (board approval, planning approvals,
remediation re-review, discovery/analysis attestations) records a typed
`waiting-for-authority` stage result and raises `FORGE_AUTHORITY_REQUIRED`.
It never passes vacuously.
- A stage whose gate requires an unwired provider (AI reviewer, CI pipeline)
records a typed `blocked` stage result and raises `FORGE_NO_REVIEWER` /
`FORGE_NO_CI_PIPELINE`. The synthetic echo-review approval in `06-review`
and all vacuous `true` gates were removed.
### Explicit simulation (`--simulate`)
Opts into stub/synthetic execution. Every stage result, every gate result, and
the run manifest carry the distinct typed status `simulated` (manifest also
records `mode: "simulated"`). `simulated` is a non-satisfying outcome:
`isSatisfyingOutcome()` and all completion/gate consumers treat only `passed`
as satisfying. The CLI exits 0 for a simulated run only because the caller
explicitly passed `--simulate`, and prints a loud SIMULATED banner.
### Typed outcome model
Every gate/task outcome is one of the closed set
`passed | failed | blocked | error | waiting-for-authority | simulated |
not-applicable`, with the reason recorded on the stage status and each gate
result in `manifest.json`. Missing implementations, missing gate evidence,
unknown stages, process errors, and timeouts map to fail-closed members —
never to `passed`.
@@ -1,319 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { generateBoardTasks } from '../src/board-tasks.js';
import { STAGE_SPECS } from '../src/constants.js';
import { ForgeCapabilityError } from '../src/errors.js';
import {
evaluateStageGates,
gateLabel,
isCommandGate,
isSatisfyingOutcome,
} from '../src/outcomes.js';
import { loadManifest, runPipeline } from '../src/pipeline-runner.js';
import type { ForgeTask, ForgeTaskResult, TaskExecutor } from '../src/types.js';
/**
* Mock real executor that returns typed results.
*
* Command gates are "verified" by the mock so normal-mode runs can pass
* mechanically gated stages; authority/provider gates are never reported
* because they have no mechanical implementation.
*/
function createTypedExecutor(options?: {
failStage?: string;
gateOutcomes?: Record<string, 'passed' | 'failed' | 'simulated' | 'error' | 'blocked'>;
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
const submittedTasks: ForgeTask[] = [];
return {
submittedTasks,
async submitTask(task: ForgeTask) {
submittedTasks.push(task);
},
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
const task = submittedTasks.find((t) => t.id === taskId);
const stageName = task?.metadata?.['stageName'] as string | undefined;
if (options?.failStage && stageName === options.failStage) {
return {
task_id: taskId,
outcome: 'failed',
reason: 'mock task failure',
completed_at: new Date().toISOString(),
exit_code: 1,
gate_results: [],
};
}
const gateResults = (task?.qualityGates ?? [])
.filter((gate) => isCommandGate(gate))
.map((gate) => {
const label = gateLabel(gate);
const outcome = options?.gateOutcomes?.[label] ?? 'passed';
return {
gate: label,
outcome,
reason: outcome === 'passed' ? 'mock verified' : `mock gate outcome: ${outcome}`,
};
});
return {
task_id: taskId,
outcome: 'passed',
reason: 'mock verified',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: gateResults,
};
},
async getTaskStatus() {
return 'completed' as const;
},
};
}
describe('fail-closed: no executor wired', () => {
let tmpDir: string;
let briefPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-failclosed-'));
briefPath = path.join(tmpDir, 'brief.md');
fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('throws a typed FORGE_NO_EXECUTOR capability error without --simulate', async () => {
await expect(
runPipeline(briefPath, tmpDir, {
// no executor, no simulate — must fail closed, never run with a stub
stages: ['00-intake'],
}),
).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_NO_EXECUTOR',
capability: 'task-executor',
});
});
it('does not create a run directory when failing closed on a missing executor', async () => {
try {
await runPipeline(briefPath, tmpDir, { stages: ['00-intake'] });
} catch {
// expected
}
expect(fs.existsSync(path.join(tmpDir, '.forge', 'runs'))).toBe(false);
});
it('completes with every result typed simulated when simulate is set', async () => {
const result = await runPipeline(briefPath, tmpDir, {
simulate: true,
stages: ['00-intake', '00b-discovery', '02-planning-1', '06-review'],
});
expect(result.manifest.mode).toBe('simulated');
expect(result.manifest.status).toBe('simulated');
for (const stage of result.stages) {
const stageStatus = result.manifest.stages[stage];
expect(stageStatus?.status, `stage ${stage}`).toBe('simulated');
expect(stageStatus?.status, `stage ${stage}`).not.toBe('passed');
expect(stageStatus?.reason, `stage ${stage}`).toBeTruthy();
for (const gateResult of stageStatus?.gateResults ?? []) {
expect(gateResult.outcome, `gate ${gateResult.gate} of ${stage}`).toBe('simulated');
expect(gateResult.outcome, `gate ${gateResult.gate} of ${stage}`).not.toBe('passed');
}
}
// The persisted manifest agrees.
const persisted = loadManifest(result.runDir);
expect(persisted.mode).toBe('simulated');
expect(persisted.status).toBe('simulated');
expect(persisted.stages['02-planning-1']?.status).toBe('simulated');
});
});
describe('fail-closed: typed outcome model', () => {
it('only passed satisfies the gate/dependency predicate', () => {
expect(isSatisfyingOutcome('passed')).toBe(true);
expect(isSatisfyingOutcome('failed')).toBe(false);
expect(isSatisfyingOutcome('blocked')).toBe(false);
expect(isSatisfyingOutcome('error')).toBe(false);
expect(isSatisfyingOutcome('waiting-for-authority')).toBe(false);
expect(isSatisfyingOutcome('simulated')).toBe(false);
expect(isSatisfyingOutcome('not-applicable')).toBe(false);
});
it('a simulated gate result cannot satisfy the stage gate evaluation', () => {
const evaluation = evaluateStageGates('05-coding', STAGE_SPECS['05-coding']!.qualityGates, {
task_id: 'FORGE-x-05',
outcome: 'passed',
reason: 'executor claims success',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [{ gate: 'pnpm lint', outcome: 'simulated', reason: 'simulated gate' }],
});
expect(isSatisfyingOutcome(evaluation.outcome)).toBe(false);
expect(evaluation.outcome).toBe('error');
});
it('a simulated task outcome cannot satisfy evaluation in normal mode', () => {
const evaluation = evaluateStageGates('00-intake', [], {
task_id: 'FORGE-x-00',
outcome: 'simulated',
reason: 'executor reported simulated',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
});
expect(isSatisfyingOutcome(evaluation.outcome)).toBe(false);
});
it('a missing gate result blocks the stage instead of passing vacuously', () => {
const evaluation = evaluateStageGates('05-coding', STAGE_SPECS['05-coding']!.qualityGates, {
task_id: 'FORGE-x-05',
outcome: 'passed',
reason: 'executor claims success',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
});
expect(evaluation.outcome).toBe('blocked');
});
});
describe('fail-closed: authority and provider gates', () => {
let tmpDir: string;
let briefPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-authority-'));
briefPath = path.join(tmpDir, 'brief.md');
fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it.each(['02-planning-1', '03-planning-2', '04-planning-3', '07-remediate'])(
'planning/remediation stage %s yields waiting-for-authority (not passed) in normal mode',
async (stage) => {
const executor = createTypedExecutor();
let runDir: string | undefined;
try {
await runPipeline(briefPath, tmpDir, {
executor,
stages: [stage as string],
});
expect.unreachable('runPipeline should have failed closed');
} catch (err) {
expect(err).toBeInstanceOf(ForgeCapabilityError);
expect((err as ForgeCapabilityError).code).toBe('FORGE_AUTHORITY_REQUIRED');
runDir = path.join(tmpDir, '.forge', 'runs');
}
const runIds = fs.readdirSync(runDir!);
expect(runIds).toHaveLength(1);
const manifest = loadManifest(path.join(runDir!, runIds[0]!));
expect(manifest.stages[stage]?.status).toBe('waiting-for-authority');
expect(manifest.stages[stage]?.status).not.toBe('passed');
expect(manifest.status).toBe('waiting-for-authority');
},
);
it('review stage fails closed with a typed FORGE_NO_REVIEWER error in normal mode', async () => {
const executor = createTypedExecutor();
try {
await runPipeline(briefPath, tmpDir, {
executor,
stages: ['06-review'],
});
expect.unreachable('runPipeline should have failed closed');
} catch (err) {
expect(err).toBeInstanceOf(ForgeCapabilityError);
expect((err as ForgeCapabilityError).code).toBe('FORGE_NO_REVIEWER');
expect((err as ForgeCapabilityError).capability).toBe('reviewer');
}
const runsDir = path.join(tmpDir, '.forge', 'runs');
const runIds = fs.readdirSync(runsDir);
const manifest = loadManifest(path.join(runsDir, runIds[0]!));
expect(manifest.stages['06-review']?.status).toBe('blocked');
expect(manifest.stages['06-review']?.status).not.toBe('passed');
expect(manifest.status).toBe('failed');
});
it('review stage produces simulated results under --simulate', async () => {
const result = await runPipeline(briefPath, tmpDir, {
simulate: true,
stages: ['06-review'],
});
expect(result.manifest.mode).toBe('simulated');
expect(result.manifest.stages['06-review']?.status).toBe('simulated');
for (const gateResult of result.manifest.stages['06-review']?.gateResults ?? []) {
expect(gateResult.outcome).toBe('simulated');
}
});
it('deploy stage fails closed without a wired ci-pipeline provider in normal mode', async () => {
const executor = createTypedExecutor();
await expect(
runPipeline(briefPath, tmpDir, {
executor,
stages: ['09-deploy'],
}),
).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_NO_CI_PIPELINE',
});
});
});
describe('fail-closed: no vacuous gate commands remain', () => {
it('stage constants contain no echo/synthetic-approval, vacuous true, or empty gate commands', () => {
for (const [stageName, spec] of Object.entries(STAGE_SPECS)) {
for (const gate of spec.qualityGates) {
const serialized = JSON.stringify(gate);
// The echo-review synthetic approval must be gone.
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toContain('echo');
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toMatch(/"verdict"\s*:/);
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toMatch(
/"summary"\s*:\s*"review-pass"/,
);
// No vacuous literal `true` gate.
expect(gate, `stage ${stageName}`).not.toBe('true');
// Command gates must carry a real, non-empty command.
if (isCommandGate(gate)) {
const command = typeof gate === 'string' ? gate : gate.command;
expect(command.trim().length, `stage ${stageName} gate ${serialized}`).toBeGreaterThan(0);
}
}
}
});
it('board tasks contain no vacuous true gates', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-board-gates-'));
try {
const tasks = generateBoardTasks('# Brief', [], tmpDir, 'BOARD-TEST');
for (const task of tasks) {
for (const gate of task.qualityGates) {
expect(gate, `task ${task.id}`).not.toBe('true');
const serialized = JSON.stringify(gate);
expect(serialized, `task ${task.id} gate ${serialized}`).not.toContain('echo');
}
}
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
+34 -161
View File
@@ -12,10 +12,10 @@ import {
resumePipeline,
getPipelineStatus,
} from '../src/pipeline-runner.js';
import type { ForgeTask, ForgeTaskResult, RunManifest, TaskExecutor } from '../src/types.js';
import { gateLabel, isCommandGate } from '../src/outcomes.js';
import type { ForgeTask, RunManifest, TaskExecutor } from '../src/types.js';
import type { TaskResult } from '@mosaicstack/macp';
/** Mock TaskExecutor that records submitted tasks and returns typed results. */
/** Mock TaskExecutor that records submitted tasks and returns success. */
function createMockExecutor(options?: {
failStage?: string;
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
@@ -25,7 +25,7 @@ function createMockExecutor(options?: {
async submitTask(task: ForgeTask) {
submittedTasks.push(task);
},
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
async waitForCompletion(taskId: string): Promise<TaskResult> {
const failStage = options?.failStage;
const task = submittedTasks.find((t) => t.id === taskId);
const stageName = task?.metadata?.['stageName'] as string | undefined;
@@ -33,8 +33,7 @@ function createMockExecutor(options?: {
if (failStage && stageName === failStage) {
return {
task_id: taskId,
outcome: 'failed',
reason: 'mock task failure',
status: 'failed',
completed_at: new Date().toISOString(),
exit_code: 1,
gate_results: [],
@@ -42,17 +41,10 @@ function createMockExecutor(options?: {
}
return {
task_id: taskId,
outcome: 'passed',
reason: 'mock verified',
status: 'completed',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: (task?.qualityGates ?? [])
.filter((gate) => isCommandGate(gate))
.map((gate) => ({
gate: gateLabel(gate),
outcome: 'passed' as const,
reason: 'mock verified',
})),
gate_results: [],
};
},
async getTaskStatus() {
@@ -164,13 +156,12 @@ describe('runPipeline', () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '05-coding'],
stages: ['00-intake', '00b-discovery'],
});
expect(result.runId).toMatch(/^\d{8}-\d{6}$/);
expect(result.stages).toEqual(['00-intake', '05-coding']);
expect(result.stages).toEqual(['00-intake', '00b-discovery']);
expect(result.manifest.status).toBe('completed');
expect(result.manifest.mode).toBe('normal');
expect(executor.submittedTasks).toHaveLength(2);
});
@@ -189,17 +180,12 @@ describe('runPipeline', () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '05-coding'],
stages: ['00-intake', '00b-discovery'],
});
const manifest = loadManifest(result.runDir);
expect(manifest.stages['00-intake']?.status).toBe('passed');
expect(manifest.stages['05-coding']?.status).toBe('passed');
expect(manifest.stages['05-coding']?.gateResults?.map((g) => g.outcome)).toEqual([
'passed',
'passed',
'passed',
]);
expect(manifest.stages['00b-discovery']?.status).toBe('passed');
});
it('respects CLI class override', async () => {
@@ -229,7 +215,7 @@ describe('runPipeline', () => {
const executor = createMockExecutor();
await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '05-coding', '08-test'],
stages: ['00-intake', '00b-discovery', '02-planning-1'],
});
expect(executor.submittedTasks[0]!.dependsOn).toBeUndefined();
@@ -238,14 +224,14 @@ describe('runPipeline', () => {
});
it('handles stage failure', async () => {
const executor = createMockExecutor({ failStage: '05-coding' });
const executor = createMockExecutor({ failStage: '00b-discovery' });
await expect(
runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '05-coding'],
stages: ['00-intake', '00b-discovery'],
}),
).rejects.toThrow('Stage 05-coding failed');
).rejects.toThrow('Stage 00b-discovery failed');
});
it('marks manifest as failed on stage failure', async () => {
@@ -284,143 +270,30 @@ describe('resumePipeline', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('resumes from first incomplete stage and fails closed at the next provider gate', async () => {
// Simulate a run whose authority stages were approved out-of-band
// (recorded as passed) and whose coding stage failed mechanically.
const runId = '20260101-000000';
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' };
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '05-coding',
status: 'failed',
stages: {
'00-intake': passed,
'00b-discovery': passed,
'02-planning-1': passed,
'03-planning-2': passed,
'04-planning-3': passed,
'05-coding': { status: 'failed', reason: 'gate failed' },
},
});
it('resumes from first incomplete stage', async () => {
// First run fails on discovery
const executor1 = createMockExecutor({ failStage: '00b-discovery' });
let runDir: string;
// Resume re-runs 05-coding (the first non-passed stage), then fails
// closed at 06-review because no reviewer provider is wired.
const executor = createMockExecutor();
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_NO_REVIEWER',
});
const manifest = loadManifest(runDir);
expect(manifest.stages['05-coding']?.status).toBe('passed');
expect(manifest.stages['06-review']?.status).toBe('blocked');
expect(manifest.status).toBe('failed');
});
it('resumes to completion as simulated under explicit simulate', async () => {
const runId = '20260101-000003';
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' };
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '05-coding',
status: 'failed',
stages: {
'00-intake': passed,
'00b-discovery': passed,
'02-planning-1': passed,
'03-planning-2': passed,
'04-planning-3': passed,
'05-coding': { status: 'failed', reason: 'gate failed' },
},
});
const result = await resumePipeline(runDir, undefined, { simulate: true });
expect(result.manifest.status).toBe('simulated');
expect(result.manifest.mode).toBe('simulated');
expect(result.stages[0]).toBe('05-coding');
for (const stage of result.stages) {
expect(result.manifest.stages[stage]?.status).toBe('simulated');
try {
await runPipeline(briefPath, tmpDir, {
executor: executor1,
stages: ['00-intake', '00b-discovery', '02-planning-1'],
});
} catch {
// expected
}
});
it('fails closed on resume when the next stage needs authority sign-off', async () => {
const runId = '20260101-000001';
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {
'00-intake': { status: 'passed' },
},
});
const runsDir = path.join(tmpDir, '.forge', 'runs');
runDir = path.join(runsDir, fs.readdirSync(runsDir)[0]!);
const executor = createMockExecutor();
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_AUTHORITY_REQUIRED',
});
// Resume should pick up from 00b-discovery
const executor2 = createMockExecutor();
const result = await resumePipeline(runDir, executor2);
const manifest = loadManifest(runDir);
expect(manifest.stages['00b-discovery']?.status).toBe('waiting-for-authority');
expect(manifest.status).toBe('waiting-for-authority');
});
it('fails closed on resume without an executor or --simulate', async () => {
const runId = '20260101-000002';
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {
'00-intake': { status: 'passed' },
},
});
await expect(resumePipeline(runDir)).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_NO_EXECUTOR',
});
expect(result.manifest.status).toBe('completed');
// Should have re-run from 00b-discovery onward
expect(result.stages[0]).toBe('00b-discovery');
});
});
+2 -15
View File
@@ -95,14 +95,7 @@ export function generateBoardTasks(
briefPath,
resultPath: resultRelPath,
timeoutSeconds: 120,
qualityGates: [
{
kind: 'authority',
capability: 'board-approval',
reason:
'persona evaluation is judged by board synthesis (authority review); no mechanical gate exists',
},
],
qualityGates: ['true'],
metadata: {
personaName: persona.name,
personaSlug: persona.slug,
@@ -128,13 +121,7 @@ export function generateBoardTasks(
timeoutSeconds: 120,
dependsOn: personaTaskIds,
dependsOnPolicy: 'all_terminal',
qualityGates: [
{
kind: 'authority',
capability: 'board-approval',
reason: 'board synthesis is an authority decision; no mechanical gate exists',
},
],
qualityGates: ['true'],
metadata: {
resultOutputPath: synthesisResult,
inputResultPaths: personaResultPaths,
+1 -96
View File
@@ -1,11 +1,7 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { describe, expect, it } from 'vitest';
import { registerForgeCommand } from './cli.js';
import { loadManifest } from './pipeline-runner.js';
describe('registerForgeCommand', () => {
it('registers a "forge" command on the parent program', () => {
@@ -59,94 +55,3 @@ describe('registerForgeCommand', () => {
}).not.toThrow();
});
});
describe('forge run fail-closed behavior (SDLC-D-035)', () => {
let tmpDir: string;
let briefPath: string;
let errSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let prevExitCode: string | number | null | undefined;
const parse = (args: string[]) => {
const program = new Command();
registerForgeCommand(program);
return program.parseAsync(['forge', ...args], { from: 'user' });
};
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-cli-failclosed-'));
briefPath = path.join(tmpDir, 'brief.md');
fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.');
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
prevExitCode = process.exitCode;
});
afterEach(() => {
errSpy.mockRestore();
logSpy.mockRestore();
process.exitCode = prevExitCode;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('exits nonzero with a typed FORGE_NO_EXECUTOR error when no executor is wired and --simulate is absent', async () => {
await parse(['run', '--brief', briefPath, '--codebase', tmpDir]);
expect(process.exitCode).toBe(1);
const errText = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(errText).toContain('FORGE_NO_EXECUTOR');
// It must never run the pipeline with a stub and report success.
expect(fs.existsSync(path.join(tmpDir, '.forge', 'runs'))).toBe(false);
});
it('completes with typed simulated results and exit 0 under explicit --simulate', async () => {
await parse(['run', '--brief', briefPath, '--codebase', tmpDir, '--simulate']);
expect(process.exitCode).toBeUndefined();
// Loud simulated-mode summary.
const logText = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(logText).toContain('SIMULATED');
// Manifest records the mode and simulated per-result statuses.
const runsDir = path.join(tmpDir, '.forge', 'runs');
const runIds = fs.readdirSync(runsDir);
expect(runIds).toHaveLength(1);
const manifest = loadManifest(path.join(runsDir, runIds[0]!));
expect(manifest.mode).toBe('simulated');
expect(manifest.status).toBe('simulated');
for (const stageStatus of Object.values(manifest.stages)) {
expect(stageStatus?.status).toBe('simulated');
for (const gateResult of stageStatus?.gateResults ?? []) {
expect(gateResult.outcome).toBe('simulated');
}
}
});
it('resume exits nonzero with a typed FORGE_NO_EXECUTOR error without --simulate', async () => {
const runDir = path.join(tmpDir, '.forge', 'runs', '20260101-000000');
fs.mkdirSync(runDir, { recursive: true });
fs.writeFileSync(
path.join(runDir, 'manifest.json'),
JSON.stringify({
runId: '20260101-000000',
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: { '00-intake': { status: 'passed' } },
}),
);
await parse(['resume', '20260101-000000', '--project', tmpDir]);
expect(process.exitCode).toBe(1);
const errText = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(errText).toContain('FORGE_NO_EXECUTOR');
});
});
+48 -122
View File
@@ -5,47 +5,37 @@ import type { Command } from 'commander';
import { classifyBrief } from './brief-classifier.js';
import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js';
import { ForgeCapabilityError } from './errors.js';
import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js';
import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js';
import { createSimulatedExecutor } from './simulated-executor.js';
import type { PipelineOptions, RunManifest, RunMode } from './types.js';
import type { PipelineOptions, RunManifest, TaskExecutor } from './types.js';
// ---------------------------------------------------------------------------
// Stub executor — used when no real executor is wired at CLI invocation time.
// ---------------------------------------------------------------------------
const stubExecutor: TaskExecutor = {
async submitTask(task) {
console.log(` [forge] stage submitted: ${task.id} (${task.title})`);
},
async waitForCompletion(taskId, _timeoutMs) {
console.log(` [forge] stage complete: ${taskId}`);
return {
task_id: taskId,
status: 'completed' as const,
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
};
},
async getTaskStatus(_taskId) {
return 'completed' as const;
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Resolve a run's effective mode, defaulting legacy manifests to normal. */
function runModeOf(manifest: RunManifest): RunMode {
return manifest.mode ?? 'normal';
}
/** Print a loud banner so a simulated run can never be misread as verified. */
function printSimulatedBanner(): void {
console.log('');
console.log('[forge] ===============================================================');
console.log('[forge] MODE: SIMULATED — no stage or gate was really executed.');
console.log('[forge] All results are synthetic and MUST NOT be read as verified');
console.log('[forge] success. Wire a real executor/providers and re-run to verify.');
console.log('[forge] ===============================================================');
}
/** Print a typed error line for fail-closed capability errors. */
function printCapabilityError(err: ForgeCapabilityError): void {
console.error(`[forge] error ${err.code}: ${err.message}`);
console.error(`[forge] missing capability: ${err.capability}`);
}
/** Handle a pipeline error uniformly: typed capability errors get their code. */
function handlePipelineError(err: unknown): void {
if (err instanceof ForgeCapabilityError) {
printCapabilityError(err);
} else {
console.error(`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`);
}
process.exitCode = 1;
}
function formatDuration(startedAt?: string, completedAt?: string): string {
if (!startedAt || !completedAt) return '-';
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
@@ -54,24 +44,19 @@ function formatDuration(startedAt?: string, completedAt?: string): string {
}
function printManifestTable(manifest: RunManifest): void {
const mode = runModeOf(manifest);
console.log(`\nRun ID : ${manifest.runId}`);
console.log(`Status : ${manifest.status}`);
console.log(`Mode : ${mode}`);
if (mode === 'simulated') {
console.log('WARNING: SIMULATED RUN — results are synthetic, not verified success.');
}
console.log(`Brief : ${manifest.brief}`);
console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`);
console.log(`Updated: ${manifest.updatedAt}`);
console.log('');
console.log('Stage'.padEnd(22) + 'Status'.padEnd(24) + 'Duration');
console.log('-'.repeat(60));
console.log('Stage'.padEnd(22) + 'Status'.padEnd(14) + 'Duration');
console.log('-'.repeat(50));
for (const stage of STAGE_SEQUENCE) {
const s = manifest.stages[stage];
if (!s) continue;
const label = (STAGE_LABELS[stage] ?? stage).padEnd(22);
const status = s.status.padEnd(24);
const status = s.status.padEnd(14);
const dur = formatDuration(s.startedAt, s.completedAt);
console.log(`${label}${status}${dur}`);
}
@@ -105,58 +90,23 @@ function listRecentRuns(projectRoot?: string): void {
}
console.log('\nRecent runs:');
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(24) + 'Mode'.padEnd(12) + 'Brief');
console.log('-'.repeat(80));
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(14) + 'Brief');
console.log('-'.repeat(70));
for (const runId of entries) {
const runDir = path.join(runsDir, runId);
try {
const manifest = loadManifest(runDir);
const status = manifest.status.padEnd(24);
const mode = runModeOf(manifest).padEnd(12);
const status = manifest.status.padEnd(14);
const brief = path.basename(manifest.brief);
console.log(`${runId.padEnd(22)}${status}${mode}${brief}`);
console.log(`${runId.padEnd(22)}${status}${brief}`);
} catch {
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(24)}`);
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`);
}
}
console.log('');
}
/**
* Apply the exit-code policy for a finished pipeline run (SDLC-D-035):
*
* - exit 0 only for a verified `completed` normal run, or for an overall
* `simulated` run when the caller explicitly passed --simulate;
* - anything else exits nonzero so it can never be read as success.
*/
function applyRunExitPolicy(result: { manifest: RunManifest; runDir: string }, simulate: boolean) {
const { manifest } = result;
if (runModeOf(manifest) === 'simulated') {
if (!simulate || manifest.status !== 'simulated') {
console.error(
'[forge] error FORGE_MODE_MISMATCH: run reports simulated results without an explicit, ' +
'consistent --simulate request; refusing to report success.',
);
process.exitCode = 1;
return;
}
printSimulatedBanner();
console.log(`[forge] run directory: ${result.runDir}`);
return; // exit 0 — the caller explicitly opted into simulation
}
if (manifest.status !== 'completed') {
console.error(`[forge] run did not complete: terminal status '${manifest.status}'`);
process.exitCode = 1;
return;
}
console.log(`[forge] pipeline complete (mode: normal): ${manifest.runId}`);
console.log(`[forge] run directory: ${result.runDir}`);
}
// ---------------------------------------------------------------------------
// Register function
// ---------------------------------------------------------------------------
@@ -179,11 +129,6 @@ export function registerForgeCommand(parent: Command): void {
.option('--config <path>', 'Path to forge config file (.forge/config.yaml)')
.option('--codebase <path>', 'Codebase root to pass to the pipeline', process.cwd())
.option('--dry-run', 'Print planned stages without executing', false)
.option(
'--simulate',
'Simulate execution without real providers (every result is typed simulated, never verified)',
false,
)
.action(
async (opts: {
brief: string;
@@ -192,7 +137,6 @@ export function registerForgeCommand(parent: Command): void {
config?: string;
codebase: string;
dryRun: boolean;
simulate: boolean;
}) => {
const briefPath = path.resolve(opts.brief);
@@ -205,22 +149,14 @@ export function registerForgeCommand(parent: Command): void {
const briefContent = fs.readFileSync(briefPath, 'utf-8');
const briefClass = classifyBrief(briefContent);
const projectRoot = opts.codebase;
// A real executor is never wired at CLI invocation time today, so the
// only executor we may construct is the explicitly-requested simulated
// one. Normal mode fails closed with FORGE_NO_EXECUTOR.
const executor = opts.simulate ? createSimulatedExecutor() : undefined;
if (opts.resume) {
const runId = opts.runId ?? generateRunId();
const runDir = resolveRunDir(runId, projectRoot);
console.log(`[forge] resuming run: ${runId}`);
try {
const { resumePipeline } = await import('./pipeline-runner.js');
const result = await resumePipeline(runDir, executor, { simulate: opts.simulate });
applyRunExitPolicy(result, opts.simulate);
} catch (err) {
handlePipelineError(err);
}
const { resumePipeline } = await import('./pipeline-runner.js');
const result = await resumePipeline(runDir, stubExecutor);
console.log(`[forge] pipeline complete: ${result.runId}`);
return;
}
@@ -228,8 +164,7 @@ export function registerForgeCommand(parent: Command): void {
briefClass,
codebase: projectRoot,
dryRun: opts.dryRun,
executor,
simulate: opts.simulate,
executor: stubExecutor,
};
if (opts.dryRun) {
@@ -245,15 +180,16 @@ export function registerForgeCommand(parent: Command): void {
console.log(`[forge] starting pipeline for brief: ${briefPath}`);
console.log(`[forge] classified as: ${briefClass}`);
if (opts.simulate) {
console.log('[forge] mode: SIMULATED (explicit --simulate)');
}
try {
const result = await runPipeline(briefPath, projectRoot, pipelineOptions);
applyRunExitPolicy(result, opts.simulate);
console.log(`[forge] pipeline complete: ${result.runId}`);
console.log(`[forge] run directory: ${result.runDir}`);
} catch (err) {
handlePipelineError(err);
console.error(
`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
}
},
);
@@ -288,12 +224,7 @@ export function registerForgeCommand(parent: Command): void {
.command('resume <runId>')
.description('Resume a stopped or failed pipeline run')
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
.option(
'--simulate',
'Simulate execution without real providers (every result is typed simulated, never verified)',
false,
)
.action(async (runId: string, opts: { project: string; simulate: boolean }) => {
.action(async (runId: string, opts: { project: string }) => {
const runDir = resolveRunDir(runId, opts.project);
if (!fs.existsSync(runDir)) {
@@ -303,20 +234,15 @@ export function registerForgeCommand(parent: Command): void {
}
console.log(`[forge] resuming run: ${runId}`);
if (opts.simulate) {
console.log('[forge] mode: SIMULATED (explicit --simulate)');
}
// No real executor is wired at CLI invocation time; only the explicitly
// requested simulated executor may be constructed (fail closed otherwise).
const executor = opts.simulate ? createSimulatedExecutor() : undefined;
try {
const { resumePipeline } = await import('./pipeline-runner.js');
const result = await resumePipeline(runDir, executor, { simulate: opts.simulate });
applyRunExitPolicy(result, opts.simulate);
const result = await resumePipeline(runDir, stubExecutor);
console.log(`[forge] pipeline complete: ${result.runId}`);
console.log(`[forge] run directory: ${result.runDir}`);
} catch (err) {
handlePipelineError(err);
console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
+12 -72
View File
@@ -9,16 +9,7 @@ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.
/** Pipeline asset directory (stages, agents, rails, gates, templates). */
export const PIPELINE_DIR = path.join(PACKAGE_ROOT, 'pipeline');
/** Stage specifications defines every pipeline stage.
*\n * Gate semantics (SDLC-D-035): every gate is one of
* - a real command string / GateEntry a mechanical runner can execute,
* - an `authority` gate (human/board sign-off; produces waiting-for-authority),
* - a `provider` gate (requires a wired provider such as a reviewer or CI pipeline).
*
* Vacuous gates (`true`, echo'd synthetic approvals, placeholder ci-pipeline
* commands) are forbidden: a stage whose gate has no real implementation
* fails closed instead of passing.
*/
/** Stage specifications — defines every pipeline stage. */
export const STAGE_SPECS: Record<string, StageSpec> = {
'00-intake': {
number: '00',
@@ -36,13 +27,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research',
gate: 'discovery-complete',
promptFile: '00b-discovery.md',
qualityGates: [
{
kind: 'authority',
capability: 'discovery-complete',
reason: 'discovery completion is attested by an authority; no mechanical check exists',
},
],
qualityGates: ['true'],
},
'01-board': {
number: '01',
@@ -51,13 +36,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'review',
gate: 'board-approval',
promptFile: '01-board.md',
qualityGates: [
{
kind: 'authority',
capability: 'board-approval',
reason: 'board approval is a board/human decision; no mechanical gate exists',
},
],
qualityGates: [{ type: 'ci-pipeline', command: 'board-approval (via board-tasks)' }],
},
'01b-brief-analyzer': {
number: '01b',
@@ -66,13 +45,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research',
gate: 'brief-analysis-complete',
promptFile: '01-board.md',
qualityGates: [
{
kind: 'authority',
capability: 'brief-analysis-complete',
reason: 'brief analysis completion is attested by an authority; no mechanical check exists',
},
],
qualityGates: ['true'],
},
'02-planning-1': {
number: '02',
@@ -81,13 +54,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research',
gate: 'architecture-approval',
promptFile: '02-planning-1-architecture.md',
qualityGates: [
{
kind: 'authority',
capability: 'architecture-approval',
reason: 'ADR approval requires authority sign-off; no mechanical check exists',
},
],
qualityGates: ['true'],
},
'03-planning-2': {
number: '03',
@@ -96,14 +63,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research',
gate: 'implementation-approval',
promptFile: '03-planning-2-implementation.md',
qualityGates: [
{
kind: 'authority',
capability: 'implementation-approval',
reason:
'implementation spec approval requires authority sign-off; no mechanical check exists',
},
],
qualityGates: ['true'],
},
'04-planning-3': {
number: '04',
@@ -112,14 +72,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research',
gate: 'decomposition-approval',
promptFile: '04-planning-3-decomposition.md',
qualityGates: [
{
kind: 'authority',
capability: 'decomposition-approval',
reason:
'task decomposition approval requires authority sign-off; no mechanical check exists',
},
],
qualityGates: ['true'],
},
'05-coding': {
number: '05',
@@ -139,10 +92,9 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
promptFile: '06-review.md',
qualityGates: [
{
kind: 'provider',
capability: 'reviewer',
reason:
'review verdicts require a wired reviewer provider; synthetic approvals are not permitted',
type: 'ai-review',
command:
'echo \'{"summary":"review-pass","verdict":"approve","findings":[],"stats":{"blockers":0,"should_fix":0,"suggestions":0}}\'',
},
],
},
@@ -153,13 +105,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'coding',
gate: 're-review',
promptFile: '07-remediate.md',
qualityGates: [
{
kind: 'authority',
capability: 're-review',
reason: 'remediation re-review is an approval-based gate; no mechanical check exists',
},
],
qualityGates: ['true'],
},
'08-test': {
number: '08',
@@ -177,13 +123,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'deploy',
gate: 'deploy-verification',
promptFile: '09-deploy.md',
qualityGates: [
{
kind: 'provider',
capability: 'ci-pipeline',
reason: 'deploy verification requires a wired CI pipeline provider',
},
],
qualityGates: [{ type: 'ci-pipeline', command: 'deploy-verification' }],
},
};
-46
View File
@@ -1,46 +0,0 @@
/**
* Typed fail-closed capability errors (SDLC-D-035).
*
* A Forge run must fail closed when a required capability (executor, reviewer
* provider, CI pipeline, authority sign-off) is missing. These typed errors
* name the missing capability so callers can distinguish "not wired" from
* ordinary execution failures.
*/
/** Closed set of typed Forge capability error codes. */
export const FORGE_ERROR_CODES = [
'FORGE_NO_EXECUTOR',
'FORGE_NO_REVIEWER',
'FORGE_NO_CI_PIPELINE',
'FORGE_NO_PROVIDER',
'FORGE_AUTHORITY_REQUIRED',
] as const;
export type ForgeErrorCode = (typeof FORGE_ERROR_CODES)[number];
/** Raised when a required capability is missing and the pipeline must fail closed. */
export class ForgeCapabilityError extends Error {
/** Typed error code from the closed FORGE_ERROR_CODES set. */
readonly code: ForgeErrorCode;
/** The missing capability, e.g. `task-executor`, `reviewer`, `board-approval`. */
readonly capability: string;
constructor(code: ForgeErrorCode, capability: string, message: string) {
super(message);
this.name = 'ForgeCapabilityError';
this.code = code;
this.capability = capability;
}
}
/** Map a provider gate capability to its typed error code. */
export function providerErrorCode(capability: string): ForgeErrorCode {
switch (capability) {
case 'reviewer':
return 'FORGE_NO_REVIEWER';
case 'ci-pipeline':
return 'FORGE_NO_CI_PIPELINE';
default:
return 'FORGE_NO_PROVIDER';
}
}
-26
View File
@@ -5,13 +5,6 @@ export type {
StageSpec,
BriefClass,
ClassSource,
ForgeOutcome,
AuthorityGate,
ProviderGate,
ForgeGate,
ForgeGateResult,
ForgeTaskResult,
RunMode,
StageStatus,
RunManifest,
ForgeTaskStatus,
@@ -88,24 +81,5 @@ export {
getPipelineStatus,
} from './pipeline-runner.js';
// Fail-closed errors and typed outcome model (SDLC-D-035)
export { FORGE_ERROR_CODES, ForgeCapabilityError, providerErrorCode } from './errors.js';
export type { ForgeErrorCode } from './errors.js';
export {
isSatisfyingOutcome,
isCapabilityGate,
isCommandGate,
gateLabel,
uniformGateResults,
simulatedGateResults,
waitingGateResults,
blockedGateResults,
evaluateStageGates,
} from './outcomes.js';
export type { StageEvaluation } from './outcomes.js';
// Simulated executor (explicit --simulate only)
export { createSimulatedExecutor } from './simulated-executor.js';
// CLI
export { registerForgeCommand } from './cli.js';
-147
View File
@@ -1,147 +0,0 @@
import type { GateEntry } from '@mosaicstack/macp';
import type {
AuthorityGate,
ForgeGate,
ForgeGateResult,
ForgeOutcome,
ForgeTaskResult,
ProviderGate,
} from './types.js';
/**
* Gate and dependency satisfaction predicate (SDLC-D-035).
*
* ONLY a verified `passed` outcome satisfies. Every other member of the closed
* outcome set including `simulated` is non-satisfying, so a simulated or
* authority-blocked result can never be read as success-by-verification.
*/
export function isSatisfyingOutcome(outcome: ForgeOutcome): boolean {
return outcome === 'passed';
}
/** Whether a gate is an authority or provider gate (capability-based, command-less). */
export function isCapabilityGate(gate: ForgeGate): gate is AuthorityGate | ProviderGate {
if (typeof gate !== 'object' || gate === null) return false;
const kind = (gate as Record<string, unknown>)['kind'];
return kind === 'authority' || kind === 'provider';
}
/** Whether a gate definition carries a real command a mechanical runner can execute. */
export function isCommandGate(gate: ForgeGate): gate is string | GateEntry {
if (typeof gate === 'string') {
return gate.trim().length > 0;
}
if (isCapabilityGate(gate)) {
// Authority and provider gates are satisfied by a capability, not a command.
return false;
}
return typeof gate.command === 'string' && gate.command.trim().length > 0;
}
/** Typed label identifying a gate in results and logs. */
export function gateLabel(gate: ForgeGate): string {
if (typeof gate === 'string') return gate;
if (isCapabilityGate(gate)) return `${gate.kind}:${gate.capability}`;
return gate.command || gate.type || 'unnamed-gate';
}
/** Reason string stamped on every simulated gate result. */
export const SIMULATED_GATE_REASON =
'simulated execution (--simulate): gate was not evaluated by a real implementation';
/** Build typed gate results with a uniform outcome for a stage's declared gates. */
export function uniformGateResults(
gates: ForgeGate[],
outcome: ForgeOutcome,
reason: string,
): ForgeGateResult[] {
return gates.map((gate) => ({ gate: gateLabel(gate), outcome, reason }));
}
/** Typed simulated gate results — used exclusively in `--simulate` runs. */
export function simulatedGateResults(gates: ForgeGate[]): ForgeGateResult[] {
return uniformGateResults(gates, 'simulated', SIMULATED_GATE_REASON);
}
/** Typed waiting-for-authority gate results for approval-based stages. */
export function waitingGateResults(gates: ForgeGate[], reason: string): ForgeGateResult[] {
return uniformGateResults(gates, 'waiting-for-authority', reason);
}
/** Typed blocked gate results for stages whose provider capability is not wired. */
export function blockedGateResults(gates: ForgeGate[], reason: string): ForgeGateResult[] {
return uniformGateResults(gates, 'blocked', reason);
}
/** Outcome of evaluating a completed stage in normal mode. */
export interface StageEvaluation {
outcome: ForgeOutcome;
reason: string;
gateResults: ForgeGateResult[];
}
/**
* Evaluate a stage's declared gates against the executor's typed result.
*
* Fail-closed mapping:
* - a `simulated` task or gate outcome in normal mode maps to `error`
* - a missing gate result for a required command gate maps to `blocked`
* - a non-passing task outcome propagates as the stage outcome
* - only verified `passed` task and gate outcomes yield a `passed` stage
*/
export function evaluateStageGates(
stageName: string,
gates: ForgeGate[],
result: ForgeTaskResult,
): StageEvaluation {
const gateResults = result.gate_results ?? [];
if (result.outcome === 'simulated') {
return {
outcome: 'error',
reason: `executor reported a simulated outcome for stage '${stageName}' in normal mode — refusing to treat simulated results as verified`,
gateResults,
};
}
if (!isSatisfyingOutcome(result.outcome)) {
return {
outcome: result.outcome,
reason: `task outcome is '${result.outcome}': ${result.reason}`,
gateResults,
};
}
for (const gate of gates) {
// Authority and provider gates are pre-flighted before execution; they have
// no mechanical result to verify here.
if (!isCommandGate(gate)) continue;
const label = gateLabel(gate);
const gateResult = gateResults.find((r) => r.gate === label);
if (!gateResult) {
return {
outcome: 'blocked',
reason: `no gate result was reported for required gate '${label}' (stage '${stageName}')`,
gateResults,
};
}
if (!isSatisfyingOutcome(gateResult.outcome)) {
return {
outcome: gateResult.outcome === 'simulated' ? 'error' : gateResult.outcome,
reason: `gate '${label}' outcome is '${gateResult.outcome}': ${gateResult.reason}`,
gateResults,
};
}
}
return {
outcome: 'passed',
reason:
gates.length === 0
? "stage declares no gates; task outcome 'passed' accepted"
: 'all declared gates verified passed',
gateResults,
};
}
+99 -227
View File
@@ -1,33 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';
import { STAGE_SEQUENCE, STAGE_SPECS } from './constants.js';
import { STAGE_SEQUENCE } from './constants.js';
import { determineBriefClass, stagesForClass } from './brief-classifier.js';
import { ForgeCapabilityError, providerErrorCode } from './errors.js';
import {
blockedGateResults,
evaluateStageGates,
isCapabilityGate,
simulatedGateResults,
waitingGateResults,
} from './outcomes.js';
import { mapStageToTask } from './stage-adapter.js';
import { createSimulatedExecutor } from './simulated-executor.js';
import type {
ForgeTask,
ForgeTaskResult,
PipelineOptions,
PipelineResult,
RunManifest,
RunMode,
StageStatus,
TaskExecutor,
} from './types.js';
/** Reason stamped on stages that complete under explicit simulation. */
const SIMULATED_STAGE_REASON =
'simulated execution (--simulate): stage was not executed by a real executor';
/**
* Generate a timestamp-based run ID.
*/
@@ -62,7 +47,6 @@ function createManifest(opts: {
briefClass: RunManifest['briefClass'];
classSource: RunManifest['classSource'];
forceBoard: boolean;
mode: RunMode;
runDir: string;
}): RunManifest {
const ts = nowISO();
@@ -73,7 +57,6 @@ function createManifest(opts: {
briefClass: opts.briefClass,
classSource: opts.classSource,
forceBoard: opts.forceBoard,
mode: opts.mode,
createdAt: ts,
updatedAt: ts,
currentStage: '',
@@ -125,199 +108,20 @@ export function selectStages(stages?: string[], skipTo?: string): string[] {
return selected.slice(skipIndex);
}
/**
* Fail closed when the required executor capability is missing (SDLC-D-035).
*/
function requireExecutor(executor: TaskExecutor | undefined, simulate: boolean): TaskExecutor {
if (executor) return executor;
if (simulate) return createSimulatedExecutor({ log: false });
throw new ForgeCapabilityError(
'FORGE_NO_EXECUTOR',
'task-executor',
'no task executor is wired; refusing to run the pipeline with a stub executor (fail closed). ' +
'Pass --simulate to opt into explicitly simulated execution.',
);
}
/**
* Pre-flight a stage's gates in normal mode (fail closed, SDLC-D-035).
*
* - authority gates: record a typed `waiting-for-authority` stage result and
* raise FORGE_AUTHORITY_REQUIRED approval-based gates never pass vacuously.
* - provider gates: record a typed `blocked` stage result and raise the typed
* capability error for the missing provider.
*
* Returns the stage status to record when the pre-flight blocks, or undefined
* when the stage may proceed.
*/
function preflightStageGates(
stageName: string,
manifest: RunManifest,
): { status: StageStatus; error: ForgeCapabilityError } | undefined {
const spec = STAGE_SPECS[stageName];
if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`);
for (const gate of spec.qualityGates) {
if (!isCapabilityGate(gate)) continue;
const startedAt = manifest.stages[stageName]?.startedAt;
const completedAt = nowISO();
if (gate.kind === 'authority') {
const reason = `gate '${gate.capability}' requires authority sign-off; no mechanical implementation exists (${gate.reason})`;
return {
status: {
status: 'waiting-for-authority',
reason,
startedAt,
completedAt,
gateResults: waitingGateResults(spec.qualityGates, reason),
},
error: new ForgeCapabilityError(
'FORGE_AUTHORITY_REQUIRED',
gate.capability,
`stage '${stageName}' is blocked on authority gate '${gate.capability}': ${gate.reason}. ` +
'The pipeline fails closed instead of passing vacuously. Record the approval out-of-band ' +
'or run with --simulate for explicitly simulated execution.',
),
};
}
const reason = `gate '${gate.capability}' requires provider '${gate.capability}' and none is wired (${gate.reason})`;
return {
status: {
status: 'blocked',
reason,
startedAt,
completedAt,
gateResults: blockedGateResults(spec.qualityGates, reason),
},
error: new ForgeCapabilityError(
providerErrorCode(gate.capability),
gate.capability,
`stage '${stageName}' requires provider '${gate.capability}' which is not wired: ${gate.reason}. ` +
'The pipeline fails closed instead of passing vacuously.',
),
};
}
return undefined;
}
/**
* Execute the given stage tasks sequentially, updating the manifest.
*
* Normal mode requires a real executor and evaluates every declared command
* gate through the typed outcome model; any non-verified result fails closed.
* Simulate mode types every stage and gate result as `simulated`.
*/
async function executeStages(opts: {
manifest: RunManifest;
runDir: string;
tasks: ForgeTask[];
stageNames: string[];
executor: TaskExecutor;
simulate: boolean;
}): Promise<void> {
const { manifest, runDir, tasks, stageNames, executor, simulate } = opts;
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i]!;
const stageName = stageNames[i]!;
const spec = STAGE_SPECS[stageName];
if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`);
// Update manifest: stage in progress
manifest.currentStage = stageName;
manifest.stages[stageName] = {
status: 'in_progress',
startedAt: nowISO(),
};
saveManifest(runDir, manifest);
// Fail-closed pre-flight (normal mode only): authority/provider gates have
// no mechanical implementation and must never pass vacuously.
if (!simulate) {
const blocked = preflightStageGates(stageName, manifest);
if (blocked) {
manifest.stages[stageName] = blocked.status;
manifest.status =
blocked.status.status === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed';
saveManifest(runDir, manifest);
throw blocked.error;
}
}
let result: ForgeTaskResult;
try {
await executor.submitTask(task);
result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
} catch (error) {
// Process errors (including timeouts) map to the fail-closed `error` outcome.
const reason = error instanceof Error ? error.message : String(error);
manifest.stages[stageName] = {
status: 'error',
reason: `executor error: ${reason}`,
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
gateResults: [],
};
manifest.status = 'failed';
saveManifest(runDir, manifest);
throw error instanceof Error ? error : new Error(reason);
}
if (simulate) {
manifest.stages[stageName] = {
status: 'simulated',
reason: SIMULATED_STAGE_REASON,
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
gateResults: simulatedGateResults(spec.qualityGates),
};
saveManifest(runDir, manifest);
continue;
}
const evaluation = evaluateStageGates(stageName, spec.qualityGates, result);
manifest.stages[stageName] = {
status: evaluation.outcome,
reason: evaluation.reason,
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
gateResults: evaluation.gateResults,
};
if (evaluation.outcome !== 'passed') {
manifest.status =
evaluation.outcome === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed';
saveManifest(runDir, manifest);
throw new Error(`Stage ${stageName} ${evaluation.outcome}: ${evaluation.reason}`);
}
saveManifest(runDir, manifest);
}
}
/**
* Run the Forge pipeline.
*
* 1. Fail closed unless a real executor is wired or simulation is explicit
* 2. Classify the brief
* 3. Generate a run ID and create run directory
* 4. Map stages to tasks and submit to TaskExecutor
* 5. Track manifest with typed stage outcomes
* 6. Return pipeline result
* 1. Classify the brief
* 2. Generate a run ID and create run directory
* 3. Map stages to tasks and submit to TaskExecutor
* 4. Track manifest with stage statuses
* 5. Return pipeline result
*/
export async function runPipeline(
briefPath: string,
projectRoot: string,
options: PipelineOptions,
): Promise<PipelineResult> {
const simulate = options.simulate ?? false;
const executor = requireExecutor(options.executor, simulate);
const mode: RunMode = simulate ? 'simulated' : 'normal';
const resolvedRoot = path.resolve(projectRoot);
const resolvedBrief = path.resolve(briefPath);
const briefContent = fs.readFileSync(resolvedBrief, 'utf-8');
@@ -342,7 +146,6 @@ export async function runPipeline(
briefClass,
classSource,
forceBoard: options.forceBoard ?? false,
mode,
runDir,
});
@@ -369,10 +172,54 @@ export async function runPipeline(
}
// Execute stages
await executeStages({ manifest, runDir, tasks, stageNames: selectedStages, executor, simulate });
const { executor } = options;
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i]!;
const stageName = selectedStages[i]!;
// All stages reached a terminal state for this mode
manifest.status = simulate ? 'simulated' : 'completed';
// Update manifest: stage in progress
manifest.currentStage = stageName;
manifest.stages[stageName] = {
status: 'in_progress',
startedAt: nowISO(),
};
saveManifest(runDir, manifest);
try {
await executor.submitTask(task);
const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
// Update manifest: stage completed or failed
const stageStatus: StageStatus = {
status: result.status === 'completed' ? 'passed' : 'failed',
startedAt: manifest.stages[stageName]!.startedAt,
completedAt: nowISO(),
};
manifest.stages[stageName] = stageStatus;
if (result.status !== 'completed') {
manifest.status = 'failed';
saveManifest(runDir, manifest);
throw new Error(`Stage ${stageName} failed with status: ${result.status}`);
}
saveManifest(runDir, manifest);
} catch (error) {
if (!manifest.stages[stageName]?.completedAt) {
manifest.stages[stageName] = {
status: 'failed',
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
};
}
manifest.status = 'failed';
saveManifest(runDir, manifest);
throw error;
}
}
// All stages passed
manifest.status = 'completed';
saveManifest(runDir, manifest);
return {
@@ -387,30 +234,22 @@ export async function runPipeline(
}
/**
* Resume a pipeline from the last non-passed stage.
* Resume a pipeline from the last incomplete stage.
*/
export async function resumePipeline(
runDir: string,
executor?: TaskExecutor,
options?: { simulate?: boolean },
executor: TaskExecutor,
): Promise<PipelineResult> {
const simulate = options?.simulate ?? false;
const wiredExecutor = requireExecutor(executor, simulate);
const mode: RunMode = simulate ? 'simulated' : 'normal';
const manifest = loadManifest(runDir);
const resolvedRoot = path.dirname(path.dirname(path.dirname(runDir))); // .forge/runs/{id} → project root
const briefContent = fs.readFileSync(manifest.brief, 'utf-8');
const allStages = stagesForClass(manifest.briefClass, manifest.forceBoard);
manifest.mode = mode;
// Find first non-satisfying stage (only a verified `passed` counts as done;
// simulated and waiting-for-authority stages are re-run).
// Find first non-passed stage
const resumeFrom = allStages.find((s) => manifest.stages[s]?.status !== 'passed');
if (!resumeFrom) {
manifest.status = mode === 'simulated' ? 'simulated' : 'completed';
manifest.status = 'completed';
saveManifest(runDir, manifest);
return {
runId: manifest.runId,
@@ -445,16 +284,49 @@ export async function resumePipeline(
tasks.push(task);
}
await executeStages({
manifest,
runDir,
tasks,
stageNames: remainingStages,
executor: wiredExecutor,
simulate,
});
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i]!;
const stageName = remainingStages[i]!;
manifest.status = simulate ? 'simulated' : 'completed';
manifest.currentStage = stageName;
manifest.stages[stageName] = {
status: 'in_progress',
startedAt: nowISO(),
};
saveManifest(runDir, manifest);
try {
await executor.submitTask(task);
const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
manifest.stages[stageName] = {
status: result.status === 'completed' ? 'passed' : 'failed',
startedAt: manifest.stages[stageName]!.startedAt,
completedAt: nowISO(),
};
if (result.status !== 'completed') {
manifest.status = 'failed';
saveManifest(runDir, manifest);
throw new Error(`Stage ${stageName} failed with status: ${result.status}`);
}
saveManifest(runDir, manifest);
} catch (error) {
if (!manifest.stages[stageName]?.completedAt) {
manifest.stages[stageName] = {
status: 'failed',
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
};
}
manifest.status = 'failed';
saveManifest(runDir, manifest);
throw error;
}
}
manifest.status = 'completed';
saveManifest(runDir, manifest);
return {
-32
View File
@@ -1,32 +0,0 @@
import type { ForgeTask, ForgeTaskResult, TaskExecutor } from './types.js';
/**
* Simulated executor used ONLY when the caller explicitly passes --simulate.
*
* It submits no real work and returns typed `simulated` results so a simulated
* run can never be confused with a verified one. In normal mode (no --simulate)
* the CLI refuses to run at all with FORGE_NO_EXECUTOR instead of wiring this
* stub (fail closed, SDLC-D-035).
*/
export function createSimulatedExecutor(options?: { log?: boolean }): TaskExecutor {
const log = options?.log ?? true;
return {
async submitTask(task: ForgeTask) {
if (log) console.log(` [forge:simulated] stage submitted: ${task.id} (${task.title})`);
},
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
if (log) console.log(` [forge:simulated] stage complete: ${taskId}`);
return {
task_id: taskId,
outcome: 'simulated',
reason: 'no executor wired; simulated execution requested via --simulate',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
};
},
async getTaskStatus() {
return 'completed' as const;
},
};
}
+7 -88
View File
@@ -1,4 +1,4 @@
import type { GateEntry } from '@mosaicstack/macp';
import type { GateEntry, TaskResult } from '@mosaicstack/macp';
/** Stage dispatch mode. */
export type StageDispatch = 'exec' | 'yolo' | 'pi';
@@ -6,58 +6,6 @@ export type StageDispatch = 'exec' | 'yolo' | 'pi';
/** Stage type — determines agent selection and gate requirements. */
export type StageType = 'research' | 'review' | 'coding' | 'deploy';
/**
* Typed outcome for every gate and stage evaluation closed set (SDLC-D-035).
*
* Only `passed` means "verified by a real implementation". `simulated` is
* produced exclusively in explicit `--simulate` runs and is never satisfying.
*/
export type ForgeOutcome =
| 'passed'
| 'failed'
| 'blocked'
| 'error'
| 'waiting-for-authority'
| 'simulated'
| 'not-applicable';
/** A gate that requires authority (human/board) sign-off; no mechanical command can satisfy it. */
export interface AuthorityGate {
kind: 'authority';
capability: string;
reason: string;
}
/** A gate that requires a wired provider (e.g. an AI reviewer, CI pipeline) to evaluate. */
export interface ProviderGate {
kind: 'provider';
capability: string;
reason: string;
}
/** Forge quality gate: a real command, an authority sign-off, or a provider-backed check. */
export type ForgeGate = string | GateEntry | AuthorityGate | ProviderGate;
/** Typed result of evaluating a single quality gate. */
export interface ForgeGateResult {
gate: string;
outcome: ForgeOutcome;
reason: string;
exitCode?: number;
output?: string;
timedOut?: boolean;
}
/** Typed result of a task/stage execution returned by a TaskExecutor. */
export interface ForgeTaskResult {
task_id: string;
outcome: ForgeOutcome;
reason: string;
completed_at: string;
exit_code: number;
gate_results: ForgeGateResult[];
}
/** Stage specification — defines a single pipeline stage. */
export interface StageSpec {
number: string;
@@ -66,7 +14,7 @@ export interface StageSpec {
type: StageType;
gate: string;
promptFile: string;
qualityGates: ForgeGate[];
qualityGates: (string | GateEntry)[];
}
/** Brief classification. */
@@ -77,18 +25,11 @@ export type ClassSource = 'cli' | 'frontmatter' | 'auto';
/** Per-stage status within a run manifest. */
export interface StageStatus {
status: 'pending' | 'in_progress' | ForgeOutcome;
/** Why the stage reached its current (terminal) outcome, when applicable. */
reason?: string;
status: 'pending' | 'in_progress' | 'passed' | 'failed';
startedAt?: string;
completedAt?: string;
/** Typed per-gate results recorded alongside the stage outcome. */
gateResults?: ForgeGateResult[];
}
/** Execution mode of a run. */
export type RunMode = 'normal' | 'simulated';
/** Run manifest — persisted to disk as manifest.json. */
export interface RunManifest {
runId: string;
@@ -97,23 +38,10 @@ export interface RunManifest {
briefClass: BriefClass;
classSource: ClassSource;
forceBoard: boolean;
/**
* Execution mode. `simulated` runs stub execution; their results are typed
* `simulated` and must never be read as verified success. Optional because
* manifests written before this field existed default to `normal`.
*/
mode?: RunMode;
createdAt: string;
updatedAt: string;
currentStage: string;
status:
| 'in_progress'
| 'completed'
| 'failed'
| 'interrupted'
| 'rejected'
| 'simulated'
| 'waiting-for-authority';
status: 'in_progress' | 'completed' | 'failed' | 'interrupted' | 'rejected';
stages: Record<string, StageStatus>;
}
@@ -137,7 +65,7 @@ export interface ForgeTask {
briefPath: string;
resultPath: string;
timeoutSeconds: number;
qualityGates: ForgeGate[];
qualityGates: (string | GateEntry)[];
worktree?: string;
command?: string;
dependsOn?: string[];
@@ -148,7 +76,7 @@ export interface ForgeTask {
/** Abstract task executor — decouples from packages/coord. */
export interface TaskExecutor {
submitTask(task: ForgeTask): Promise<void>;
waitForCompletion(taskId: string, timeoutMs: number): Promise<ForgeTaskResult>;
waitForCompletion(taskId: string, timeoutMs: number): Promise<TaskResult>;
getTaskStatus(taskId: string): Promise<ForgeTaskStatus>;
}
@@ -194,16 +122,7 @@ export interface PipelineOptions {
stages?: string[];
skipTo?: string;
dryRun?: boolean;
/**
* Real task executor. Required in normal mode: the pipeline fails closed
* with FORGE_NO_EXECUTOR when it is absent.
*/
executor?: TaskExecutor;
/**
* Explicit opt-in to simulated execution. Every stage and gate result is
* typed `simulated` and is never satisfying.
*/
simulate?: boolean;
executor: TaskExecutor;
}
/** Pipeline run result. */
@@ -1,74 +0,0 @@
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');
});
});
-27
View File
@@ -12,33 +12,6 @@ The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
default tmux server. The roster is the desired-state authority; generated environment files are
rebuildable projections, never a second source of configuration.
## Brain-home split (fleet state vs framework templates)
When a mosaic-brain clone is present, fleet **state** resolves from the brain
home while framework templates and dispatch state stay in the config home
(three-tree model, canon `docs/STRUCTURE-CANON.md` §2):
| Path | Without brain (legacy) | With brain |
| ------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------ |
| `fleet/agents/<seat>.env.*` | `~/.config/mosaic/fleet/agents/` | `~/.mosaic/fleet/agents/` |
| `fleet/roles.local/` (overrides) | `~/.config/mosaic/fleet/roles.local/` | `~/.mosaic/fleet/roles.local/` |
| `fleet/profiles/` (working copies) | `~/.config/mosaic/fleet/profiles/` | `~/.mosaic/fleet/profiles/` |
| `fleet/roster.yaml`, `fleet/roles/` (baseline), `fleet/run/`, `fleet/services/` | `~/.config/mosaic/fleet/…` | unchanged (config home) |
Activation (`packages/mosaic/src/fleet/brain-home.ts`, mirrored in
`tools/fleet/start-agent-session.sh`):
1. `MOSAIC_BRAIN_HOME` env var — explicit, always wins.
2. Canonical `~/.mosaic` — adopted only when `MOSAIC_HOME` is the default
`~/.config/mosaic` AND `~/.mosaic/fleet/agents` exists. Custom
`--mosaic-home` values (tests, sandboxes, canaries) never adopt, keeping
them hermetic.
3. Otherwise the config home (legacy single-tree behavior).
Seat env dirs under a brain are subject to the same privacy boundary (0700
dirs, 0600 files); `.env.generated` files are structure-valuable and tracked
in the brain repo, hand-maintained `.env`/`.env.local` stay ignored and private.
## Examples
- `examples/minimal.yaml` starts one local canary slot.
-58
View File
@@ -35,18 +35,6 @@ 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.
@@ -708,52 +696,6 @@ 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,14 +4,6 @@ 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,14 +128,6 @@ 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,118 +225,6 @@ 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}"
}
# Brain-home fleet-state resolution (#1298; canon STRUCTURE-CANON §2).
#
# Seat launch envs, roles.local overrides, and profile working copies resolve
# from the brain home when one is active; roster, baseline roles, run/, and
# services stay under MOSAIC_HOME. This check surfaces which tree fleet state
# resolves from and the drift a launch would otherwise hit at runtime:
#
# - a stale MOSAIC_BRAIN_HOME pointing at a directory with no fleet/agents is a
# misconfiguration the resolver honors (explicit wins) — warn, don't pass;
# - a symlinked brain or agents dir defeats the managed-directory boundary;
# - a group/world-readable agents dir violates the 0700 projection boundary;
# - env files left in the config-home tree while a brain is active are split
# state — the write path rejects NEW split writes, but nothing would ever
# tell the operator the old files are stranded.
resolve_brain_home() {
local explicit="${MOSAIC_BRAIN_HOME:-}"
if [[ -n "$(printf '%s' "$explicit" | tr -d '[:space:]')" ]]; then
printf '%s' "$explicit"
return
fi
if [[ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" == "$HOME/.config/mosaic" \
&& -d "$HOME/.mosaic/fleet/agents" ]]; then
printf '%s' "$HOME/.mosaic"
return
fi
printf '%s' "$MOSAIC_HOME"
}
check_brain_home() {
local brain agents mode
brain="$(resolve_brain_home)"
if [[ "$brain" == "$MOSAIC_HOME" ]]; then
pass "Fleet state home: $MOSAIC_HOME (legacy single-tree; no brain adopted)"
return
fi
agents="$brain/fleet/agents"
if [[ ! -d "$agents" ]]; then
warn "Brain home '$brain' has no fleet/agents — seat envs will not resolve from it. Point MOSAIC_BRAIN_HOME at a brain carrying fleet/agents, or unset it."
return
fi
if [[ -L "$brain" || -L "$agents" ]]; then
warn "Brain fleet-state path resolves through a symlink ($brain) — the managed-directory boundary requires regular directories."
return
fi
mode="$(stat -c '%a' -- "$agents" 2>/dev/null)" || mode=""
if [[ -n "$mode" ]] && (( (8#$mode & 8#077) != 0 )); then
warn "Brain agents dir '$agents' is group/world-accessible (mode $mode) — the projection boundary requires 0700."
return
fi
if [[ -d "$MOSAIC_HOME/fleet/agents" ]] \
&& ls "$MOSAIC_HOME/fleet/agents/"*.env* >/dev/null 2>&1; then
warn "Fleet env files exist in BOTH trees — brain '$brain' is active but '$MOSAIC_HOME/fleet/agents' still carries env files (split state). Migrate them (mosaic fleet regen) and remove the config-home copies."
return
fi
pass "Fleet state home: $brain (brain active); roster + templates: $MOSAIC_HOME"
}
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
check_brain_home
# Legacy migration surfaces should no longer contain symlink trees.
legacy_paths=(
"$HOME/.claude/agent-guides"
@@ -1,108 +0,0 @@
#!/usr/bin/env bash
# Covers the brain-home fleet-state check in `mosaic-doctor` (#1298 follow-up).
#
# The functions are extracted from the shipped script rather than copied here
# (same discipline as test-fleet-transport-check.sh): a test that carries its
# own copy of the logic keeps passing after the shipped copy changes.
# Extraction is by exact function header and a closing brace in column one.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname "$0")" && pwd)
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
extract_function() {
local name="$1"
local extracted
extracted=$(sed -n "/^${name}() {/,/^}/p" "$DOCTOR")
[ -n "$extracted" ] || fail "could not extract ${name}() from mosaic-doctor — script reshaped?"
printf '%s\n' "$extracted"
}
for fn in resolve_brain_home check_brain_home; do
extract_function "$fn" >/dev/null
done
warn_count=0
warn() { warn_count=$((warn_count + 1)); echo "[WARN] $*"; }
pass() { echo "[OK] $*"; return 0; }
eval "$(extract_function resolve_brain_home)"
eval "$(extract_function check_brain_home)"
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
run_case() {
# label, expect (ok|warn), then env assignments as arguments.
# The check runs under `env` in a subshell, so its warn() also prints a
# sentinel the parent counts — a subshell counter would never be visible.
local label="$1" expect="$2"
shift 2
local out warns
out=$(env "$@" bash -c "warn() { echo \"[WARN] \$*\"; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function resolve_brain_home); $(extract_function check_brain_home); check_brain_home" 2>&1)
warns=$(printf '%s\n' "$out" | grep -c '^\[WARN\]' || true)
if [[ "$expect" == ok && "$warns" -eq 0 ]]; then
echo "ok - $label"
elif [[ "$expect" == warn && "$warns" -gt 0 ]]; then
echo "ok - $label (warned)"
else
echo "output: $out" >&2
fail "$label: expected $expect (warns=$warns)"
fi
}
# ── legacy: no brain, custom home never adopts ─────────────────────────────
mkdir -p "$ROOT/legacy-mosaic/fleet/agents"
run_case "custom home without brain stays legacy" ok \
MOSAIC_HOME="$ROOT/legacy-mosaic" HOME="$ROOT"
# ── healthy brain at the default config home ───────────────────────────────
mkdir -p "$ROOT/home/.config/mosaic" "$ROOT/home/.mosaic/fleet/agents"
chmod 700 "$ROOT/home/.mosaic/fleet/agents"
run_case "default home adopts healthy brain" ok \
MOSAIC_HOME="$ROOT/home/.config/mosaic" HOME="$ROOT/home"
# ── explicit MOSAIC_BRAIN_HOME to a brain without fleet/agents → warn ──────
mkdir -p "$ROOT/brain-noagents/fleet" "$ROOT/config"
run_case "explicit brain without agents warns" warn \
MOSAIC_HOME="$ROOT/config" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-noagents"
# ── explicit MOSAIC_BRAIN_HOME to a healthy brain → ok ─────────────────────
mkdir -p "$ROOT/brain-ok/fleet/agents" "$ROOT/config2"
chmod 700 "$ROOT/brain-ok/fleet/agents"
run_case "explicit healthy brain passes" ok \
MOSAIC_HOME="$ROOT/config2" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-ok"
# ── group-readable agents dir → warn (0700 boundary) ───────────────────────
mkdir -p "$ROOT/brain-loose/fleet/agents" "$ROOT/config3"
chmod 750 "$ROOT/brain-loose/fleet/agents"
run_case "group-readable brain agents warns" warn \
MOSAIC_HOME="$ROOT/config3" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-loose"
# ── symlinked agents dir → warn (managed-directory boundary) ───────────────
mkdir -p "$ROOT/brain-link/real-agents" "$ROOT/brain-link/fleet" "$ROOT/config4"
ln -s "$ROOT/brain-link/real-agents" "$ROOT/brain-link/fleet/agents"
run_case "symlinked brain agents warns" warn \
MOSAIC_HOME="$ROOT/config4" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-link"
# ── split state: envs in BOTH trees → warn ─────────────────────────────────
mkdir -p "$ROOT/brain-split/fleet/agents" "$ROOT/config5/fleet/agents"
chmod 700 "$ROOT/brain-split/fleet/agents" "$ROOT/config5/fleet/agents"
touch "$ROOT/config5/fleet/agents/coder0.env.generated"
run_case "env files in both trees warns (split state)" warn \
MOSAIC_HOME="$ROOT/config5" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-split"
# ── config-home agents dir WITHOUT env files alongside a brain → ok ────────
mkdir -p "$ROOT/brain-clean/fleet/agents" "$ROOT/config6/fleet/agents"
chmod 700 "$ROOT/brain-clean/fleet/agents" "$ROOT/config6/fleet/agents"
run_case "empty config-home agents dir alongside brain passes" ok \
MOSAIC_HOME="$ROOT/config6" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-clean"
echo "ok - mosaic-doctor brain-home check"
@@ -1,215 +0,0 @@
#!/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)"
@@ -80,26 +80,6 @@ safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"
FLEET_DIR="$MOSAIC_HOME/fleet"
AGENT_ENV_DIR="$FLEET_DIR/agents"
# Brain-home split (canon docs/STRUCTURE-CANON.md §2): seat launch envs live
# under the brain home's fleet/agents when a brain is active; roster, roles
# baseline, and runtime state (fleet/run) stay under MOSAIC_HOME.
# Resolution mirrors packages/mosaic/src/fleet/brain-home.ts:
# 1. MOSAIC_BRAIN_HOME env (explicit, always wins)
# 2. ~/.mosaic — adopted only when MOSAIC_HOME is the default config home AND
# ~/.mosaic/fleet/agents exists
# 3. MOSAIC_HOME (legacy single-tree)
BRAIN_HOME="${MOSAIC_BRAIN_HOME:-}"
if [ -z "$BRAIN_HOME" ]; then
BRAIN_HOME="$MOSAIC_HOME"
if [ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" = "$HOME/.config/mosaic" ] \
&& [ -d "$HOME/.mosaic/fleet/agents" ]; then
BRAIN_HOME="$HOME/.mosaic"
fi
fi
if [ "$BRAIN_HOME" != "$MOSAIC_HOME" ]; then
AGENT_ENV_DIR="$BRAIN_HOME/fleet/agents"
fi
assert_managed_directory "$MOSAIC_HOME"
assert_managed_directory "$FLEET_DIR"
assert_private_directory "$AGENT_ENV_DIR"
@@ -306,36 +286,6 @@ _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
@@ -434,19 +384,6 @@ 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
# #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"
echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2
fi
@@ -23,26 +23,8 @@ 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
;;
@@ -80,30 +62,6 @@ 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"
@@ -123,19 +81,6 @@ 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() {
@@ -143,7 +88,6 @@ 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"
@@ -154,10 +98,7 @@ run_start() {
HOME_VALID="$ROOT/valid"
AGENT_VALID="coder0"
write_generated "$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"
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"
@@ -167,54 +108,6 @@ if echo "$valid_args" | grep -qF 'bash -c'; then
fail "launcher constructed a shell command payload"
fi
# ── Brain-home split (canon §2) ─────────────────────────────────────────
# When MOSAIC_HOME is the default config home under $HOME and the host carries
# $HOME/.mosaic/fleet/agents, seat envs resolve from the brain tree; the config
# home still owns fleet/run (holder-owner) and remains a managed boundary.
: > "$TMUX_CALLS"
HOME_BRAIN="$ROOT/brain-home"
CONFIG_HOME="$HOME_BRAIN/.config/mosaic"
BRAIN="$HOME_BRAIN/.mosaic"
mkdir -p "$CONFIG_HOME/fleet/run" "$BRAIN/fleet/agents" "$HOME_BRAIN/work"
chmod 700 "$CONFIG_HOME" "$CONFIG_HOME/fleet" "$CONFIG_HOME/fleet/run" \
"$BRAIN/fleet/agents" "$HOME_BRAIN/work"
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$CONFIG_HOME/fleet/run/holder-owner"
chmod 600 "$CONFIG_HOME/fleet/run/holder-owner"
cat > "$BRAIN/fleet/agents/coder-brain.env.generated" <<EOF
MOSAIC_AGENT_NAME=coder-brain
MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
MOSAIC_AGENT_REASONING=high
MOSAIC_AGENT_TOOL_POLICY=code
MOSAIC_AGENT_WORKDIR=$HOME_BRAIN/work
MOSAIC_TMUX_SOCKET=mosaic-test
EOF
chmod 600 "$BRAIN/fleet/agents/coder-brain.env.generated"
install_pane_binaries "$HOME_BRAIN"
HOME="$HOME_BRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_BRAIN" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$CONFIG_HOME" "$START" coder-brain
brain_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$brain_args" | grep -qF new-session || fail "brain-home generated projection did not reach tmux"
echo "$brain_args" | grep -qF 'coder-brain' || fail "brain-home agent env was not the launch source"
[ -f "$BRAIN/fleet/agents/coder-brain.env.generated" ] || fail "brain generated env vanished"
# Negative control: the SAME default-config-home shape but without
# ~/.mosaic/fleet/agents — the config-home env tree is used directly (legacy).
: > "$TMUX_CALLS"
HOME_NOBRAIN="$ROOT/brainless-home"
CONFIG_HOME_NOBRAIN="$HOME_NOBRAIN/.config/mosaic"
write_generated "$CONFIG_HOME_NOBRAIN" "coder-legacy"
install_pane_binaries "$HOME_NOBRAIN"
HOME="$HOME_NOBRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_NOBRAIN" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$CONFIG_HOME_NOBRAIN" "$START" coder-legacy
legacy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$legacy_args" | grep -qF new-session || fail "legacy single-tree launch regressed"
# The pane must start through an absolute clean-environment boundary. Its
# runtime command remains an argv vector, but no holder/session environment
# control variable can pass through the pane command.
@@ -352,13 +245,6 @@ 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' \
@@ -372,7 +258,6 @@ 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" || \
@@ -507,75 +392,6 @@ 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,6 +32,10 @@ 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
@@ -39,20 +43,3 @@ packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires rea
# recorded judgement. These lines ARE that judgement, signed.)
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
# --- tools/fleet: precondition is unsatisfiable in the CI image (#1271) ---
# Signed by fred (sb-it-1-dt, 2026-08-16) at origin/next 476db12.
# This suite asserts the launcher's behaviour when `mosaic` and `pi` are MISSING.
# It shims fakes into $FAKE_BIN, but the constructed PANE_PATH always ends in the
# real system path, so on a host that installs those binaries the missing-binary
# cases cannot be measured at all. The suite's own guard (line 103) says so and
# fails rather than reporting a pass it cannot back. That guard is correct.
# The error was wiring the suite into CI: #1017 (c56483eb) enumerated it and
# dropped this exclusion, and the CI image provides `pi` in the system path, so
# it has failed on every pipeline since. Measured 2026-08-16 across pipelines
# 2444 (#1256), 2438 (#1240) and 2441 (#1017-quality): exactly one FAIL line in
# each full log, identical, this assertion; control `zzz-not-present-zzz` -> 0.
# Burn-down and the full measurement are tracked in #1271; unwired by PR #1270.
# Because test:framework-shell is one && chain and this sat at position 44 of 48,
# the four suites after it had not run at all since the merge.
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | precondition unsatisfiable in the CI image: asserts missing-binary behaviour, but PANE_PATH always ends in the system path and the image provides `pi` there; guard at line 103 fails by design rather than passing unmeasured. Burn down by controlling the tail of PANE_PATH inside the test. NOT by removing `pi` from the image: the CI image installs @earendil-works/[email protected] deliberately (measured in pipeline 2444's test-step log), and other suites depend on that pin. Burn-down tracked in #1271
+1 -1
View File
@@ -25,7 +25,7 @@
"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 && 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 && bash framework/tools/_scripts/test-brain-home-check.sh"
"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"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -1,6 +1,5 @@
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fleetAgentEnvDir, fleetRolesLocalDir } from '../fleet/brain-home.js';
import type { Command } from 'commander';
import {
executeFleetAgentMutation,
@@ -150,9 +149,9 @@ async function executeCommand(
request,
mosaicHome,
rosterPath,
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
rolesDir: join(mosaicHome, 'fleet', 'roles'),
overrideDir: fleetRolesLocalDir(mosaicHome),
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
dryRun: forceDryRun || opts.dryRun === true,
...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }),
});
@@ -1,6 +1,5 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fleetAgentEnvDir, fleetRolesLocalDir } from '../fleet/brain-home.js';
import type { Command } from 'commander';
import {
parseV1MigrationObservations,
@@ -121,11 +120,11 @@ export function registerFleetMigrationCommand(
observations,
personaDirs: {
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
overrideDir: deps.overrideDir ?? fleetRolesLocalDir(mosaicHome),
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
},
environment: {
mosaicHome,
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
},
});
printJson(preview);
@@ -30,21 +30,19 @@ import { lstat, readFile, readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, isAbsolute, join, sep } from 'node:path';
import type { Command } from 'commander';
import { fleetRolesLocalDir } from '../fleet/brain-home.js';
function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}
/** Baseline persona role contracts (reseeded on update; config home — framework). */
/** Baseline persona role contracts (reseeded on update). */
export function defaultRolesDir(mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'roles');
}
/** PRESERVE-protected override layer (survives update; wins on merge).
* Brain home (`~/.mosaic/fleet/roles.local`) when a brain is active. */
/** PRESERVE-protected override layer (survives update; wins on merge). */
export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
return fleetRolesLocalDir(mosaicHome);
return join(mosaicHome, 'fleet', 'roles.local');
}
/**
@@ -25,7 +25,6 @@ import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import type { Command } from 'commander';
import YAML from 'yaml';
import { fleetProfilesDir } from '../fleet/brain-home.js';
import {
defaultOverrideDir,
extractClassesFromDir,
@@ -37,10 +36,9 @@ function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}
/** Directory holding the seeded profile yaml files brain home when active
* (user working copies, committed), else the config home seed. */
/** Directory holding the seeded profile yaml files. */
export function defaultProfilesDir(mosaicHome = defaultMosaicHome()): string {
return fleetProfilesDir(mosaicHome);
return join(mosaicHome, 'fleet', 'profiles');
}
/** Directory holding the persona role contracts. */
@@ -3,7 +3,6 @@ import { homedir } from 'node:os';
import { join, relative, resolve } from 'node:path';
import type { Command } from 'commander';
import type { CommandRunner } from './fleet.js';
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
import {
applyPreparedGeneratedAgentEnvironmentProjection,
prepareGeneratedAgentEnvironmentProjection,
@@ -154,7 +153,7 @@ export async function executeFleetRegen(
options: FleetRegenOptions,
): Promise<FleetRegenResult> {
const mosaicHome = defaultMosaicHome(deps);
const agentEnvDir = fleetAgentEnvDir(mosaicHome);
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
const readRoster = deps.readRoster ?? defaultReadRoster(deps, mosaicHome);
const prepare = deps.prepareProjection ?? prepareGeneratedAgentEnvironmentProjection;
@@ -1,323 +0,0 @@
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);
});
});
+9 -118
View File
@@ -13,7 +13,6 @@ import {
import { randomUUID } from 'node:crypto';
import { homedir, hostname, userInfo } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import * as readline from 'node:readline';
@@ -35,7 +34,6 @@ 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,
@@ -159,7 +157,7 @@ export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths
fleetToolsDir: join(mosaicHome, 'tools', 'fleet'),
tmuxToolsDir: join(mosaicHome, 'tools', 'tmux'),
systemdUserDir: join(homedir(), '.config', 'systemd', 'user'),
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
};
}
@@ -822,7 +820,7 @@ export function buildEnableLingerCommand(user: string): string[] {
*/
export async function enableFleetUnits(
runner: CommandRunner,
roster: { readonly agents: readonly { readonly name: string }[] },
roster: FleetRoster,
opts: { enable?: boolean },
): Promise<void> {
if (opts.enable === false) {
@@ -1529,8 +1527,7 @@ 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);
// Unit enablement needs agent names only, so it reads either version.
const roster = await loadRosterReadModel(cmd);
const roster = await loadRosterForCommand(cmd);
await enableFleetUnits(runner, roster, opts);
});
@@ -1540,8 +1537,7 @@ 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);
// Unit enablement needs agent names only, so it reads either version.
const roster = await loadRosterReadModel(cmd);
const roster = await loadRosterForCommand(cmd);
await enableFleetUnits(runner, roster, opts);
});
@@ -1692,9 +1688,7 @@ 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);
// 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 roster = await loadRosterForCommand(cmd);
const { tenant_id, host } = getDefaultTenantAndHost();
const nowMs = Date.now();
@@ -1914,16 +1908,6 @@ 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(', ')}.`,
@@ -1989,12 +1973,6 @@ 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);
@@ -2353,9 +2331,7 @@ export function registerFleetAgentCommands(
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
// 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);
const roster = await loadRosterForCommand(cmd);
await ensureFleetHolderIdentity(activePaths.mosaicHome);
await mkdir(activePaths.fleetToolsDir, { recursive: true });
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
@@ -2415,30 +2391,16 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
join(activePaths.systemdUserDir, '[email protected]'),
);
// 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) {
for (const agent of roster.agents) {
await writeAgentEnvironmentProjection({
mosaicHome: activePaths.mosaicHome,
agentEnvDir: activePaths.agentEnvDir,
agentName: agent.name,
generated: generateAgentEnvValues(v1Roster, agent),
generated: generateAgentEnvValues(roster, agent),
});
}
console.log(`Installed fleet files for ${v1Roster.agents.length} agent(s).`);
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
}
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
@@ -2465,77 +2427,6 @@ 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,
@@ -349,90 +349,3 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
expect(mockExit).not.toHaveBeenCalled();
});
});
// ─── Seat harness homes (MOSAIC-D-002, brain-home split) ────────────────────
import { activeSeatDir, seatPersonaOverlay } from './launch.js';
describe('activeSeatDir — per-agent harness home resolution', () => {
let root: string;
const savedAgentName = process.env['MOSAIC_AGENT_NAME'];
const savedBrainHome = process.env['MOSAIC_BRAIN_HOME'];
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-seat-home-'));
delete process.env['MOSAIC_BRAIN_HOME'];
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
if (savedAgentName === undefined) {
delete process.env['MOSAIC_AGENT_NAME'];
} else {
process.env['MOSAIC_AGENT_NAME'] = savedAgentName;
}
if (savedBrainHome !== undefined) {
process.env['MOSAIC_BRAIN_HOME'] = savedBrainHome;
} else {
delete process.env['MOSAIC_BRAIN_HOME'];
}
});
it('resolves the seat dir when MOSAIC_BRAIN_HOME carries the seat', () => {
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
mkdirSync(seat, { recursive: true });
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBe(seat);
});
it('returns undefined without an agent name (bare launches stay shared)', () => {
delete process.env['MOSAIC_AGENT_NAME'];
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
});
it('returns undefined when the seat dir does not exist in the brain', () => {
process.env['MOSAIC_AGENT_NAME'] = 'ghost';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
mkdirSync(join(root, 'brain', 'fleet', 'agents'), { recursive: true });
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
});
it.each(['../escape', 'a/b', '.hidden-start', '', 'spaced name'])(
'rejects unsafe agent name %j (path traversal cannot leave the seat store)',
(name: string) => {
process.env['MOSAIC_AGENT_NAME'] = name;
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
},
);
it('seatPersonaOverlay renders the seat SOUL.md as an overlay block', () => {
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
mkdirSync(seat, { recursive: true });
writeFileSync(join(seat, 'SOUL.md'), '# coder0 — code seat persona\n\nShips tested code.\n');
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
const overlay = seatPersonaOverlay(join(root, 'config', 'mosaic'));
expect(overlay).toContain('## Seat Persona');
expect(overlay).toContain('coder0 — code seat persona');
});
it('seatPersonaOverlay is empty when the seat carries no SOUL.md', () => {
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
mkdirSync(seat, { recursive: true });
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
expect(seatPersonaOverlay(join(root, 'config', 'mosaic'))).toBe('');
});
it('seatPersonaOverlay is empty when no agent name is set', () => {
delete process.env['MOSAIC_AGENT_NAME'];
expect(seatPersonaOverlay(join(root, 'config', 'mosaic'))).toBe('');
});
});
+4 -49
View File
@@ -19,7 +19,7 @@ import {
import { createHash, randomBytes } from 'node:crypto';
import { createRequire } from 'node:module';
import { homedir, hostname } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
import {
buildResolvedFleetCommsBlock,
@@ -29,7 +29,6 @@ import {
import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js';
import { resolveBrainHome } from '../fleet/brain-home.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
@@ -65,46 +64,9 @@ const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
opencode: 'XDG_CONFIG_HOME',
};
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime>.
* With an active brain seat (MOSAIC_AGENT_NAME + seat dir in the brain home)
* the home is per-agent instead: <brainHome>/fleet/agents/<seat>/.<runtime>
* per-agent sessions, settings, and auth inside the seat dir (canon §2,
* MOSAIC-D-002). Seat runtime dirs are dot-named so the brain's ignore policy
* (per-seat .pi/.claude/.codex dirs) keeps credential material untracked. */
const SEAT_AGENT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
export function activeSeatDir(mosaicHome: string = MOSAIC_HOME): string | undefined {
const agent = process.env['MOSAIC_AGENT_NAME']?.trim();
if (
agent === undefined ||
agent === '' ||
!SEAT_AGENT_NAME_RE.test(agent) ||
agent.includes('..')
) {
return undefined;
}
const brain = resolveBrainHome(mosaicHome);
if (resolve(brain) === resolve(mosaicHome)) return undefined; // no brain
const seat = join(brain, 'fleet', 'agents', agent);
return existsSync(seat) ? seat : undefined;
}
function harnessHome(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string {
const seat = activeSeatDir(mosaicHome);
if (seat !== undefined) return join(seat, `.${runtime}`);
return join(mosaicHome, `.${runtime}`);
}
/** Seat persona block: with an active brain seat, <seat>/SOUL.md layers
* persona on the root generic base (canon invariant; MOSAIC-D-002). The base
* SOUL stays load-on-demand only the seat delta is injected by value.
* Empty string when no seat is active or the seat carries no SOUL.md. */
export function seatPersonaOverlay(mosaicHome: string = MOSAIC_HOME): string {
const seatDir = activeSeatDir(mosaicHome);
if (seatDir === undefined) return '';
const seatSoul = readOptional(join(seatDir, 'SOUL.md'));
if (!seatSoul.trim()) return '';
return '## Seat Persona\n\n' + seatSoul.trim();
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
function harnessHome(runtime: RuntimeName): string {
return join(MOSAIC_HOME, `.${runtime}`);
}
/**
@@ -220,8 +182,6 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): v
cli_version: CLI_VERSION,
config_home: harnessHome(runtime),
config_home_isolated: true,
config_home_kind: activeSeatDir() !== undefined ? 'seat' : 'runtime-shared',
agent_name: process.env['MOSAIC_AGENT_NAME']?.trim() || null,
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
argv: redactArgv(cliArgs),
normative_fragments: normativeFragmentDigests(runtime),
@@ -609,11 +569,6 @@ For required push/merge/issue-close/release actions, execute without routine con
if (soulLocal.trim()) {
overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim());
}
// Seat persona (MOSAIC-D-002): per-seat SOUL.md layers on the generic base.
const seatPersona = seatPersonaOverlay(mosaicHome);
if (seatPersona !== '') {
overlayBlocks.push(seatPersona);
}
const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md'));
if (standardsLocal.trim()) {
overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
@@ -1,114 +0,0 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
brainHomeIsActive,
fleetAgentEnvDir,
fleetProfilesDir,
fleetRolesLocalDir,
fleetStateDir,
resolveBrainHome,
type BrainHomeOptions,
} from './brain-home.js';
describe('fleet brain-home resolution', (): void => {
let cleanup: string | undefined;
const savedBrainEnv = process.env['MOSAIC_BRAIN_HOME'];
beforeEach((): void => {
delete process.env['MOSAIC_BRAIN_HOME'];
});
afterEach(async (): Promise<void> => {
if (savedBrainEnv === undefined) {
delete process.env['MOSAIC_BRAIN_HOME'];
} else {
process.env['MOSAIC_BRAIN_HOME'] = savedBrainEnv;
}
if (cleanup !== undefined) {
await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
}
});
async function makeTmp(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'mosaic-brain-home-'));
cleanup = root;
return root;
}
it('MOSAIC_BRAIN_HOME env wins over every other signal', (): void => {
process.env['MOSAIC_BRAIN_HOME'] = '/explicit/brain';
expect(resolveBrainHome('/any/mosaic-home')).toBe('/explicit/brain');
expect(fleetAgentEnvDir('/any/mosaic-home')).toBe('/explicit/brain/fleet/agents');
expect(brainHomeIsActive('/any/mosaic-home')).toBe(true);
});
it('injected envBrainHome wins identically (test seam)', (): void => {
const opts: BrainHomeOptions = { envBrainHome: '/injected/brain' };
expect(resolveBrainHome('/any/mosaic-home', opts)).toBe('/injected/brain');
expect(fleetAgentEnvDir('/any/mosaic-home', opts)).toBe('/injected/brain/fleet/agents');
});
it('a non-default mosaicHome never adopts the canonical brain (hermetic legacy)', (): void => {
const mosaicHome = '/tmp/not-the-default-config-home';
expect(resolveBrainHome(mosaicHome)).toBe(mosaicHome);
expect(brainHomeIsActive(mosaicHome)).toBe(false);
expect(fleetAgentEnvDir(mosaicHome)).toBe(join(mosaicHome, 'fleet', 'agents'));
});
it('the default config home adopts the brain when it carries fleet/agents', async (): Promise<void> => {
const root = await makeTmp();
const brain = join(root, 'brain');
await mkdir(join(brain, 'fleet', 'agents'), { recursive: true });
const configHome = join(root, 'config', 'mosaic');
const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } };
expect(resolveBrainHome(configHome, opts)).toBe(brain);
expect(fleetAgentEnvDir(configHome, opts)).toBe(join(brain, 'fleet', 'agents'));
expect(fleetRolesLocalDir(configHome, opts)).toBe(join(brain, 'fleet', 'roles.local'));
expect(fleetProfilesDir(configHome, opts)).toBe(join(brain, 'fleet', 'profiles'));
expect(fleetStateDir(configHome, opts)).toBe(join(brain, 'fleet'));
expect(brainHomeIsActive(configHome, opts)).toBe(true);
});
it('the default config home stays legacy when no brain exists', async (): Promise<void> => {
const root = await makeTmp();
const configHome = join(root, 'config', 'mosaic');
const opts: BrainHomeOptions = {
homes: { brain: join(root, 'brain'), configDefault: configHome },
};
expect(resolveBrainHome(configHome, opts)).toBe(configHome);
expect(brainHomeIsActive(configHome, opts)).toBe(false);
});
it('an empty MOSAIC_BRAIN_HOME is ignored, not treated as set', (): void => {
process.env['MOSAIC_BRAIN_HOME'] = ' ';
expect(resolveBrainHome('/tmp/legacy-home')).toBe('/tmp/legacy-home');
});
it('adoption requires fleet/agents specifically, not any brain content', async (): Promise<void> => {
const root = await makeTmp();
const brain = join(root, 'brain');
await mkdir(join(brain, 'fleet'), { recursive: true }); // fleet without agents
const configHome = join(root, 'config', 'mosaic');
const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } };
expect(resolveBrainHome(configHome, opts)).toBe(configHome);
});
it('real-home control: a host brain is adopted only through the default home', (): void => {
// Control on the un-injected path: this host carries ~/.mosaic/fleet/agents,
// so the default config home resolves to the brain or legacy — both valid
// canonical endpoints — while a non-default home never adopts.
const defaultHome = join(homedir(), '.config', 'mosaic');
const resolved = resolveBrainHome(defaultHome);
expect([defaultHome, join(homedir(), '.mosaic')]).toContain(resolved);
expect(resolveBrainHome(join(homedir(), 'elsewhere', 'mosaic'))).toBe(
join(homedir(), 'elsewhere', 'mosaic'),
);
});
});
-76
View File
@@ -1,76 +0,0 @@
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
/**
* Overridable resolution inputs (tests inject tmp homes; production reads
* the environment and the real home directory).
*/
export interface BrainHomeOptions {
/** Explicit brain home; defaults to `MOSAIC_BRAIN_HOME`. */
readonly envBrainHome?: string;
/**
* Canonical homes used for adoption. Defaults derive from the real
* `homedir()`: `{ brain: ~/.mosaic, configDefault: ~/.config/mosaic }`.
*/
readonly homes?: { readonly brain: string; readonly configDefault: string };
}
/**
* Brain-home resolution the three-tree fleet split (stack canon
* `docs/STRUCTURE-CANON.md` §2, first carried by the USC estate brain):
*
* config home (~/.config/mosaic) framework templates + dispatch state:
* fleet/roles (baseline), fleet/roster.yaml,
* fleet/run (heartbeats), fleet/services
* brain home (~/.mosaic) user-owned fleet state, committed:
* fleet/agents/<seat>.env.*, fleet/roles.local,
* fleet/profiles working copies
*
* Resolution order:
* 1. `MOSAIC_BRAIN_HOME` env (explicit, always wins)
* 2. canonical `~/.mosaic` adopted ONLY when mosaicHome is the real
* default config home AND `~/.mosaic/fleet/agents` exists. Custom
* `--mosaic-home` values (tests, sandboxes, canaries) never trigger
* adoption, keeping them hermetic and deterministic.
* 3. mosaicHome itself (legacy single-tree behavior).
*/
export function resolveBrainHome(mosaicHome: string, options: BrainHomeOptions = {}): string {
const explicit = options.envBrainHome ?? process.env['MOSAIC_BRAIN_HOME'];
if (explicit !== undefined && explicit.trim() !== '') {
return explicit;
}
const homes = options.homes ?? {
brain: join(homedir(), '.mosaic'),
configDefault: join(homedir(), '.config', 'mosaic'),
};
if (resolve(mosaicHome) !== resolve(homes.configDefault)) {
return mosaicHome;
}
return existsSync(join(homes.brain, 'fleet', 'agents')) ? homes.brain : mosaicHome;
}
/** True when fleet state resolves somewhere other than the config home. */
export function brainHomeIsActive(mosaicHome: string, options: BrainHomeOptions = {}): boolean {
return resolve(resolveBrainHome(mosaicHome, options)) !== resolve(mosaicHome);
}
/** Fleet state root (brain home when active, else the config home). */
export function fleetStateDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(resolveBrainHome(mosaicHome, options), 'fleet');
}
/** Seat launch envs — `<brainHome>/fleet/agents` when a brain is active. */
export function fleetAgentEnvDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(fleetStateDir(mosaicHome, options), 'agents');
}
/** PRESERVE-protected persona override layer — `<brainHome>/fleet/roles.local`. */
export function fleetRolesLocalDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(fleetStateDir(mosaicHome, options), 'roles.local');
}
/** System-type profiles (user working copies) — `<brainHome>/fleet/profiles`. */
export function fleetProfilesDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(fleetStateDir(mosaicHome, options), 'profiles');
}
@@ -3,7 +3,6 @@ import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fleetAgentEnvDir } from './brain-home.js';
import {
applyPreparedAgentEnvironmentProjection,
prepareAgentEnvironmentProjection,
@@ -618,7 +617,7 @@ function defaultPrepareProjections(
(agent: FleetRosterV2Agent): Promise<PreparedAgentEnvironmentProjection> =>
prepareAgentEnvironmentProjection({
mosaicHome,
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
agentName: agent.name,
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
}),
@@ -176,52 +176,6 @@ describe('generated fleet agent environment boundary', (): void => {
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
});
it('brain home: accepts and writes projections under MOSAIC_BRAIN_HOME/fleet/agents', async (): Promise<void> => {
const savedBrainHome = process.env['MOSAIC_BRAIN_HOME'];
try {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'config-home');
const brainHome = join(cleanup, 'brain');
const agentEnvDir = join(brainHome, 'fleet', 'agents');
process.env['MOSAIC_BRAIN_HOME'] = brainHome;
const result = await writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: 'coder0',
generated: generatedValues,
});
// Projection landed in the brain tree, not under the config home.
expect(result.generatedPath).toBe(join(agentEnvDir, 'coder0.env.generated'));
expect((await stat(join(brainHome, 'fleet'))).mode & 0o777).toBe(0o700);
expect((await stat(agentEnvDir)).mode & 0o777).toBe(0o700);
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
await expect(stat(join(mosaicHome, 'fleet'))).rejects.toThrow();
// A config-home agentEnvDir is now REJECTED while the brain is active —
// the boundary must not silently split state across two trees.
let rejected: unknown;
try {
await writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
agentName: 'coder1',
generated: { ...generatedValues, MOSAIC_AGENT_NAME: 'coder1' },
});
} catch (caught: unknown) {
rejected = caught;
}
expect(rejected).toBeInstanceOf(AgentEnvBoundaryError);
} finally {
if (savedBrainHome === undefined) {
delete process.env['MOSAIC_BRAIN_HOME'];
} else {
process.env['MOSAIC_BRAIN_HOME'] = savedBrainHome;
}
}
});
it('regenerates desired keys, relocates safe legacy local data, and quarantines forbidden legacy input', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
@@ -2,7 +2,6 @@ import { createHash, randomUUID } from 'node:crypto';
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fleetAgentEnvDir, resolveBrainHome } from './brain-home.js';
import { compareCodePoints } from './deterministic-order.js';
export type AgentEnvironmentKind = 'generated' | 'local';
@@ -529,15 +528,12 @@ async function validatePrivateProjectionDirectory(
mosaicHome: string,
agentEnvDir: string,
): Promise<void> {
// Brain-home split (canon §2): seat envs live under the brain home's
// fleet/agents when a brain is active; roster + templates stay config-home.
const expectedAgentEnvDir = fleetAgentEnvDir(mosaicHome);
const fleetDir = join(mosaicHome, 'fleet');
const expectedAgentEnvDir = join(fleetDir, 'agents');
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
}
const stateHome = resolveBrainHome(mosaicHome);
const fleetDir = join(stateHome, 'fleet');
await assertManagedDirectoryIfPresent(stateHome, false);
await assertManagedDirectoryIfPresent(mosaicHome, false);
await assertManagedDirectoryIfPresent(fleetDir, false);
await assertManagedDirectoryIfPresent(agentEnvDir, true);
}
@@ -547,9 +543,8 @@ async function ensurePrivateProjectionDirectory(
agentEnvDir: string,
): Promise<void> {
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
const stateHome = resolveBrainHome(mosaicHome);
const fleetDir = join(stateHome, 'fleet');
await ensureManagedDirectory(stateHome, false);
const fleetDir = join(mosaicHome, 'fleet');
await ensureManagedDirectory(mosaicHome, false);
await ensureManagedDirectory(fleetDir, false);
await ensureManagedDirectory(agentEnvDir, true);
}
+6 -8
View File
@@ -1,3 +1,4 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, platform } from 'node:os';
@@ -21,18 +22,15 @@ 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, '.zshenv');
return join(zdotdir, '.zshrc');
}
case 'bash':
case 'bash': {
const bashrc = join(home, '.bashrc');
if (existsSync(bashrc)) return bashrc;
return join(home, '.profile');
}
case 'fish':
return join(home, '.config', 'fish', 'config.fish');
default:
-166
View File
@@ -1,166 +0,0 @@
#!/usr/bin/env node
// verify-release.mjs — the ONE canonical terminal verification command
// (SDLC-D-034, `pnpm verify:release`).
//
// Publication (.woodpecker/publish.yml `verify` step) is bound to terminal
// verification of the exact commit through this command, which is composed
// from the SAME commands the PR CI pipeline (.woodpecker/ci.yml) runs — CI and
// publish share one semantic checklist:
//
// stage | mirrors ci.yml step | commands
// --------------|---------------------|------------------------------------------
// sanitization | sanitization | verify-sanitized.sh, check-resident-
// | | budget.sh (--self-test + run),
// | | check-test-enumeration.sh
// upgrade-guard | upgrade-guard | test-upgrade-manifest-guard.sh,
// | | test-upgrade-rollback.sh,
// | | test-upgrade-durable-snapshot.sh,
// | | test-install-migration.sh
// typecheck | typecheck | pnpm typecheck (runs the checkout
// | | preflight, then turbo typecheck)
// lint | lint | pnpm lint
// format | format | pnpm format:check
// test | test | pnpm test
// build | publish.yml build | pnpm build
//
// Caller-provided prerequisites (kept at the pipeline level — see the comments
// in .woodpecker/ci.yml): `bash` + `rsync` for the guard stages, `openssl` and
// the pinned @earendil-works/pi-coding-agent for the test stage, and — on the
// postgres path only — the ci-postgres service plus
// `pnpm --filter @mosaicstack/db run db:migrate` before the test stage.
//
// This command works with DATABASE_URL set (CI postgres path) or unset (local
// PGlite path); it never sets, exports, or requires a database itself.
//
// scripts/verify-release.test.mjs enforces that this stage table keeps
// matching .woodpecker/ci.yml step-for-step, so the two surfaces cannot drift
// apart silently.
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const STAGES = [
{
name: 'sanitization',
// Mirror of the .woodpecker/ci.yml `sanitization` step (minus its
// `apk add` environment prep). Kept as direct command strings here: the
// #1017 test-enumeration guard audits these paths through the ci.yml
// surface, so indirection from ci.yml into this file is not possible.
commands: [
'bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh',
'bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test',
'bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh',
'bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh',
],
},
{
name: 'upgrade-guard',
// Mirror of the .woodpecker/ci.yml `upgrade-guard` step (minus its
// `apk add` environment prep).
commands: [
'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh',
'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh',
'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh',
'bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh',
],
},
{
// `pnpm typecheck` is `pnpm preflight && turbo run typecheck`, so the
// checkout preflight (scripts/preflight.mjs) is part of this stage exactly
// as it is part of the ci.yml `typecheck` step.
name: 'typecheck',
commands: ['pnpm typecheck'],
},
{
name: 'lint',
commands: ['pnpm lint'],
},
{
name: 'format',
commands: ['pnpm format:check'],
},
{
// Requires `openssl` and the pinned `pi` binary on the pipeline path; see
// the caller-provided prerequisites above.
name: 'test',
commands: ['pnpm test'],
},
{
name: 'build',
commands: ['pnpm build'],
},
];
export function stageByName(name) {
return STAGES.find((stage) => stage.name === name);
}
function missingBinaries(bins) {
return bins.filter(
(bin) => spawnSync('sh', ['-c', `command -v ${bin} >/dev/null 2>&1`]).status !== 0,
);
}
function runCommand(command) {
const result = spawnSync(command, { shell: true, stdio: 'inherit' });
if (result.error) {
console.error(`[verify:release] failed to launch '${command}': ${result.error.message}`);
return false;
}
if (result.status !== 0) {
const reason = result.signal ? `terminated by ${result.signal}` : `exited ${result.status}`;
console.error(`[verify:release] command '${command}' ${reason}`);
return false;
}
return true;
}
// Runs the complete mandatory verification set (or, with --stage <name>, the
// single named stage — used for wiring/smoke-testing, not for gating: only a
// run of every stage is a terminal verification). Fails fast: the first
// failing command aborts with a non-zero exit code. Returns the exit code.
export function verifyRelease({ stages = STAGES } = {}) {
const missing = missingBinaries(['bash', 'rsync']);
if (missing.length > 0) {
console.error(
`[verify:release] FATAL: required binaries missing from PATH: ${missing.join(', ')}. ` +
'The caller provides them (ci-base bakes bash; pipelines apk add rsync).',
);
return 1;
}
for (const stage of stages) {
console.log(`\n[verify:release] === stage: ${stage.name} ===`);
for (const command of stage.commands) {
console.log(`[verify:release] $ ${command}`);
if (!runCommand(command)) {
console.error(
`[verify:release] FATAL: stage '${stage.name}' failed — verification inconclusive`,
);
return 1;
}
}
}
console.log(`\n[verify:release] all ${stages.length} stage(s) passed`);
return 0;
}
function main(argv) {
const stageFlagIndex = argv.indexOf('--stage');
if (stageFlagIndex !== -1) {
const name = argv[stageFlagIndex + 1];
const stage = stageByName(name);
if (!stage) {
console.error(
`[verify:release] unknown stage '${name ?? ''}' — expected one of: ${STAGES.map((entry) => entry.name).join(', ')}`,
);
process.exit(2);
}
process.exit(verifyRelease({ stages: [stage] }));
}
process.exit(verifyRelease());
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2));
}
-277
View File
@@ -1,277 +0,0 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import test from 'node:test';
import { STAGES } from './verify-release.mjs';
// SDLC-D-034 checkout invariant: publication in .woodpecker/publish.yml is
// bound to exact-commit terminal verification. This suite parses the real
// pipeline files and fails red when the gate is bypassed, weakened, or drifts
// out of sync with the canonical `pnpm verify:release` command.
// Reuse the monorepo's existing YAML parser (@mosaicstack/mosaic's direct
// dependency) instead of adding a root dependency or vendoring a parser.
const mosaicRequire = createRequire(
path.resolve(process.cwd(), 'packages', 'mosaic', 'package.json'),
);
const { parse: parseYaml } = mosaicRequire('yaml');
const publishYmlPath = path.join(process.cwd(), '.woodpecker', 'publish.yml');
const ciYmlPath = path.join(process.cwd(), '.woodpecker', 'ci.yml');
async function readPublishPipeline() {
return parseYaml(await readFile(publishYmlPath, 'utf8'));
}
// A step has an external publication effect when its name starts with
// `publish` or when any command pushes an image to a registry.
function pushesImage(step) {
return (step.commands ?? []).some((command) =>
/(^|\s)(\/kaniko\/executor|docker push)\b|--destination/.test(command),
);
}
function publishEffectSteps(pipeline) {
return Object.entries(pipeline.steps ?? {})
.filter(([name, step]) => name.startsWith('publish') || pushesImage(step))
.map(([name]) => name);
}
// Transitive closure of a step's depends_on graph.
function dependencyClosure(pipeline, stepName, seen = new Set()) {
const dependencies = pipeline.steps?.[stepName]?.depends_on ?? [];
for (const dependency of dependencies) {
if (seen.has(dependency)) continue;
seen.add(dependency);
dependencyClosure(pipeline, dependency, seen);
}
return seen;
}
function verifyCommands(pipeline) {
const verify = pipeline.steps?.verify;
assert.ok(verify, 'publish pipeline must define a `verify` step');
assert.ok(Array.isArray(verify.commands), '`verify` step must have commands');
return verify.commands;
}
function assertCommitIdentityAssertion(commands) {
const text = commands.join('\n');
assert.match(
text,
/CI_COMMIT_SHA/,
'`verify` must compare the provider commit identity (CI_COMMIT_SHA)',
);
assert.match(text, /git rev-parse HEAD/, '`verify` must compare against git rev-parse HEAD');
assert.match(
text,
/exit 1/,
'`verify` must fail closed (exit 1) on identity mismatch or emptiness',
);
}
function assertCanonicalCommand(commands) {
assert.ok(
commands.some((command) => /^pnpm verify:release\b/.test(command.trim())),
'`verify` must run the canonical terminal verification command `pnpm verify:release`',
);
}
function assertPublishGate(pipeline) {
assert.ok(pipeline.steps, 'publish pipeline must define steps');
const commands = verifyCommands(pipeline);
assertCommitIdentityAssertion(commands);
assertCanonicalCommand(commands);
const effects = publishEffectSteps(pipeline);
assert.ok(effects.length > 0, 'publish pipeline must contain publish effect steps to guard');
for (const stepName of effects) {
const step = pipeline.steps[stepName];
assert.ok(
Array.isArray(step.depends_on) && step.depends_on.includes('verify'),
`publish effect '${stepName}' must depend DIRECTLY on the verify step (SDLC-D-034: transitively through build is not enough)`,
);
assert.ok(
dependencyClosure(pipeline, stepName).has('verify'),
`publish effect '${stepName}' must depend on a chain that includes verify`,
);
}
return effects;
}
test('the publish pipeline gates every publish effect behind exact-commit verification', async () => {
const pipeline = await readPublishPipeline();
const effects = assertPublishGate(pipeline);
assert.deepEqual(effects.sort(), [
'build-appservice',
'build-gateway',
'build-web',
'publish-next-npm',
'publish-npm',
]);
});
test('the verify step carries no path/event short-circuit of its own', async () => {
const pipeline = await readPublishPipeline();
// A `when` filter on `verify` would let a publish effect fire on an event
// class that skipped verification — the gate must be unconditional.
assert.equal(pipeline.steps.verify.when, undefined);
});
test('a publish step that bypasses verify fails the gate checker', () => {
// Negative fixture: a plausible publish pipeline where `publish-npm` hangs
// off `build` only and `build` never chains to `verify` — the exact bypass
// class SDLC-D-034 closes. The checker must go red on it.
const bypassingPipeline = `
steps:
install:
image: node:24-alpine
commands:
- pnpm install --frozen-lockfile
verify:
image: node:24-alpine
commands:
- |
if [ -z "$CI_COMMIT_SHA" ] || [ "$CI_COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then
echo "identity mismatch" >&2
exit 1
fi
- pnpm verify:release
depends_on:
- install
build:
image: node:24-alpine
commands:
- pnpm build
depends_on:
- install
publish-npm:
image: node:24-alpine
commands:
- pnpm publish
depends_on:
- build
`;
assert.throws(
() => assertPublishGate(parseYaml(bypassingPipeline)),
/publish-npm.*DIRECTLY.*verify/s,
);
});
test('a publish step chained to verify only transitively fails the gate checker', () => {
// Negative fixture: `build` depends on verify but `publish-npm` does not
// carry the direct edge — weaker than SDLC-D-034 requires of the real DAG.
const transitiveOnlyPipeline = `
steps:
install:
image: node:24-alpine
commands:
- pnpm install --frozen-lockfile
verify:
image: node:24-alpine
commands:
- |
if [ -z "$CI_COMMIT_SHA" ] || [ "$CI_COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then
echo "identity mismatch" >&2
exit 1
fi
- pnpm verify:release
depends_on:
- install
build:
image: node:24-alpine
commands:
- pnpm build
depends_on:
- install
- verify
publish-npm:
image: node:24-alpine
commands:
- pnpm publish
depends_on:
- build
`;
assert.throws(
() => assertPublishGate(parseYaml(transitiveOnlyPipeline)),
/publish-npm.*DIRECTLY.*verify/s,
);
});
test('a verify step without the commit-identity assertion fails the gate checker', () => {
const noIdentityPipeline = `
steps:
verify:
image: node:24-alpine
commands:
- pnpm verify:release
publish-npm:
image: node:24-alpine
commands:
- pnpm publish
depends_on:
- verify
`;
assert.throws(() => assertPublishGate(parseYaml(noIdentityPipeline)), /CI_COMMIT_SHA/);
});
test('the canonical verify:release stages mirror the PR CI pipeline one-for-one', async () => {
const ci = parseYaml(await readFile(ciYmlPath, 'utf8'));
const canonical = Object.fromEntries(STAGES.map((stage) => [stage.name, stage.commands]));
// The complete mandatory set, in gate order.
assert.deepEqual(
STAGES.map((stage) => stage.name),
['sanitization', 'upgrade-guard', 'typecheck', 'lint', 'format', 'test', 'build'],
);
// Guard stages: ci.yml commands minus its `apk add` environment prep must be
// exactly the canonical stage commands (order included).
for (const stageName of ['sanitization', 'upgrade-guard']) {
assert.deepEqual(
ci.steps[stageName].commands.filter((command) => !command.startsWith('apk add')),
canonical[stageName],
`canonical '${stageName}' stage must match the ci.yml step`,
);
}
// pnpm stages: ci.yml commands minus `corepack enable` must be exactly the
// canonical stage commands.
for (const stepName of ['typecheck', 'lint', 'format']) {
assert.deepEqual(
ci.steps[stepName].commands.filter((command) => command !== 'corepack enable'),
canonical[stepName],
`canonical '${stepName}' stage must match the ci.yml step`,
);
}
// The test stage is shared, but ci.yml wraps it in pipeline-level
// prerequisites the canonical command expects its caller to provide
// (SDLC-D-034): the postgres service + readiness wait + db:migrate, openssl,
// and the pinned pi runtime. None of those may be dropped silently.
for (const command of canonical.test) {
assert.ok(
ci.steps.test.commands.includes(command),
`ci.yml test step must run the canonical test stage command '${command}'`,
);
}
for (const fragment of [
'pg_isready -h ci-postgres',
'pnpm --filter @mosaicstack/db run db:migrate',
'npm install -g @earendil-works/[email protected]',
]) {
assert.ok(
ci.steps.test.commands.some((command) => command.includes(fragment)),
`ci.yml test step must keep its pipeline-level prerequisite '${fragment}'`,
);
}
});
test('the root package.json exposes verify:release as the canonical command', async () => {
const packageJson = JSON.parse(await readFile(path.join(process.cwd(), 'package.json'), 'utf8'));
assert.match(packageJson.scripts['verify:release'], /scripts\/verify-release\.mjs/);
});
+3 -6
View File
@@ -133,13 +133,10 @@ When the full `@mosaicstack/forge` package is available, Forge uses MACP task ex
```bash
# Run from CLI
# Fails closed with a typed FORGE_NO_EXECUTOR capability error when no real
# executor is wired — pass --simulate to opt into explicit typed simulation
# (every result carries status `simulated`, which satisfies nothing).
mosaic forge run path/to/brief.md [--simulate]
mosaic forge run path/to/brief.md
# Resume interrupted run (same fail-closed rule as forge run)
mosaic forge resume .forge/runs/20260401-143022/ [--simulate]
# Resume interrupted run
mosaic forge resume .forge/runs/20260401-143022/
# Check status
mosaic forge status .forge/runs/20260401-143022/
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Tests for newest_matching_file() in tools/install.sh.
#
# The function answers one question -- "which is the most recent backup / tarball
# here?" -- and its callers act destructively on the answer. Three ways of getting it
# wrong have already been found, and each has a case below:
#
# * `ls -1t | head -1` returns 141 under `set -o pipefail` once the listing fills a
# pipe buffer (~1600 names), because head closes the pipe and ls takes SIGPIPE.
# Callers assign it at top level under `set -e`, so a 141 aborts the run.
# * `mapfile` is a Bash 4 builtin. macOS ships Bash 3.2 and the installer supports
# Darwin, so the whole lookup was unavailable there -- and an empty answer is what
# sends the uninstaller down its delete-the-destination branch.
# * Any line-based parse of `ls` splits a filename containing a newline into two
# wrong answers.
#
# The large-population and newline cases are the point: with two or three ordinary
# names every version of this function passes, which is why the first two went
# unnoticed.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-newest-match-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
# Load the function under test and the mtime helper it depends on, with the same
# shell options install.sh runs under.
eval "$(sed -n '/^_MTIME_STYLE=/,/^}/p' "$ROOT/tools/install.sh")"
eval "$(sed -n '/^newest_matching_file()/,/^}/p' "$ROOT/tools/install.sh")"
POPULATED="$TMP/many"
mkdir -p "$POPULATED"
# Enough names to overflow a 64 KiB pipe buffer several times over.
for i in $(seq 1 5000); do
: > "$POPULATED/mosaicstack-mosaic-0.0.${i}.tgz"
done
sleep 1
: > "$POPULATED/mosaicstack-mosaic-9.9.9.tgz"
echo "[test] the newest match is returned from a directory large enough to fill a pipe"
GOT="$(newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz')"
[[ "$(basename "$GOT")" == "mosaicstack-mosaic-9.9.9.tgz" ]] || {
echo "expected the newest tarball, got '${GOT}'" >&2
exit 1
}
echo "[test] a large population does not make the lookup fail"
set +e
newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz' >/dev/null
RC=$?
set -e
[[ "$RC" -eq 0 ]] || { echo "expected rc=0, got ${RC} (141 means the SIGPIPE regression is back)" >&2; exit 1; }
echo "[test] a small population still works"
SMALL="$TMP/few"
mkdir -p "$SMALL"
: > "$SMALL/mosaicstack-gateway-0.0.1.tgz"
sleep 1
: > "$SMALL/mosaicstack-gateway-0.0.2.tgz"
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
[[ "$(basename "$GOT")" == "mosaicstack-gateway-0.0.2.tgz" ]] || {
echo "expected the newer gateway tarball, got '${GOT}'" >&2
exit 1
}
echo "[test] a name containing a space is returned whole"
SPACED="$TMP/spaced"
mkdir -p "$SPACED"
: > "$SPACED/agents.md.mosaic-bak-one two"
GOT="$(newest_matching_file "$SPACED" 'agents.md.mosaic-bak-*')"
[[ "$GOT" == "$SPACED/agents.md.mosaic-bak-one two" ]] || {
echo "expected the spaced name intact, got '${GOT}'" >&2
exit 1
}
echo "[test] a name containing a newline is returned whole, not split"
# The old `ls -1t` parse reported this file as two separate shorter names, neither of
# which exists -- so the caller saw a backup path that could not be restored.
NEWLINE="$TMP/newline"
mkdir -p "$NEWLINE"
WEIRD="$NEWLINE/agents.md.mosaic-bak-$(printf 'a\nb')"
: > "$WEIRD"
GOT="$(newest_matching_file "$NEWLINE" 'agents.md.mosaic-bak-*')"
[[ "$GOT" == "$WEIRD" ]] || {
echo "expected the newline-containing name intact, got '${GOT}'" >&2
exit 1
}
[[ -f "$GOT" ]] || { echo "the returned path does not name a real file" >&2; exit 1; }
echo "[test] no match is an empty answer, not an error"
EMPTY="$TMP/none"
mkdir -p "$EMPTY"
set +e
GOT="$(newest_matching_file "$EMPTY" 'nothing-*.tgz')"
RC=$?
set -e
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
echo "[test] a directory that does not exist is an empty answer, not an error"
set +e
GOT="$(newest_matching_file "$TMP/absent" 'nothing-*.tgz')"
RC=$?
set -e
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
echo "[test] an unanswerable lookup fails loudly instead of reporting no match"
# This is the distinction the uninstaller depends on. "No backup exists" is licence to
# delete the destination; "I could not tell" must never reach that branch.
_MTIME_STYLE=none
set +e
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
RC=$?
set -e
_MTIME_STYLE=""
[[ "$RC" -ne 0 ]] || {
echo "expected a non-zero rc when no mtime source is usable, got rc=0 output '${GOT}'" >&2
exit 1
}
echo "[test] the installer uses no Bash 4 syntax"
# A lint, not an execution test: this host has no Bash 3.2 to run under. It is still
# the thing that stops the regression, because every Bash 4 construct that has broken
# macOS here was introduced by someone who never ran the script there either.
# Comments are stripped first -- the ones above name these constructs on purpose.
BASH4_HITS="$(
sed 's/#.*$//' "$ROOT/tools/install.sh" \
| grep -nE '(^|[^[:alnum:]_])(mapfile|readarray)([^[:alnum:]_]|$)|declare[[:space:]]+-[a-zA-Z]*A|local[[:space:]]+-[a-zA-Z]*A|\$\{[A-Za-z_][A-Za-z0-9_]*(\^\^|,,)' \
|| true
)"
[[ -z "$BASH4_HITS" ]] || {
echo "tools/install.sh uses Bash 4+ syntax, which macOS's Bash 3.2 cannot run:" >&2
echo "$BASH4_HITS" >&2
exit 1
}
echo "[test] newest_matching_file tests passed"
+6 -2
View File
@@ -153,17 +153,21 @@ reset_state() {
}
reset_state
# The installer now provisions Node itself, so Node 20 no longer stops a --next
# install -- it gets replaced. What still has to hold is that the >= 22 gate fires
# before anything is installed, so this asserts it on the one lane where refusing is
# still the outcome. The replacement path is covered by install-node-provisioning.test.sh.
echo "[test] --next rejects Node 20 before any install action"
if OUTPUT="$(
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NODE_MAJOR=20 PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch --no-node-install 2>&1
)"; then
echo "expected Node 20 next-lane install to fail" >&2
exit 1
fi
grep -qF 'Node.js >= 22 required for the --next lane' <<<"$OUTPUT"
grep -qF 'Node >= 22 required and --no-node-install was given.' <<<"$OUTPUT"
[[ ! -s "$LOG" ]] || { echo "Node 20 gate ran npm actions" >&2; exit 1; }
reset_state
+460
View File
@@ -0,0 +1,460 @@
#!/usr/bin/env bash
# Tests for the installer's Node provisioning.
#
# The installer's whole promise is that one command turns a bare host into a working
# one. Node was the exception: it was a hard prerequisite the installer checked and
# refused, so on a greenfield host the documented one-command install failed first.
# These tests pin the fixed behaviour, including the refusals.
#
# Everything runs offline. MOSAIC_NODE_DIST points at a local directory laid out like
# nodejs.org/dist, served over file:// -- so the download, the checksum gate, and the
# unpack are the real code paths, with no network and no real Node download.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-provision-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
DIST="$TMP/dist"
FAKE_BIN="$TMP/bin"
HOME_DIR="$TMP/home"
PREFIX="$TMP/prefix"
MOSAIC_HOME_DIR="$TMP/mosaic"
STATE="$TMP/state"
LOG="$TMP/npm.log"
NODE_HOME="$TMP/nodehome"
mkdir -p "$DIST" "$FAKE_BIN" "$HOME_DIR" "$STATE"
REAL_NODE="$(command -v node)"
# The platform triple, derived the same way the installer derives it.
case "$(uname -s)" in
Linux) TEST_OS=linux ;;
Darwin) TEST_OS=darwin ;;
*) echo "[skip] no Node build for $(uname -s)"; exit 0 ;;
esac
case "$(uname -m)" in
x86_64|amd64) TEST_ARCH=x64 ;;
aarch64|arm64) TEST_ARCH=arm64 ;;
armv7l) TEST_ARCH=armv7l ;;
*) echo "[skip] no Node build for $(uname -m)"; exit 0 ;;
esac
PLATFORM="${TEST_OS}-${TEST_ARCH}"
VERSION=v22.99.0 # the one that must be chosen
MID_VERSION=v22.50.0 # same major, older -- catches "take the last match"
OLD_VERSION=v20.99.0 # wrong major
NEWER_MAJOR=v24.99.0 # listed first -- catches "take the first entry"
# ─── fixtures ─────────────────────────────────────────────────────────────────
# A node stub that answers the installer's version probe and defers everything else
# to the real interpreter, so the rest of the install still runs.
#
# The major is baked in per stub rather than read from the environment. A shared env
# var would be read by the downloaded Node too, so the "system Node is too old" case
# would install a replacement that also claimed to be too old.
write_node_stub() {
local path="$1" major="${2:-22}"
cat > "$path" <<STUB
#!/usr/bin/env bash
set -euo pipefail
if [[ "\$*" == *'process.versions.node.split'* ]]; then
printf '%s' "${major}"
exit 0
fi
if [[ "\${1:-}" == "--version" ]]; then
printf 'v%s.99.0\n' "${major}"
exit 0
fi
exec "\${MOSAIC_TEST_REAL_NODE:?}" "\$@"
STUB
chmod +x "$path"
}
write_npm_stub() {
cat > "$1" <<'STUB'
#!/usr/bin/env bash
set -euo pipefail
echo "$*" >> "${MOSAIC_TEST_NPM_LOG:?}"
STATE="${MOSAIC_TEST_STATE:?}"
if [[ "${1:-}" == "view" ]]; then
case "$2 $3" in
"@mosaicstack/mosaic@next version") echo "0.0.50-next.999" ;;
"@mosaicstack/gateway@next version") echo "0.0.7-next.999" ;;
"@mosaicstack/mosaic version") echo "0.0.49" ;;
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "${1:-}" == "install" ]]; then
case "$*" in
*"@mosaicstack/mosaic@"*) echo "0.0.50-next.999" > "$STATE/mosaic" ;;
*"@mosaicstack/gateway@"*) echo "0.0.7-next.999" > "$STATE/gateway" ;;
esac
exit 0
fi
if [[ "${1:-}" == "ls" ]]; then
printf '{"dependencies":{"@mosaicstack/mosaic":{"version":"%s"},"@mosaicstack/gateway":{"version":"%s"}}}\n' \
"$(cat "$STATE/mosaic" 2>/dev/null || echo '')" \
"$(cat "$STATE/gateway" 2>/dev/null || echo '')"
exit 0
fi
exit 0
STUB
chmod +x "$1"
}
# Build a nodejs.org-shaped release: the tarball, and a SHASUMS256.txt over it.
publish_release() {
local version="$1" corrupt_checksum="${2:-false}"
local base="node-${version}-${PLATFORM}"
local stage="$TMP/stage-${version}"
rm -rf "$stage"
mkdir -p "$stage/${base}/bin"
write_node_stub "$stage/${base}/bin/node" "$(sed 's/^v//; s/\..*//' <<<"$version")"
write_npm_stub "$stage/${base}/bin/npm"
mkdir -p "${DIST}/${version}"
tar -czf "${DIST}/${version}/${base}.tar.gz" -C "$stage" "$base"
local sum
if command -v sha256sum &>/dev/null; then
sum="$(sha256sum "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')"
else
sum="$(shasum -a 256 "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')"
fi
if [[ "$corrupt_checksum" == "true" ]]; then
sum="0000000000000000000000000000000000000000000000000000000000000000"
fi
printf '%s %s.tar.gz\n' "$sum" "$base" > "${DIST}/${version}/SHASUMS256.txt"
}
publish_release "$VERSION"
publish_release "$MID_VERSION"
publish_release "$OLD_VERSION"
publish_release "$NEWER_MAJOR"
# Newest-first, as nodejs.org publishes it. Every wrong entry is genuinely installable,
# so a resolver that picks one fails on the assertion rather than on a 404 -- the
# assertion is then about version selection and not about the fixture.
printf '[{"version":"%s"},{"version":"%s"},{"version":"%s"},{"version":"%s"}]\n' \
"$NEWER_MAJOR" "$VERSION" "$MID_VERSION" "$OLD_VERSION" > "$DIST/index.json"
# A PATH with the usual tools but no Node toolchain, so "a host with no Node" is
# actually true on a developer machine and in CI, both of which have one installed.
NONODE_BIN="$TMP/nonode-bin"
mkdir -p "$NONODE_BIN"
for candidate in /usr/bin/* /bin/*; do
[[ -e "$candidate" ]] || continue
case "$(basename "$candidate")" in
node|npm|npx|corepack|nodejs) continue ;;
esac
ln -sf "$candidate" "$NONODE_BIN/$(basename "$candidate")" 2>/dev/null || true
done
if PATH="$NONODE_BIN" command -v node &>/dev/null; then
echo "[skip] could not build a Node-free PATH on this host" >&2
exit 0
fi
reset_home() {
rm -rf "$HOME_DIR" "$PREFIX" "$MOSAIC_HOME_DIR" "$NODE_HOME" "$LOG" "$STATE"
mkdir -p "$HOME_DIR" "$STATE"
: > "$LOG"
}
# Run the installer with no Node anywhere on PATH.
run_bare() {
env -u npm_config_prefix \
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" "$@"
}
# ─── tests ────────────────────────────────────────────────────────────────────
reset_home
echo "[test] a host with no Node gets one, and the CLI install proceeds"
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
grep -qF -- "Node is not installed" <<<"$OUTPUT"
grep -qF -- "Installed Node ${VERSION}" <<<"$OUTPUT"
[[ -x "${NODE_HOME}/${VERSION}/bin/node" ]]
grep -qF -- "install -g @mosaicstack/[email protected]" "$LOG"
echo "[test] the newest release of the required major is chosen"
# The index lists a higher major first and an older release of the right major after
# the right answer, so "first entry" and "last match" both produce a wrong directory.
[[ -d "${NODE_HOME}/${VERSION}" ]]
[[ ! -d "${NODE_HOME}/${NEWER_MAJOR}" ]]
[[ ! -d "${NODE_HOME}/${MID_VERSION}" ]]
[[ ! -d "${NODE_HOME}/${OLD_VERSION}" ]]
echo "[test] future shells can find both Node and the CLI"
grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.profile"
grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.profile"
# Debian's .bashrc returns early when non-interactive, so the login profile is the
# one that matters -- but an interactive non-login shell only reads .bashrc.
grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.bashrc"
grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.bashrc"
echo "[test] a real login shell resolves node, not just the text of a profile line"
# Grepping the file only proves the installer wrote something. This starts an actual
# login shell against that HOME and asks it to find the binary.
RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')"
[[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || {
echo "a login shell resolved node to '${RESOLVED}'" >&2
exit 1
}
echo "[test] a systemd --user unit gets the same PATH, via environment.d"
# Units read no shell file at all, which is how a Mosaic agent seat starts.
ENVD="$HOME_DIR/.config/environment.d/50-mosaic-path.conf"
[[ -f "$ENVD" ]] || { echo "no environment.d drop-in was written" >&2; exit 1; }
grep -qF -- "PATH=${NODE_HOME}/${VERSION}/bin:\${PATH}" "$ENVD"
grep -qF -- "PATH=${PREFIX}/bin:\${PATH}" "$ENVD"
echo "[test] re-running reuses the Node it installed and does not duplicate PATH lines"
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
grep -qF -- "from ${NODE_HOME}" <<<"$OUTPUT"
[[ "$(grep -c 'export PATH=' "$HOME_DIR/.profile")" -eq 2 ]]
[[ "$(grep -c 'export PATH=' "$HOME_DIR/.bashrc")" -eq 2 ]]
[[ "$(grep -c '^PATH=' "$ENVD")" -eq 2 ]]
reset_home
echo "[test] a ~/.bash_profile does not silently swallow the PATH entry"
# A bash login shell reads the first of .bash_profile / .bash_login / .profile that
# exists and never looks at the rest. Writing only .profile is a no-op on such a host,
# and the failure is invisible until something cannot find node.
: > "$HOME_DIR/.bash_profile"
run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1
RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')"
[[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || {
echo "with a .bash_profile present, a login shell resolved node to '${RESOLVED}'" >&2
exit 1
}
reset_home
echo "[test] a commented-out example does not count as the PATH entry already existing"
# The idempotence check used to be an unanchored substring match, so a line like this
# in a user's profile made the installer skip the real entry.
mkdir -p "$HOME_DIR"
printf '# export PATH="%s/%s/bin:$PATH"\n' "$NODE_HOME" "$VERSION" > "$HOME_DIR/.profile"
run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1
[[ "$(grep -c '^export PATH=' "$HOME_DIR/.profile")" -eq 2 ]] || {
echo "expected two real export lines, found:" >&2
cat "$HOME_DIR/.profile" >&2
exit 1
}
reset_home
echo "[test] --no-node-install refuses instead of installing"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-node-install 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "--no-node-install was given" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] --check never provisions Node"
set +e
OUTPUT="$(run_bare --check --cli --next 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "Required command not found: node" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a tampered download is rejected and nothing is installed"
publish_release "$VERSION" true
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "failed checksum verification" <<<"$OUTPUT"
# Not just "no usable node": nothing at all may survive. An unpack that ran before
# verification, or a staging directory left behind, would still satisfy the weaker
# check while leaving unverified bytes on disk for the next run to adopt.
[[ ! -x "${NODE_HOME}/${VERSION}/bin/node" ]]
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
[[ ! -e "${NODE_HOME}/${VERSION}.partial" ]]
[[ ! -d "$NODE_HOME" ]] || [[ -z "$(ls -A "$NODE_HOME")" ]]
publish_release "$VERSION"
reset_home
echo "[test] a system Node that is new enough is used as-is and left alone"
write_node_stub "$FAKE_BIN/node" 22
write_npm_stub "$FAKE_BIN/npm"
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
grep -qF -- "satisfies the >= 22 requirement" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a system Node that is too old is replaced rather than accepted"
write_node_stub "$FAKE_BIN/node" 18
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
grep -qF -- "older than the required >= 22" <<<"$OUTPUT"
[[ -x "${NODE_HOME}/${VERSION}/bin/node" ]]
# ─── refusals: untrusted input that reaches a path or an exec ─────────────────
reset_home
echo "[test] an empty checksum manifest is refused, not read as an empty digest"
: > "${DIST}/${VERSION}/SHASUMS256.txt"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "No checksum published" <<<"$OUTPUT"
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
publish_release "$VERSION"
reset_home
echo "[test] a manifest naming a regex-equivalent file does not vouch for this one"
# The lookup used to interpolate the filename into a grep pattern. A Node tarball name
# is mostly dots, and a dot matches any character, so this line -- which names a
# different file -- was accepted as this file's checksum.
DECOY="node-${VERSION}-${PLATFORM}Xtar.gz"
printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$DECOY" > "${DIST}/${VERSION}/SHASUMS256.txt"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "No checksum published" <<<"$OUTPUT"
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
publish_release "$VERSION"
reset_home
echo "[test] a manifest listing the same file twice is refused rather than guessed at"
BASE="node-${VERSION}-${PLATFORM}.tar.gz"
GOOD="$(awk '{print $1}' "${DIST}/${VERSION}/SHASUMS256.txt")"
{
printf '%s %s\n' "$GOOD" "$BASE"
printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$BASE"
} > "${DIST}/${VERSION}/SHASUMS256.txt"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "refusing to guess" <<<"$OUTPUT"
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
publish_release "$VERSION"
echo "[test] a version string is checked before it becomes a path"
# MOSAIC_NODE_VERSION becomes a directory name under NODE_HOME, and that directory is
# later handed to `rm -rf`. This is defence in depth, and the honest scope should be
# recorded: the plain 'v..' case is separately refused by rm itself, and a traversal
# value breaks the download URL before the removal is reached. Measured, not assumed.
# What the check buys is that neither of those accidents is what is protecting us, and
# that a typo is refused with its own name on it rather than a curl error.
eval "$(sed -n '/^node_valid_version()/,/^}/p' "$ROOT/tools/install.sh")"
for good in v22.99.0 v0.0.0 v22.11.0 v100.0.1; do
node_valid_version "$good" || { echo "rejected a real version: ${good}" >&2; exit 1; }
done
for bad in 'v..' '..' 'v9.9.9/../../elsewhere' '/etc' 'v22' 'v22.1' '22.1.0' 'v22.1.0-rc1' '' 'v1.0.0 ' '$(id)'; do
! node_valid_version "$bad" || { echo "accepted a bad version: '${bad}'" >&2; exit 1; }
done
reset_home
echo "[test] a bad MOSAIC_NODE_VERSION is refused by name, before any download"
set +e
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" MOSAIC_NODE_VERSION="v9.9.9/../../elsewhere" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "MOSAIC_NODE_VERSION" <<<"$OUTPUT"
grep -qF -- "Downloading Node" <<<"$OUTPUT" && {
echo "the download started despite an invalid version" >&2
exit 1
}
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a download location with no transport integrity is refused"
set +e
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="http://example.invalid/dist" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "MOSAIC_NODE_DIST must be" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a path containing shell syntax is not written into a profile"
# The PATH line is executed by every future shell that reads the file, so a directory
# holding $() or a quote would run there as code.
EVIL="$TMP/ev\$(touch $TMP/pwned)il"
set +e
env -u npm_config_prefix \
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$EVIL" \
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch >/dev/null 2>&1
set -e
if [[ -f "$HOME_DIR/.profile" ]]; then
grep -qF -- 'touch' "$HOME_DIR/.profile" && {
echo "a command substitution was written into .profile" >&2
exit 1
}
fi
[[ ! -e "$TMP/pwned" ]] || { echo "the embedded command ran" >&2; exit 1; }
echo "[test] installer node provisioning tests passed"
+445 -286
View File
@@ -25,6 +25,9 @@
# tarballs and installs them globally. Use to test a branch
# end-to-end before cutting a release.
# --yes Accept all defaults; headless/non-interactive install
# --no-node-install Do not provision Node; fail if Node >= 20 (>= 22 with
# --next) is not already present. Default is to install a
# user-local Node under ~/.mosaic/node when it is missing.
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
#
@@ -38,6 +41,11 @@
# MOSAIC_NEXT — equivalent to --next (set to 1)
# MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# MOSAIC_NODE_HOME — user-local Node install dir (default: ~/.mosaic/node)
# MOSAIC_NODE_VERSION — pin the Node release (default: latest of the
# required major, e.g. v22.23.2)
# MOSAIC_NODE_DIST — Node download mirror (default: nodejs.org/dist)
# MOSAIC_NO_NODE_INSTALL — equivalent to --no-node-install (set to 1)
# ──────────────────────────────────────────────────────────────────────────────
#
# Wrapped in main() for safe curl-pipe usage.
@@ -82,7 +90,7 @@ if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
fi
installer_usage() {
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--no-node-install] [--uninstall]\n' >&2
}
while [[ $# -gt 0 ]]; do
@@ -109,6 +117,7 @@ while [[ $# -gt 0 ]]; do
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
--yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--no-node-install) MOSAIC_NO_NODE_INSTALL=1; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;;
*)
printf 'Error: Unknown argument: %s\n' "$1" >&2
@@ -150,6 +159,43 @@ fi
WORK_DIR=""
EXTRACTED_DIR=""
# Modification time of one file, as an integer. GNU/BusyBox stat takes -c, BSD/macOS
# stat takes -f, and there is no flag both accept -- so probe once and remember.
_MTIME_STYLE=""
file_mtime() {
if [[ -z "$_MTIME_STYLE" ]]; then
if stat -c %Y . >/dev/null 2>&1; then
_MTIME_STYLE=gnu
elif stat -f %m . >/dev/null 2>&1; then
_MTIME_STYLE=bsd
else
_MTIME_STYLE=none
fi
fi
case "$_MTIME_STYLE" in
gnu) stat -c %Y -- "$1" 2>/dev/null ;;
bsd) stat -f %m -- "$1" 2>/dev/null ;;
*) return 1 ;;
esac
}
# The most recently modified file in "$dir" matching "$pattern".
#
# Three separate contracts, and callers must tell them apart:
# rc=0 with output — this is the newest match
# rc=0, no output — the directory or the pattern matched nothing
# rc=1 — the answer could not be determined
#
# The third one exists because the uninstall path treats "no backup" as licence to
# delete the destination. A lookup that fails must never be mistaken for a lookup
# that succeeded and found nothing.
#
# The candidates come from a glob and are compared in-shell, never rendered as text.
# That is deliberate, and it closes three bugs at once: `mapfile` is a Bash 4 builtin
# and macOS ships Bash 3.2, which this installer supports (see node_platform); piping
# `ls` into `head` dies on SIGPIPE under `set -o pipefail` once the listing fills a
# pipe buffer, returning 141 with no output; and any line-based parse of `ls` splits a
# filename that contains a newline into two wrong answers.
newest_matching_file() {
local dir="$1"
local pattern="$2"
@@ -160,8 +206,17 @@ newest_matching_file() {
matches=("$dir"/$pattern)
shopt -u nullglob
[[ "${#matches[@]}" -gt 0 ]] || return 0
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
ls -1t "${matches[@]}" 2>/dev/null | head -1
local newest="" newest_t="" candidate t
for candidate in "${matches[@]}"; do
t="$(file_mtime "$candidate")" || return 1
[[ -n "$t" ]] || return 1
if [[ -z "$newest_t" ]] || [[ "$t" -gt "$newest_t" ]]; then
newest="$candidate"
newest_t="$t"
fi
done
printf '%s\n' "$newest"
}
# ─── uninstall path ───────────────────────────────────────────────────────────
@@ -224,12 +279,17 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
for dest in "${RUNTIME_DESTS[@]}"; do
base="$(basename "$dest")"
dir="$(dirname "$dest")"
# Find most recent backup
# Find most recent backup. A lookup that could not answer is not the same as
# "there is no backup": removing the destination on a failed lookup would destroy
# the file the backup exists to restore.
backup=""
backup_lookup_ok=true
if [[ -d "$dir" ]]; then
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" || backup_lookup_ok=false
fi
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
if [[ "$backup_lookup_ok" != "true" ]]; then
echo " Skipped: $dest (could not check for a backup; left in place)"
elif [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
cp "$backup" "$dest"
rm -f "$backup"
echo " Restored: $dest"
@@ -309,122 +369,376 @@ require_cmd() {
fi
}
# True if any shell rc file already puts $1 on PATH.
# ─── node provisioning ────────────────────────────────────────────────────────
#
# 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
# Node is a hard prerequisite for everything below, and a greenfield host does not
# have it. Treating that as the operator's problem made the documented one-command
# install a two-command install that fails first — so the installer provisions Node
# itself.
#
# It installs into the user's own tree rather than through apt/dnf/brew on purpose:
# no root, one code path on every distro, and it works on an immutable host where
# there is no system package manager to reach for. A system Node that is already
# new enough is always preferred and left untouched.
NODE_HOME="${MOSAIC_NODE_HOME:-$HOME/.mosaic/node}"
NODE_DIST="${MOSAIC_NODE_DIST:-https://nodejs.org/dist}"
FLAG_NO_NODE_INSTALL=false
if [[ "${MOSAIC_NO_NODE_INSTALL:-0}" == "1" ]]; then
FLAG_NO_NODE_INSTALL=true
fi
# A Node version string is about to become a directory name under NODE_HOME, and that
# directory is passed to `rm -rf`. Nothing reaches a filesystem operation until it has
# matched this. `v..` is the case that matters: it resolves to NODE_HOME's parent.
node_valid_version() {
[[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
# 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
# The download location is executable code. Refuse a scheme that carries no transport
# integrity at all, and say plainly what an override does and does not buy, since the
# tarball and the checksum that vouches for it then come from the same place.
case "$NODE_DIST" in
https://*) ;;
file://*) ;;
*)
if [[ -n "${MOSAIC_NODE_DIST:-}" ]]; then
fail "MOSAIC_NODE_DIST must be an https:// or file:// URL; got '${NODE_DIST}'"
exit 1
fi
;;
esac
if path_entry_exists "$dir"; then
node_major_of() {
# Read the major from the binary rather than parsing `node --version` text, so a
# build with a suffix (v22.1.0-nightly…) does not read as a different major.
"$1" -e 'process.stdout.write(String(process.versions.node.split(".")[0]))' 2>/dev/null || echo 0
}
# The platform triple in a nodejs.org tarball name, or empty where nodejs.org
# publishes no build we can use.
node_platform() {
local os arch
case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) return 1 ;;
esac
# Official Linux builds are glibc-linked; on musl they install and then fail to run.
if [[ "$os" == "linux" ]] && ldd --version 2>&1 | grep -qi musl; then
return 1
fi
case "$(uname -m)" in
x86_64|amd64) arch=x64 ;;
aarch64|arm64) arch=arm64 ;;
armv7l) arch=armv7l ;;
*) return 1 ;;
esac
printf '%s-%s' "$os" "$arch"
}
# Newest release of the wanted major. Resolved rather than pinned so a fresh install
# picks up security releases; MOSAIC_NODE_VERSION pins it when reproducibility matters.
node_resolve_version() {
local want="$1" index resolved
if [[ -n "${MOSAIC_NODE_VERSION:-}" ]]; then
if ! node_valid_version "$MOSAIC_NODE_VERSION"; then
fail "MOSAIC_NODE_VERSION must look like v22.11.0; got '${MOSAIC_NODE_VERSION}'"
return 1
fi
printf '%s' "$MOSAIC_NODE_VERSION"
return 0
fi
index="$(curl -fsSL --retry 3 "${NODE_DIST}/index.json" 2>/dev/null)" || return 1
# index.json is newest-first, so the first match is the latest of that major.
# grep/sed rather than a JSON parser because node is the thing we do not have yet.
# No `| head -1` here: head closes the pipe, grep takes SIGPIPE, and under
# `set -o pipefail` the whole substitution returns 141 -- the bug already fixed in
# newest_matching_file. Take the first line in the shell instead.
local found
found="$(printf '%s' "$index" | grep -o "\"version\":\"v${want}\.[0-9]\+\.[0-9]\+\"")" || return 1
found="${found%%$'\n'*}"
resolved="${found#\"version\":\"}"
resolved="${resolved%\"}"
# The index is remote input, and what comes out of it becomes a path.
[[ -n "$resolved" ]] || return 1
node_valid_version "$resolved" || return 1
printf '%s' "$resolved"
}
if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
profile="$HOME/.zshenv"
else
profile="$HOME/.profile"
fi
node_verify_checksum() {
local dir="$1" file="$2" expected="" line name matched=0
local manifest="${dir}/SHASUMS256.txt"
# 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\""
if [[ ! -f "$manifest" ]]; then
fail "No checksum manifest was downloaded for ${file}"
return 1
fi
{
echo ""
echo "# $label"
echo "export PATH=\"$dir:\$PATH\""
} >>"$profile"
ok "Added $dir to PATH in $profile"
return 0
# Compare filenames exactly rather than `grep " ${file}$"`. A Node tarball name is
# mostly dots, and in a regex a dot matches any character -- so a manifest line for
# a name that merely looks like this one would be accepted as this one's checksum.
#
# Every line is read, not just the first match: two entries for the same file mean
# the manifest is not trustworthy, and picking either one is a decision this code
# has no basis to make.
while IFS= read -r line || [[ -n "$line" ]]; do
name="${line#* }"
[[ "$name" == "$file" ]] || continue
expected="${line%% *}"
matched=$(( matched + 1 ))
done < "$manifest"
if [[ "$matched" -eq 0 ]]; then
fail "No checksum published for ${file}"
return 1
fi
if [[ "$matched" -gt 1 ]]; then
fail "Checksum manifest lists ${file} ${matched} times; refusing to guess."
return 1
fi
if [[ ! "$expected" =~ ^[0-9a-fA-F]{64}$ ]]; then
fail "Checksum for ${file} is not a SHA-256 digest: '${expected}'"
return 1
fi
local actual
if command -v sha256sum &>/dev/null; then
actual="$(sha256sum "${dir}/${file}" | awk '{print $1}')"
elif command -v shasum &>/dev/null; then
actual="$(shasum -a 256 "${dir}/${file}" | awk '{print $1}')"
else
fail "Cannot verify the Node download: neither sha256sum nor shasum is present."
return 1
fi
if [[ "$actual" != "$expected" ]]; then
fail "Node download failed checksum verification (${file})"
dim " expected ${expected}"
dim " got ${actual}"
return 1
fi
}
# Persist $PREFIX/bin on PATH instead of only warning about it.
# Download, verify and unpack one Node release into a scratch dir, then move it into
# place. Staging first means a failed or interrupted download never leaves a half-tree
# that the next run would mistake for an installed Node.
node_fetch_and_unpack() {
local version="$1" platform="$2" work="$3"
local base="node-${version}-${platform}"
local tarball="${base}.tar.gz"
local dest="${NODE_HOME}/${version}"
info "Downloading Node ${version} (${platform})…"
curl -fsSL --retry 3 -o "${work}/${tarball}" "${NODE_DIST}/${version}/${tarball}" || {
fail "Could not download ${NODE_DIST}/${version}/${tarball}"
return 1
}
curl -fsSL --retry 3 -o "${work}/SHASUMS256.txt" "${NODE_DIST}/${version}/SHASUMS256.txt" || {
fail "Could not download the Node checksum file"
return 1
}
node_verify_checksum "$work" "$tarball" || return 1
mkdir -p "$NODE_HOME"
tar -xzf "${work}/${tarball}" -C "$work" || { fail "Could not unpack ${tarball}"; return 1; }
rm -rf "${dest}.partial"
mv "${work}/${base}" "${dest}.partial" || { fail "Could not stage Node into ${NODE_HOME}"; return 1; }
rm -rf "$dest"
mv "${dest}.partial" "$dest" || { fail "Could not install Node into ${dest}"; return 1; }
ok "Installed Node ${version}${dest}"
}
# Install one Node release, reusing it if this installer already put it there.
#
# 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.
# The scratch dir is removed here rather than by a RETURN trap inside the worker: a
# RETURN trap set inside a function stays installed after that function returns, so it
# fires again on the next unrelated function return, where its variables are gone.
node_install() {
local version="$1" platform="$2"
local dest="${NODE_HOME}/${version}"
# Re-checked here, not only where the version was resolved: `dest` is about to be
# handed to `rm -rf`, and this is the last place before that happens. A version of
# `..` would point the removal at NODE_HOME's parent.
if ! node_valid_version "$version"; then
fail "Refusing to install Node from an unexpected version string: '${version}'"
return 1
fi
if [[ -x "${dest}/bin/node" ]]; then
info "Reusing Node ${version} already at ${dest}"
return 0
fi
local work rc=0
work="$(mktemp -d)" || return 1
node_fetch_and_unpack "$version" "$platform" "$work" || rc=$?
rm -rf "$work"
return "$rc"
}
# Put a directory on PATH for future processes, once. A user-local Node and a
# user-local npm prefix are only useful if the next process can still find them, and
# the installer used to do no more than warn about it.
#
# There is no one file that covers this. Each target below is the only thing that
# works for some way a user -- or an agent seat -- actually starts a process:
#
# ~/.profile POSIX login shells, and `bash -lc` when no bash-specific
# profile exists.
# ~/.bash_profile A bash login shell reads the first of these that exists and
# ~/.bash_login then never reads ~/.profile. On a host with one of them,
# writing only ~/.profile is a silent no-op. Appended to when
# present, never created -- creating one would itself start
# shadowing ~/.profile for everything else the user has there.
# ~/.bashrc Interactive non-login shells. Debian's returns early when the
# shell is not interactive, so it cannot stand in for a profile.
# ~/.zshenv Every zsh invocation, including `ssh host cmd`. A remote
# non-interactive zsh reads neither ~/.zprofile nor ~/.zshrc,
# which is what the previous version of this function wrote.
# environment.d systemd --user units, which read no shell file at all. A
# Mosaic agent seat starts as a unit, so this one is the point.
persist_path_line() {
local dir="$1" line rc wrote=""
# This text is written into files that a future shell will execute, so a directory
# containing shell syntax would run there as code. Refuse rather than escape: such
# a path can only arrive through MOSAIC_NODE_HOME or MOSAIC_PREFIX, and a real
# install directory never needs these characters.
if [[ "$dir" =~ [\"\$\`\\] ]] || [[ "$dir" == *"'"* ]] || [[ "$dir" == *$'\n'* ]]; then
warn "Not adding ${dir} to PATH automatically: the path contains shell syntax."
dim " Put it on PATH by hand, or reinstall to a path without those characters."
return 0
fi
line="export PATH=\"${dir}:\$PATH\""
local files=("$HOME/.profile")
case "$(basename "${SHELL:-/bin/bash}")" in
zsh)
files+=("$HOME/.zshenv")
;;
*)
files+=("$HOME/.bashrc")
if [[ -f "$HOME/.bash_profile" ]]; then files+=("$HOME/.bash_profile"); fi
if [[ -f "$HOME/.bash_login" ]]; then files+=("$HOME/.bash_login"); fi
;;
esac
for rc in "${files[@]}"; do
# -x anchors the match to a whole line. Without it, a commented-out example of
# this same export counts as already present and the real entry never gets
# written -- the failure then looks like the installer simply did nothing.
if [[ -f "$rc" ]] && grep -Fqx "$line" "$rc"; then
continue
fi
{
printf '\n# Added by the Mosaic Stack installer\n'
printf '%s\n' "$line"
} >> "$rc"
wrote+="${wrote:+, }${rc}"
done
# systemd --user units inherit from the user manager, not from any shell.
local envd="$HOME/.config/environment.d"
local envd_file="$envd/50-mosaic-path.conf"
local envd_line="PATH=${dir}:\${PATH}"
if mkdir -p "$envd" 2>/dev/null; then
if [[ ! -f "$envd_file" ]] || ! grep -Fqx "$envd_line" "$envd_file"; then
printf '%s\n' "$envd_line" >> "$envd_file"
wrote+="${wrote:+, }${envd_file}"
fi
fi
if [[ -n "$wrote" ]]; then
ok "Added ${dir} to PATH in ${wrote}"
dim " This shell: export PATH=\"${dir}:\$PATH\""
dim " systemd --user: systemctl --user daemon-reload (or log in again)"
fi
}
# Make the installed `mosaic` reachable, now and in the next shell. Warning about
# this and moving on left a completed install whose CLI could not be found, which
# reads to an operator as a failed install.
ensure_prefix_on_path() {
if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then
return
persist_path_line "$PREFIX/bin"
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
PATH="$PREFIX/bin:$PATH"
export PATH
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"
# Guarantee a Node of at least $1 on PATH for the rest of this run.
ensure_node() {
local want="$1" current=0
if command -v node &>/dev/null; then
current="$(node_major_of node)"
if [[ "$current" -ge "$want" ]]; then
ok "Node $(node --version) satisfies the >= ${want} requirement"
return 0
fi
fi
command -v "$transport" &>/dev/null && return 0
# A Node this installer put there previously, from an earlier run or another lane.
local candidate
for candidate in "$NODE_HOME"/*/bin/node; do
[[ -x "$candidate" ]] || continue
if [[ "$(node_major_of "$candidate")" -ge "$want" ]]; then
PATH="$(dirname "$candidate"):$PATH"
export PATH
ok "Using Node $(node --version) from ${NODE_HOME}"
persist_path_line "$(dirname "$candidate")"
return 0
fi
done
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)."
if [[ "$current" == "0" ]]; then
info "Node is not installed; the Mosaic CLI needs Node >= ${want}."
else
info "Node v${current} is older than the required >= ${want}."
fi
if [[ "$FLAG_NO_NODE_INSTALL" == "true" ]]; then
fail "Node >= ${want} required and --no-node-install was given."
echo " Install Node >= ${want} and re-run, or drop --no-node-install."
exit 1
fi
local platform
if ! platform="$(node_platform)"; then
fail "No official Node build for $(uname -s)/$(uname -m)."
echo " Install Node >= ${want} with your system package manager and re-run."
exit 1
fi
require_cmd curl
require_cmd tar
local version
version="$(node_resolve_version "$want")" || true
if [[ -z "$version" ]]; then
fail "Could not resolve a Node ${want}.x release from ${NODE_DIST}."
echo " Check network access, or pin one: MOSAIC_NODE_VERSION=v${want}.0.0"
exit 1
fi
info "Installing Node ${version} into ${NODE_HOME} (no root required)…"
if ! node_install "$version" "$platform"; then
fail "Node installation failed."
echo " Install Node >= ${want} manually and re-run, or re-run with --no-node-install"
echo " once it is present."
exit 1
fi
PATH="${NODE_HOME}/${version}/bin:$PATH"
export PATH
persist_path_line "${NODE_HOME}/${version}/bin"
# Prove it, rather than assuming the unpack produced a working binary.
if ! command -v node &>/dev/null || [[ "$(node_major_of node)" -lt "$want" ]]; then
fail "Node ${version} was installed but is not usable on PATH."
exit 1
fi
ok "Node $(node --version) ready"
}
installed_cli_version() {
@@ -568,8 +882,10 @@ install_cli_from_source() {
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
local cli_tgz gw_tgz
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"
# An unanswerable lookup becomes an empty path, which the -f guards below report
# properly. Nothing destructive happens on this path, so failing soft is safe here.
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" || cli_tgz=""
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')" || gw_tgz=""
if [[ ! -f "$cli_tgz" ]]; then
fail "CLI tarball was not produced by pnpm pack."
@@ -634,186 +950,28 @@ 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
NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')"
if [[ "$NODE_MAJOR" -lt 20 ]]; then
fail "Node.js >= 20 required (found v$(node --version))"
exit 1
NODE_REQUIRED=20
if [[ "$FLAG_NEXT" == "true" ]]; then
NODE_REQUIRED=22
fi
if [[ "$FLAG_NEXT" == "true" && "$NODE_MAJOR" -lt 22 ]]; then
fail "Node.js >= 22 required for the --next lane (found v$(node --version))"
exit 1
if [[ "$FLAG_CHECK" == "true" || "$FLAG_UNINSTALL" == "true" ]]; then
# Neither lane installs anything, so neither one may install Node.
require_cmd node
require_cmd npm
NODE_MAJOR="$(node_major_of node)"
if [[ "$NODE_MAJOR" -lt "$NODE_REQUIRED" ]]; then
fail "Node.js >= ${NODE_REQUIRED} required (found $(node --version))"
exit 1
fi
else
ensure_node "$NODE_REQUIRED"
# npm ships inside the Node tarball, so this only fails on a system Node that
# was packaged without it — which is worth saying out loud rather than dying later.
require_cmd npm
NODE_MAJOR="$(node_major_of node)"
fi
echo ""
@@ -1083,7 +1241,13 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
local base dir backup_path backup_val
base="$(basename "$dest")"
dir="$(dirname "$dest")"
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
# Recording null here would tell a later uninstall that no backup exists, and
# it would then delete the destination instead of restoring it. An unanswerable
# lookup must stop the manifest, not guess at it.
if ! backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"; then
fail "Could not determine the backup state of ${dest}; refusing to write a manifest."
return 1
fi
if [[ -n "$backup_path" ]]; then
backup_val="\"$backup_path\""
else
@@ -1143,11 +1307,6 @@ 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 "$@"