Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15561263cc | ||
|
|
44b244f5c0 | ||
|
|
f2661d2c6e | ||
|
|
95d48b02cb | ||
|
|
d6fa67982e | ||
|
|
1237216e63 | ||
|
|
49a8ff73fd | ||
|
|
32be7e547a | ||
|
|
9152bb2b14 | ||
|
|
9c7fb4eda6 | ||
|
|
063de8cd85 | ||
|
|
11ffe65c97 | ||
|
|
dcaf01c789 | ||
|
|
7ddd2f5e1d | ||
|
|
16920c4a6f | ||
|
|
6b3ebce343 | ||
|
|
7fa0f65a60 | ||
|
|
f4faa3f819 | ||
|
|
0692d999f6 | ||
|
|
fa35c6abed | ||
|
|
53d4ea6ec6 | ||
|
|
39987a5b61 | ||
|
|
00bdf8b28c | ||
|
|
631567d5f7 | ||
|
|
9f741874bd | ||
|
|
430b4d5f5d | ||
|
|
404db8cd70 | ||
|
|
c0262e8856 | ||
|
|
306985990c | ||
|
|
2082ac061b | ||
|
|
18ee6eb33d | ||
|
|
76f1f1c8d3 | ||
|
|
0da1deb83f | ||
|
|
4aa67e8dff | ||
|
|
7425edb80f | ||
|
|
571a3d54b5 | ||
|
|
bcd174f89e | ||
|
|
94fc3e55f5 | ||
|
|
46d29d82e9 | ||
|
|
d210c2d7ea | ||
|
|
48531755eb | ||
|
|
9a1cc63383 | ||
|
|
0830e2e3ae | ||
|
|
205cc0d7a1 | ||
|
|
698655d40a | ||
|
|
dcad7de033 | ||
|
|
cd4409abc3 |
@@ -1,71 +0,0 @@
|
|||||||
# REPORT A1207
|
|
||||||
|
|
||||||
Date: 2026-08-13
|
|
||||||
Branch: `fix/869-lease-probe-timeout`
|
|
||||||
Starting head: `2373a5ad345fb316ad2460f6390baab1f45ba08f`
|
|
||||||
Base: `216cd72226cd9ee17eea461cfe7cd0e010a22f02`
|
|
||||||
|
|
||||||
## What changed
|
|
||||||
|
|
||||||
- Added Python behavior tests using isolated temporary directories and marker-writing fake `mosaic` executables. They prove that the supplied `PATH` wins over ambient `os.environ["PATH"]`, and that absent or empty supplied `PATH` values do not search ambient paths, platform defaults, or the current directory.
|
|
||||||
- Bound Python override behavior with executable fakes: a valid `MOSAIC_LEASE_VERSION_PROBE_COMMAND` wins over supplied and ambient `PATH`; an invalid override returns `None` without PATH fallback.
|
|
||||||
- Added a Python runner binding test that captures kwargs and requires `timeout=10.0`. Existing timeout, transport-error, and nonzero-exit checks remain fail-closed with `None`.
|
|
||||||
- Added the optional TypeScript dependency-injection seam `CapabilityProbeExecFile`, defaulting to the existing real `execFileSync` implementation. Production callers have no behavior change.
|
|
||||||
- Added TypeScript tests that capture child-process options and require exactly `timeout: 10_000`. Injected timeout, spawn-error, nonzero-exit, unparseable JSON, and malformed-object cases all return `null`.
|
|
||||||
- Removed the ambient no-dependency TypeScript smoke case that could execute a built checkout's real CLI. Default resolver and supervisor behavior retain their isolated tests, while capability transport tests now use an isolated artifact or the injected transport.
|
|
||||||
|
|
||||||
No Python production code changed relative to `2373a5ad`. The only production delta is the optional TypeScript child-process injection seam.
|
|
||||||
|
|
||||||
## Hermeticity incident and correction
|
|
||||||
|
|
||||||
An initial ambient-lookup mutation run exposed that the pre-existing Python "not resolvable" test left ambient process PATH uncontrolled. On this host, that mutation resolved and executed the host `mosaic` capability probe. A post-build intermediate TypeScript run also let the pre-existing no-dependency smoke case execute the checkout's built `dist/cli.js` capability probe. No `claude` process was run. I then isolated the Python test's ambient PATH, removed the TypeScript ambient smoke case, repeated the PATH mutation using only marker-writing temporary fakes, and repeated the final suites without either real probe path.
|
|
||||||
|
|
||||||
## Mutation evidence
|
|
||||||
|
|
||||||
Each mutation was applied independently, its focused suite was run, and the production source was restored before the final run.
|
|
||||||
|
|
||||||
| Mutation | Result | Reddened test name(s) |
|
|
||||||
| ------------------------------------------------------------------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `shutil.which("mosaic", path=environ.get("PATH", ""))` to ambient `shutil.which("mosaic")` | RED, three failures | `ProbeActivationCapabilityTest.test_supplied_path_wins_over_ambient_process_path`; `ProbeActivationCapabilityTest.test_absent_or_empty_supplied_path_never_falls_back_or_executes` for both absent and empty PATH subtests |
|
|
||||||
| Python `PROBE_TIMEOUT_SECONDS: 10.0` to `2.0` | RED, one failure | `ProbeActivationCapabilityTest.test_probe_passes_ten_second_timeout_to_runner` |
|
|
||||||
| TypeScript `LEASE_CAPABILITY_PROBE_TIMEOUT_MS: 10_000` to `2_000` | RED, one failure | `defaultCapabilityProbe > passes the exact ten-second timeout to the injected child-process transport` |
|
|
||||||
|
|
||||||
## Final test run
|
|
||||||
|
|
||||||
Dependencies were installed first with `pnpm install --frozen-lockfile`. Workspace dependencies were then built with `pnpm --filter '@mosaicstack/mosaic...' run build` so package type declarations were available.
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ cd packages/mosaic && python3 src/mutator-gate/version_coupling_unittest.py
|
|
||||||
...................
|
|
||||||
----------------------------------------------------------------------
|
|
||||||
Ran 19 tests in 0.007s
|
|
||||||
|
|
||||||
OK
|
|
||||||
|
|
||||||
$ pnpm exec vitest run src/commands/lease-activation-probe.spec.ts
|
|
||||||
✓ src/commands/lease-activation-probe.spec.ts (20 tests) 80ms
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 20 passed (20)
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ pnpm exec prettier --check packages/mosaic/src/commands/lease-activation-probe.ts packages/mosaic/src/commands/lease-activation-probe.spec.ts
|
|
||||||
Checking formatting...
|
|
||||||
All matched files use Prettier code style!
|
|
||||||
|
|
||||||
$ pnpm --filter @mosaicstack/mosaic lint
|
|
||||||
> eslint src
|
|
||||||
|
|
||||||
$ pnpm --filter @mosaicstack/mosaic typecheck
|
|
||||||
> tsc --noEmit
|
|
||||||
|
|
||||||
$ python3 -m py_compile packages/mosaic/src/mutator-gate/version_coupling_unittest.py packages/mosaic/framework/tools/lease-broker/activation_version_gate.py
|
|
||||||
|
|
||||||
$ git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
All commands above exited zero.
|
|
||||||
|
|
||||||
## Ambiguities skipped
|
|
||||||
|
|
||||||
None.
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
# Quality-Rails Probe Inventory — RI-3-001
|
|
||||||
|
|
||||||
- **Task:** RI-3-001 (SDLC-D-037 first half; PRD § Release Integrity Workstream, RI-N4)
|
|
||||||
- **Date:** 2026-08-18
|
|
||||||
- **Base:** `origin/next` @ `8199261c` (branch `docs/ri-050-qr-probe-inventory`)
|
|
||||||
- **Follow-up:** RI-3-002 consumes the dispositions here when building the single TS evaluator.
|
|
||||||
|
|
||||||
## 0. Scope and method
|
|
||||||
|
|
||||||
Every mechanism in this repository that verifies a quality, integrity, safety, or release
|
|
||||||
property — TypeScript checks, shell probes, pipeline steps, git hooks, and installer-side
|
|
||||||
assertions — gets one row. Each row's "what it actually verifies" was written from the
|
|
||||||
probe's **code**, not its name or docs. Framework tool unit/regression suites (git wrappers,
|
|
||||||
wake, tmux, orchestrator, …) are treated as one enforcement surface (`test:framework-shell`)
|
|
||||||
because they test tool behavior rather than repo quality; their wiring integrity is itself
|
|
||||||
guarded by `check-test-enumeration.sh`, and the quality-relevant members are rowed
|
|
||||||
individually.
|
|
||||||
|
|
||||||
**Kinds:** `ts` (TypeScript/Node check), `shell` (bash/python probe), `pipeline-step`
|
|
||||||
(exists only inside a Woodpecker pipeline).
|
|
||||||
|
|
||||||
**Enforcement points:** `local` (operator-invoked), `pre-commit`, `pre-push`,
|
|
||||||
`CI ci.yml#<step>`, `publish.yml#<step>` (CI on push to main/next), `turbo <task>`,
|
|
||||||
`agent-runtime` (framework hooks on an agent host), `installer` (host install path),
|
|
||||||
`unwired`.
|
|
||||||
|
|
||||||
**Dispositions** (recommendations for RI-3-002): `preserve` (keep as-is; already the
|
|
||||||
canonical or a correct guard-of-the-guard), `strengthen` (keep, but a concrete gap must
|
|
||||||
close — usually absorption into the TS evaluator), `strengthen (review)` (viable retirement
|
|
||||||
candidate once the evaluator absorbs it; do not retire yet). Note: RI-N4 requires that
|
|
||||||
effective shell probes be **absorbed before** their independent paths retire — no row here
|
|
||||||
is marked `retire` because no absorption exists yet.
|
|
||||||
|
|
||||||
## 1. Inventory
|
|
||||||
|
|
||||||
### 1.1 Repo-level gate tasks (pnpm / turbo)
|
|
||||||
|
|
||||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
|
||||||
| ------------------------------------- | ------------------------------------------------------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `pnpm preflight` (checkout preflight) | `scripts/preflight.mjs` | ts | Six gate binaries (eslint, husky, prettier, tsc, turbo, vitest) exist and are executable in `node_modules/.bin` (exit 42 if not); no stale `.mosaic-test-work/web-build.lock` (exit 43); `apps/web/.next` is a real directory (not a symlink), every entry owned by the current uid, and its `.mosaic-source-hash` fingerprint + `.mosaic-symlink-manifest` hash match the certified build written by `scripts/build-web.mjs` | `pre-push`; inside `pnpm typecheck` (→ `CI ci.yml#typecheck`, verify-release `typecheck` stage) | QC-1 Checkout integrity | preserve | Blocks a poisoned/stale generated `.next` from faking a green typecheck (the five-month-stale-`.next` class); trust chain is self-contained per-checkout. |
|
|
||||||
| `pnpm typecheck` | root `package.json` → `turbo run typecheck` | ts | Per-package `tsc --noEmit` (all 20 packages); turbo `typecheck` depends on `^build`, so package builds must succeed first; prefixed by checkout preflight | `CI ci.yml#typecheck`; `pre-push`; verify-release `typecheck` stage; `turbo typecheck` | QC-2 Workspace typecheck | preserve | The single workspace-wide type gate; CI and hooks invoke the same task, no divergent checklist. |
|
|
||||||
| `pnpm lint` | root `package.json` → `turbo run lint` | ts | Per-package `eslint src` under root `eslint.config.mjs` (ignores `dist`, `.next`, `framework/**`, etc.) | `CI ci.yml#lint`; `pre-push`; verify-release `lint` stage; `turbo lint` | QC-3 Workspace lint | preserve | Same-task invocation from every surface; no second lint definition. |
|
|
||||||
| `pnpm format:check` | root `package.json` → `prettier --check` | ts | Prettier parse/format equality over `**/*.{ts,tsx,js,jsx,json,md}` minus `.prettierignore` (generated trees, `docs/scratchpads/`, venvs, …) | `CI ci.yml#format`; `pre-push`; verify-release `format` stage | QC-4 Format check | preserve | Single formatter, single ignore list, enforced identically everywhere. |
|
|
||||||
| `pnpm test` | root `package.json` `test` = `test:checkout` && `turbo run test` && `test:installer` | ts | (a) `node --test scripts/*.test.mjs` — checkout-tool units; (b) per-package `vitest run` (mosaic appends the 47-command `test:framework-shell` chain); (c) `tools/install-next-lane.test.sh`; turbo `test` declares DB env vars and depends on `^build` | `CI ci.yml#test` (with `DATABASE_URL` + `db:migrate` first); verify-release `test` stage; `turbo test` | QC-5 Test suite execution | preserve | One composed test command; the chain property (any link red ⇒ step red) is the gate. |
|
|
||||||
| `pnpm build` | root `package.json` → `turbo run build` | ts | Per-package build (`tsc`/Next) with `^build` dependency and `dist/**` outputs | `publish.yml#build`; verify-release `build` stage; `turbo build` | QC-6 Workspace build | preserve | Publish artifacts derive from the same build task CI verifies. |
|
|
||||||
|
|
||||||
### 1.2 Framework quality shell probes (`packages/mosaic/framework/tools/quality/`)
|
|
||||||
|
|
||||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
|
||||||
| ------------------------------------------- | ----------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Sanitization gate | `scripts/verify-sanitized.sh` | shell | Built-in self-test first (planted identity/structural/YAML+service fixtures; exit 2 if the regexes or extension coverage break), then: (1) identity denylist grep (`jarvis\|jason\|woltje\|brain.woltje.com\|/home/jwoltje\|\bPDA\b`) over all shipped text files **including** `examples/`; (2) structural grep for private `$HOME/src` defaults in shipped scripts **excluding** `examples/`. Any hit ⇒ exit 1 | `CI ci.yml#sanitization`; verify-release `sanitization` stage | QC-7 Framework sanitization | preserve | Labeled one-time regression guard with a self-test that prevents silent no-op; correctly scoped (identity vs structural) and documented as not a general PII detector. |
|
|
||||||
| Resident-context budget | `scripts/check-resident-budget.sh` (+ `--self-test`) | shell | Self-test of the comparator, then `wc -l` vs per-file ceilings (CONSTITUTION 120, AGENTS 120, each RUNTIME.md 90); missing file ⇒ fail; over ceiling ⇒ exit 1 | `CI ci.yml#sanitization` (both modes); verify-release `sanitization` stage | QC-8 Resident-context budget | preserve | Caps the container (lines), never the wording — the deliberate anti-drift design (DESIGN §7); CI-enforceable half only, by design. |
|
|
||||||
| Test-membership enumeration guard (#1017) | `scripts/check-test-enumeration.sh` + `test-enumeration-exclusions.txt` | shell | Parses surface S1 (`packages/mosaic` `test:framework-shell` via JSON+shlex) and S2 (every `framework/tools/\*.sh | .py`token in`ci.yml`, comment lines stripped); population = `_test_.sh`under`framework/tools`; FAILS on: suite-shaped file on disk neither enumerated nor signed-excluded; surface naming a path missing on disk (both directions); exclusion without reason / stale / outside population / contradicting enumeration. Proves **naming, not reachability** (stated in-file) | `CI ci.yml#sanitization` (direct line); link [0] of `test:framework-shell` (thus `CI ci.yml#test`); verify-release `sanitization` stage | QC-9 Test-membership enumeration | preserve | Makes silent under-run impossible; invoked from both surfaces it audits so severing the chain cannot silence it. |
|
|
||||||
| Enumeration-guard needles | `scripts/test-check-test-enumeration.sh` | shell | Needle/control fixtures driven through `--root`: every promised failure mode must trip the guard **on its own words**, plus controls that must pass (null-case defense); covers commented-out ci.yml lines (F1) and line-range parsing (n2b) | `test:framework-shell` → `CI ci.yml#test`; verify-release `test` stage | QC-9 Test-membership enumeration | preserve | Guard-of-the-guard with both polarities; same canonical check by design. |
|
|
||||||
| Upgrade manifest guard (#791 HARD GATE) | `scripts/test-upgrade-manifest-guard.sh` | shell | Keep-mode `install.sh` upgrade against seeded throwaway `MOSAIC_HOME`: every operator sentinel — including an **unanticipated** one — survives byte-identical with unchanged mtime; framework files still update; retired framework files pruned; matrix run with rsync present AND absent (keep path must be rsync-independent); fail-closed matrix (empty/operator-only/malformed/missing manifest aborts loudly, operator files untouched); operator secret never appears in installer output | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | The operator-data hard gate for the `mosaic update` path; negative controls are load-bearing and documented. |
|
|
||||||
| Upgrade rollback gate (#791 B1) | `scripts/test-upgrade-rollback.sh` | shell | Mid-sync failure (PATH-shadowing `cp` shim) must trigger snapshot restore: restore message fires, corrupted file restored, target byte-identical to pre-upgrade; control installer with `set -E` stripped must NOT roll back (proves errtrace is load-bearing); plus signal/exit-guard controls | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Proves the rollback trap actually fires; the `-E`-stripped control keeps Part A honest. |
|
|
||||||
| Durable-snapshot gate (#791 PR2) | `scripts/test-upgrade-durable-snapshot.sh` | shell | Pre-update snapshot taken before any mutation (0700/0600 perms, secret never logged, retention-pruned); post-sync verify net restores operator files a manifest bug lets the sync touch; CWE-59 symlink-leaf guard proven with a portable cp shim in both polarities (write-through-link must not happen); v1→v2 migration semantics (intended `bin/` removal not healed) | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Covers tampering and leak vectors the manifest guard cannot see; the shim rationale (busybox vs GNU cp) is documented in-file. |
|
|
||||||
| Install migration matrix (v2→v3) | `scripts/test-install-migration.sh` | shell | Fixture matrix running the real installer with `MOSAIC_SYNC_ONLY=1`: fresh install seeds + stamps version 3; legacy user-edited AGENTS overwritten with `.pre-constitution.bak` preserved (and idempotent); tuned STANDARDS overwritten; operator files (SOUL, credentials) preserved. Mirrors the TS suite `packages/mosaic/src/config/file-adapter.test.ts` — both installers must behave identically | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Pins the shell/TS installer parity contract; removal would orphan that parity requirement. |
|
|
||||||
| Enforcement verification probe (bash) | `scripts/verify.sh` | shell | Attempts **real commits** in the target repo: planted type error must produce a commit blocked with `error`; planted `any` must trip `no-explicit-any`; planted lint error must trip `prettier`; gitleaks binary must exist (3a) and detect a planted AWS key via `gitleaks git --pre-commit --staged --redact` (3b). Verdicts are output-grep matches on hook stderr | `local` via installed `mosaic-quality-verify` on scaffolded target projects; **not run in this repo's CI** | QC-20 Downstream enforcement verification | strengthen (review) | Mechanism is genuinely behavioral (stronger than file presence) but verdict logic is grep-on-output and it is unwired here; absorb as the evaluator's enforcement-probe check (the RI-N4 evaluator invokes it or reimplements it) before retiring the shell path. |
|
|
||||||
| Enforcement verification probe (PowerShell) | `scripts/verify.ps1` | shell | Windows port of `verify.sh`: same planted-commit tests with `$output -match` matching; no gitleaks self-test parity beyond the same checks | `local` (Windows operator); no Windows CI runner exists | QC-20 Downstream enforcement verification | strengthen (review) | A hand-maintained twin of `verify.sh` with no CI coverage — exactly the drift shape the single evaluator removes; retire after the TS evaluator owns the probe. |
|
|
||||||
| Quality template installer (bash) | `scripts/install.sh` | shell | Copies template files (`.husky/pre-commit` incl. mandatory gitleaks, `.lintstagedrc.js`, `.eslintrc.js`, `tsconfig.json`, `.woodpecker.yml`, `.gitleaks.toml`) into a target project; **warns** (does not verify) about `package.json` snippet merge; no post-condition check | `local` / via `mosaic-quality-apply` | QC-21 Downstream rails scaffolding | strengthen (review) | Duplicates the TS `quality-rails init` scaffolder for a different template set; converging on one scaffolder (with post-scaffold verification) is prerequisite to retiring this path. |
|
|
||||||
| Quality template installer (PowerShell) | `scripts/install.ps1` | shell | Windows twin of the template copy above | `local` (Windows operator) | QC-21 Downstream rails scaffolding | strengthen (review) | Same twin-drift risk as `verify.ps1`; no runner exercises it. |
|
|
||||||
| `mosaic-quality-verify` adapter | `framework/tools/_scripts/mosaic-quality-verify` | shell | Thin adapter: validates target dir exists, asserts `verify.sh` present+executable, `cd` target, exec it. No verdict logic of its own | `local` (installed framework bin) | QC-20 Downstream enforcement verification | preserve | Already the thin-adapter shape RI-N4 prescribes for shell surfaces. |
|
|
||||||
| `mosaic-quality-apply` adapter | `framework/tools/_scripts/mosaic-quality-apply` | shell | Thin adapter: arg validation then exec of quality `install.sh --template … --target …` | `local` (installed framework bin) | QC-21 Downstream rails scaffolding | preserve | Thin adapter, no separate verdict; disposition follows its target script's convergence. |
|
|
||||||
| Roster schema regression | `scripts/test-roster-schema.py` | shell | jsonschema `Draft202012Validator` over `fleet/roster.schema.json` with valid/invalid connector-kind fixtures (tmux/discord/matrix conditional fields) | **unwired** — not on S1 or S2, not signed-excluded; also outside the enumeration guard's `*.sh` population, so the guard cannot see it | QC-5 Test suite execution | strengthen (review) | A real regression suite that currently runs nowhere; wire it into a CI surface or sign an exclusion — leaving it invisible re-arms the exact gap #1017 closed. |
|
|
||||||
| Framework shell chain (S1) | `packages/mosaic/package.json` `test:framework-shell` | shell | 47-command `&&` chain: enumeration guard + needles, 14 lease-broker/mutator-gate python unitests, `check-runtime-launches.py`, and ~30 framework-tool shell suites (git wrappers, wake, woodpecker, tmux, glpi, orchestrator, `_scripts`). Quality-relevant members rowed separately below | `turbo test` → `CI ci.yml#test`; verify-release `test` stage | QC-5 Test suite execution | preserve | The chain is the execution surface the enumeration guard audits; known residuals: a failing link stops later suites (measured in #1270 — suites after position 44 had not run), and the guard proves naming, not reachability. |
|
|
||||||
|
|
||||||
### 1.3 Framework runtime hooks and their harnesses (agent-host enforcement)
|
|
||||||
|
|
||||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
|
||||||
| ------------------------------------- | ----------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | --------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| QA edit hook seam | `framework/tools/qa/qa-hook-stdin.sh` (+ `qa-hook-handler.sh`) | shell | PostToolUse stdin hook: extracts edited file from the tool JSON (jq or grep fallback), skips non-JS/TS, then the deps-preflight gate — exits 1 with the legible sentinel `deps not installed — run pnpm install` when `node_modules/.bin` is missing/empty (the #856 false-red class); the downstream handler only files QA remediation **report templates** (no verification logic) | `agent-runtime` (framework `runtime/claude/settings.json` PostToolUse); never CI | QC-16 Agent-runtime edit-time checks | strengthen (review) | The sentinel gate is real enforcement; the handler's report-filing adds no verdict and its name promises more than the code does — evaluator absorption should keep the sentinel, drop the report theater. |
|
|
||||||
| Typecheck-on-edit hook | `framework/tools/qa/typecheck-hook.sh` | shell | PostToolUse: for edited `.ts/.tsx`, finds nearest `tsconfig.json` and runs `tsc --noEmit`, surfacing errors nonzero to the agent immediately | `agent-runtime` (framework `runtime/claude/settings.json` PostToolUse) | QC-16 Agent-runtime edit-time checks | strengthen (review) | Edit-time duplicate of QC-2 with independent invocation logic; keep behavior, converge invocation through the evaluator adapter. |
|
|
||||||
| Deps-preflight harness | `framework/tools/qa/test-deps-preflight.sh` | shell | Five assertions against the seam incl. a documented RED control (raw `not found`), sentinel behavior for missing and empty `.bin`, and no-false-positive once populated | `test:framework-shell` → `CI ci.yml#test` | QC-16 Agent-runtime edit-time checks | preserve | Guard-of-the-check with a red control; keeps the sentinel from regressing. |
|
|
||||||
| Prompt-helper RCE regression | `framework/tools/_scripts/test-mosaic-init-rce.sh` | shell | Sources the prompt helpers and proves a literal `$(touch /tmp/pwned)` answer round-trips verbatim and never executes (no `/tmp/pwned` created) | `test:framework-shell` → `CI ci.yml#test` | QC-5 Test suite execution | preserve | Cheap, load-bearing security regression on the installer's input path. |
|
|
||||||
| Install-ordering harness (#869 C2) | `framework/tools/_scripts/test-install-ordering-guard.sh` | shell | Drives `mosaic-link-runtime-assets` with a fake `mosaic` on PATH: probe ok ⇒ settings copied + exit 0; probe fail ⇒ exit 1 with degraded outcome but all other runtime files still copied; `--allow-inactive-enforcement` forwarded; no-mosaic-on-PATH ⇒ python3 fallback strips enforcement hooks and exits 1; fallback + flag ⇒ wires as-is, exit 0 | `test:framework-shell` → `CI ci.yml#test` | QC-17 Lease-enforcement wiring safety | preserve | Exercises the shell wiring seam independently of the TS guard's own spec suite (complementary coverage, by design). |
|
|
||||||
| Fleet-transport harness (#1240) | `framework/tools/_scripts/test-fleet-transport-check.sh` | shell | Extracts the shipped `check_fleet_transport`/`fleet_declared_transport` functions **from the shipped scripts** (fails loud if extraction yields nothing) and drives both implementations (mosaic-doctor + `tools/install.sh`) from one case table | `test:framework-shell` → `CI ci.yml#test` | QC-18 Operator-host drift audit | preserve | The anti-drift harness for the one rule shipped twice; extraction-from-source keeps it from testing a stale copy. |
|
|
||||||
| Terminal-green contract (RM-61/#1000) | `framework/tools/woodpecker/test-terminal-green-contract.sh` + `verify-terminal-green.py` | shell | Red-first fixtures: pipeline JSON variants (service failure, step failure, cancelled, etc.) must produce the correct terminal-green verdict; controls must pass | `test:framework-shell` → `CI ci.yml#test` | QC-5 Test suite execution | preserve | Keeps the CI-wait wrapper's green-detection honest; a false green here would poison every merge gate that trusts `pr-ci-wait.sh`. |
|
|
||||||
| Lease-gate launch invariant | `framework/tools/lease-broker/check-runtime-launches.py` | shell | Scans production roots (`packages/`, `apps/`, `plugins/`, `tools/`) across sh/py/ts/yaml suffixes for Claude/Pi process launches **outside** the lease gate; allowlist-based; fails CI on violation | `test:framework-shell` → `CI ci.yml#test` | QC-15 Lease-gate architecture invariant | preserve | The only architectural "no ungated launches" rail; grep+allowlist is the right cost/benefit for this invariant. |
|
|
||||||
|
|
||||||
### 1.4 TypeScript quality logic (`@mosaicstack/quality-rails` + mosaic CLI)
|
|
||||||
|
|
||||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
|
||||||
| ---------------------------------------- | ---------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `quality-rails check` | `packages/quality-rails/src/cli.ts` (`mosaic quality-rails check --project`) | ts | **Expected-file presence only**: loops `expectedFilesForKind` (node: `.eslintrc`, `biome.json`, `.githooks/pre-commit`, `PR-CHECKLIST.md`; python: `pyproject.toml`+hooks+checklist; rust: `rustfmt.toml`+…) and exits 1 listing missing paths. Does not execute any linter, formatter, hook, or scanner | `local` (operator CLI); **no CI wiring in this repo** | QC-19 Downstream rails presence check | strengthen | This is the RI-N4 evaluator seed. Today presence ≠ parity (explicitly called out by RI-N4): it must grow typed verdicts (`passed/failed/blocked/error/not-applicable`), check versioning/subject/reason, digested definitions, and absorb the effective shell probes (QC-20 first). |
|
|
||||||
| `quality-rails doctor` | `packages/quality-rails/src/cli.ts` | ts | Same presence data as `check`, printed with ok/missing lines; **cannot fail** (no nonzero exit on missing files) | `local` (operator CLI) | QC-19 Downstream rails presence check | strengthen | A doctor that cannot fail is advisory; fold into `check` (or return typed states) when the evaluator lands. |
|
|
||||||
| `quality-rails init` | `packages/quality-rails/src/cli.ts` + `scaffolder.ts`/`templates.ts` | ts | Scaffolds rails files per detected kind/profile (linters/formatters lists are advisory strings; hooks flag always true); writes files, prints follow-ups — no post-condition verification | `local` (operator CLI) | QC-21 Downstream rails scaffolding | strengthen (review) | Second scaffolding path alongside quality `install.sh` (§1.2); converge on one with post-scaffold verification before retiring either. |
|
|
||||||
| Lease activation probe (#869 C1, hidden) | `packages/mosaic/src/commands/lease-activation-probe.ts` | ts | Real capability probe, not file presence: resolves the installed mosaic CLI and requires it to advertise the exact `{name, version}` activation contract; all deps injectable; registered as hidden CLI command and consumed by C2/C5 | `local` (hidden CLI + consumed by C2/C5); spec-tested via `lease-activation-probe.spec.ts` in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | The versioned-contract probe is precisely the fail-closed capability check RI-N2 generalizes; already typed and injectable. |
|
|
||||||
| Install-ordering guard (#869 C2, hidden) | `packages/mosaic/src/commands/install-ordering-guard.ts` | ts | Decides whether enforcement hook entries are written into the `~/.claude/settings.json` the framework reseed ships: not activatable ⇒ strip hooks + nonzero loud outcome (default); explicit per-invocation `--allow-inactive-enforcement` opt-out wires-with-warning. Never touches the runtime gate's own fail-closed behavior | `installer` (framework reseed via `mosaic-link-runtime-assets`); spec + shell harness coverage in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | Correct default-deny with an explicit, non-env opt-out; test-locked from both the TS and shell sides. |
|
|
||||||
| Lease doctor check (#869 C5) | `packages/mosaic/src/commands/lease-doctor-check.ts` | ts | Combines hook-wiring detection in `~/.claude/settings.json` with C1 activatable and C3 broker-supervisor health: wired ∧ (¬activatable ∨ ¬healthy) ⇒ loud `[ERROR]` that forces `mosaic doctor` exit 1 regardless of the bash audit's own exit | `local` (inside `mosaic doctor`); spec coverage in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | Closes the "bricked host looks green" hole; cannot be masked by the bash script — that composition is the point. |
|
|
||||||
| `mosaic doctor` (framework drift audit) | `packages/mosaic/src/commands/launch.ts` (`doctor`) + `framework/tools/_scripts/mosaic-doctor` | shell+ts | Bash audit of the installed framework home: ~40 expected files/dirs present; runtime files are copies (not symlinks) matching source (`cmp`) or composed runtime-contract markers; hard-gates block present in AGENTS.md; sequential-thinking MCP configured; fleet transport binary present per roster (warn); legacy symlink trees gone; skills synced — **warn-based, exit 1 only with `--fail-on-warn`**, plus C5's forced error | `local` (operator audit) | QC-18 Operator-host drift audit | preserve | Host-state audit CI cannot see (user files by design, DESIGN §7); advisory exit is the documented contract — do not silently change it. |
|
|
||||||
| `mosaic gateway doctor` | `packages/mosaic/src/commands/gateway-doctor.ts` | ts | Probes per-service health (PostgreSQL, Valkey, pgvector) via `@mosaicstack/storage`, reports tier and JSON; exit 1 only when at least one **required** service fails (yellow stays 0) | `local` (operator) | QC-18 Operator-host drift audit | preserve | Service health with correct red/yellow exit semantics; JSON mode exists for scripting. |
|
|
||||||
| `mosaic gateway verify` | `packages/mosaic/src/commands/gateway/verify.ts` | ts | Post-install liveness: daemon meta via HTTP with retries, admin token on file, bootstrap endpoint reachable; aggregated pass/fail | `local`; consumed by `tools/e2e-install-test.sh` | QC-18 Operator-host drift audit | preserve | The first-run proof the installer E2E relies on; retry-aware so startup races don't false-red. |
|
|
||||||
| `mosaic fleet doctor` | `packages/mosaic/src/commands/fleet-reconciler-command.ts` | ts | Classifies local roster-owned drift (no mutation) from the parsed v2 roster | `local` (operator) | QC-18 Operator-host drift audit | preserve | Dry-run classification is the correct non-mutating audit shape. |
|
|
||||||
|
|
||||||
### 1.5 Git hooks (developer machine)
|
|
||||||
|
|
||||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
|
||||||
| ------------------------- | --------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Pre-commit staged hygiene | `.husky/pre-commit` → `npx lint-staged` (`.lintstagedrc`) | shell | On staged files only: `prettier --write` + `eslint --fix` for ts/tsx/js/jsx; `prettier --write` for json/md/yaml/yml. **Mutating** (fixes and re-stages); commit blocks only if a fixer itself fails | `pre-commit` (every local commit; hooks activated by `install-hooks.mjs` via `core.hooksPath .husky/_`) | QC-13 Staged-change hygiene | preserve | Correct scoped fast gate; note it auto-fixes rather than rejects (deliberate). Gap: no secret scan here — see §3. |
|
|
||||||
| Pre-push gate | `.husky/pre-push` | shell | `pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check` (no test run — documented in AGENTS.md) | `pre-push` | QC-14 Pre-push gate | preserve | Composes QC-1..4 exactly as specified in AGENTS.md; tests intentionally left to CI. |
|
|
||||||
| Hook installer | `scripts/install-hooks.mjs` (`pnpm prepare`) | ts | Stages husky hooks into a scratch repo first, asserts husky produced its `h` shim, quarantines incomplete previous sets, verifies idempotence via full directory snapshot comparison, then sets `core.hooksPath`; skips cleanly with `HUSKY=0` or no git | `installer` (runs on `pnpm install`) | QC-13 Staged-change hygiene | preserve | Self-verifying wiring for the hook gates — a corrupted half-install cannot silently disable them. |
|
|
||||||
|
|
||||||
### 1.6 CI pipeline steps (`.woodpecker/`)
|
|
||||||
|
|
||||||
Step-to-probe mapping for container steps: `ci.yml#sanitization` = QC-7+QC-8+QC-9 (rows §1.2, plus `apk add bash` env prep); `ci.yml#upgrade-guard` = QC-10 (rows §1.2, plus `apk add rsync`); `ci.yml#typecheck`/`#lint`/`#format`/`#test` = QC-2/3/4/5 (rows §1.1). Rows below are mechanisms that exist only in a pipeline.
|
|
||||||
|
|
||||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
|
||||||
| -------------------------------------- | -------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Frozen install | `ci.yml#install` | pipeline-step | `pnpm install --frozen-lockfile --prefer-offline` against the baked ci-base store — lockfile supply integrity; a drifted lockfile fails the build before any gate runs | `CI ci.yml#install` | QC-1 Checkout integrity | preserve | Lockfile-pinned dep resolution is the supply-chain floor under every later gate. |
|
|
||||||
| Test-step readiness prelude | `ci.yml#test` prologue | pipeline-step | Installs pinned `@earendil-works/[email protected]` (Invariant R suite requires the real binary) + openssl; waits up to 60×1s on `pg_isready` for the `ci-postgres` service and fails fast if it never comes up; runs `db:migrate` before tests | `CI ci.yml#test` | QC-5 Test suite execution | preserve | Fail-fast environment preconditions — a missing service produces a legible failure, not a wall of red tests. |
|
|
||||||
| Publish verify step (pending RI-1-001) | `publish.yml#verify` (branch `feat/ri-050-publish-gate` @ `46784c8d`, not yet on next) | pipeline-step | (a) Commit identity: fails closed if `CI_COMMIT_SHA` empty, `git rev-parse HEAD` empty, or the two differ; (b) runs the canonical `pnpm verify:release`. **Every publish effect depends on this step; it carries no path filter** | `publish.yml#verify` | QC-11 Terminal release verification | preserve | The RI-N1 exact-commit binding; until it merges, publish steps on next depend on `build` only (see §3 gap 1). |
|
|
||||||
| Publish error classification | `publish.yml#publish-npm` | pipeline-step | Publishes `@mosaicstack/*` (minus web) and classifies outcome: success, or the **only tolerated failure** = already-published (EPUBLISHCONFLICT / "cannot publish over" / "previously published"); explicit fatal on npm `E404/E401/ENEEDAUTH/ECONNREFUSED/ETIMEDOUT/ENOTFOUND` and on any unrecognized failure (replacing the old ` | | echo` that hid a registry 404) | `publish.yml#publish-npm` (main/tags, path-filtered on `packages/**`) | QC-12 Publish-effect integrity | preserve | Converts silent publish fall-on-floor into loud failure; allowlist-of-one error tolerance is the right shape. |
|
|
||||||
| Next-lane publish assertions | `publish.yml#publish-next-npm` | pipeline-step | Guards: branch must be `next`, `CI_PIPELINE_NUMBER` required; registry dist-tags JSON must be usable; walks all manifests, strictly parses stable semver, rewrites `X.Y.(Z+1)-next.<N>`; publishes with `--tag next` (never latest); post-publish asserts `npm view @mosaicstack/mosaic@next` resolves to the exact expected version | `publish.yml#publish-next-npm` (push/manual on next) | QC-12 Publish-effect integrity | preserve | Durable prerelease lane with end-to-end resolution proof — the published artifact is verified, not assumed. |
|
|
||||||
| Image destination policy | `publish.yml#build-gateway` / `#build-appservice` / `#build-web` | pipeline-step | Kaniko builds with destination policy: `next` ⇒ sha-tag only (fatal if a tag event sneaks in); `main` ⇒ sha + `latest`; tag events ⇒ sha + `<tag>`; anything else fatal. Path filters only skip **effects**, never the verify step | `publish.yml#build-*` | QC-12 Publish-effect integrity | preserve | Fail-closed tagging matrix; the exclude-list default-safe design keeps stale images impossible. |
|
|
||||||
|
|
||||||
Adjacent pipeline surface (not a probe): `.woodpecker/ci-image.yml` rebuilds the ci-base image on `pnpm-lock.yaml`/`Dockerfile.ci` change with an immutable `lock-<hash>` tag; pipelines consume `:latest`. Recorded for completeness — no code-quality property is checked.
|
|
||||||
|
|
||||||
### 1.7 Root installer tooling (`tools/`)
|
|
||||||
|
|
||||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
|
||||||
| --------------------------- | --------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Next-lane installer test | `tools/install-next-lane.test.sh` (`pnpm test:installer`) | shell | Drives `tools/install.sh --next` with faked `node`/`npm` binaries (no network): Node 20 must be rejected; installs must pin **exact** versions (mutable `@next` forbidden); fast path must not unexpectedly fall back to source; gateway-install failure takes the documented fallback | `turbo`-external tail of `pnpm test` → `CI ci.yml#test` | QC-5 Test suite execution | preserve | Hermetic (shimmed) regression net for the installer lane; runs as part of the standard test command. |
|
|
||||||
| Clean-container install E2E | `tools/e2e-install-test.sh` | shell | Full first-run flow in a node:22-alpine container: `install.sh --yes` → `mosaic wizard` (non-interactive) → `mosaic gateway install` → `mosaic gateway verify` exit check (with EXPECTED-SKIP if the installed CLI predates `gateway verify`); skips gracefully without Docker | `local` (manual; requires Docker); **not wired in CI** | QC-5 Test suite execution | strengthen (review) | The only end-to-end proof of the install→verify path; currently operator-initiated only — wire into a periodic/manual CI lane or sign its exclusion explicitly. |
|
|
||||||
| Host installer advisories | `tools/install.sh` (`--check`; `check_fleet_transport`) | shell | `--check` = version comparison only, no install; `check_fleet_transport` warns (non-blocking, by design — tmux is the fleet's dependency, not mosaic's) when the roster-declared transport binary is absent, naming exactly what it blocks; PATH-persistence warnings | `installer` (operator-run) | QC-18 Operator-host drift audit | preserve | Advisory-by-design warnings; the parallel doctor check is drift-tested by §1.3's harness. |
|
|
||||||
|
|
||||||
### 1.8 Pending workstream additions (branch `feat/ri-050-publish-gate` @ `46784c8d`)
|
|
||||||
|
|
||||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
|
||||||
| ------------------------------- | ---------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- |
|
|
||||||
| Canonical terminal verification | `scripts/verify-release.mjs` (`pnpm verify:release`) | ts | One command replaying the full mandatory set as stages — sanitization, upgrade-guard, typecheck (incl. preflight), lint, format, test, build — mirroring `ci.yml` step-for-step; fail-fast on first failing command; requires `bash`+`rsync` on PATH; `--stage <name>` for wiring smoke-tests only | `publish.yml#verify` (pending); `local` (`pnpm verify:release`) | QC-11 Terminal release verification | preserve | The RI-N1 canonical command — CI and publication share one semantic checklist by construction. |
|
|
||||||
| Verify-parity contract test | `scripts/verify-release.test.mjs` | ts | Parses the real `ci.yml`/`publish.yml`: stage table must match ci.yml step-for-step; every publish-effect step (name `publish*` or image-pushing) must transitively depend on `verify`; commit-identity assertion must be present; `verify` must carry no path filter | `test:checkout` → `CI ci.yml#test` (once merged) | QC-11 Terminal release verification | preserve | Guard-of-the-guard at checkout time — the two surfaces cannot drift apart silently. |
|
|
||||||
|
|
||||||
## 2. Canonical check set
|
|
||||||
|
|
||||||
The deduplicated checks every row above maps onto. IDs are stable for RI-3-002 to consume.
|
|
||||||
|
|
||||||
- **QC-1 Checkout integrity.** Owns: the checkout can run its gates — frozen-lockfile dependency resolution, required gate binaries present, no stale build lock, and the `apps/web/.next` generated-state trust chain (real directory, uid ownership, certified source fingerprint, certified symlink manifest). Implemented by `scripts/preflight.mjs` + frozen install steps.
|
|
||||||
- **QC-2 Workspace typecheck.** Owns workspace-wide TypeScript soundness: per-package `tsc --noEmit` over built dependencies (`turbo typecheck`). The single definition invoked by CI, pre-push, and terminal verification.
|
|
||||||
- **QC-3 Workspace lint.** Owns static-analysis policy: per-package ESLint under the root config. One config, one task, every surface.
|
|
||||||
- **QC-4 Format check.** Owns formatting uniformity: Prettier check with the repo ignore list. (The pre-commit variant additionally fixes; the verdict form is this check.)
|
|
||||||
- **QC-5 Test suite execution.** Owns execution of all test surfaces: checkout script units (`node --test`), per-package Vitest suites (including the framework shell chain and its python unitests), the installer-lane shim test, and — once wired — `test-roster-schema.py` and container E2E. Also owns guards-of-the-gate that live inside the chain (terminal-green contract, RCE regression).
|
|
||||||
- **QC-6 Workspace build.** Owns artifact buildability: `turbo build` producing the artifacts publication consumes.
|
|
||||||
- **QC-7 Framework sanitization.** Owns the open-source guarantee for the shipped framework package: no operator-identity tokens anywhere (examples included), no private `$HOME` defaults in shipped scripts, with a self-test that keeps the regexes honest.
|
|
||||||
- **QC-8 Resident-context budget.** Owns the line-count ceilings on framework files injected into every agent's context (Constitution, dispatcher, RUNTIME.md slices) — the CI-enforceable half of the resident-prompt budget.
|
|
||||||
- **QC-9 Test-membership enumeration.** Owns the property that no test suite can silently fall out of CI: disk population vs parsed enumeration surfaces, both-directions staleness, and signed exclusions with reasons. Includes its needle/control harness.
|
|
||||||
- **QC-10 Upgrade/install safety.** Owns the #791 family: operator-path byte-identity across keep-mode upgrades (manifest guard), mid-failure rollback (errtrace-proven), durable pre-update snapshot + verify net + CWE-59 leaf guard, and the v2→v3 migration matrix with shell/TS parity.
|
|
||||||
- **QC-11 Terminal release verification.** Owns the RI-N1 exact-commit binding: commit-identity assertion plus one canonical command (`pnpm verify:release`) replaying the complete mandatory set, with every publish effect depending on it; plus the checkout-time parity/DAG contract test that keeps pipeline and command in sync.
|
|
||||||
- **QC-12 Publish-effect integrity.** Owns publication correctness: npm publish error classification (only already-published tolerated), next-lane versioning and post-publish resolution proof, and image destination/tag policy.
|
|
||||||
- **QC-13 Staged-change hygiene.** Owns commit-time hygiene on staged files (prettier/eslint fix-and-restage) and the self-verifying hook wiring that guarantees the gates are actually installed.
|
|
||||||
- **QC-14 Pre-push gate.** Owns the local push composition: preflight + typecheck + lint + format:check (tests deliberately deferred to CI).
|
|
||||||
- **QC-15 Lease-gate architecture invariant.** Owns "no ungated runtime launches in production code": the scan + allowlist over `packages/`, `apps/`, `plugins/`, `tools/`.
|
|
||||||
- **QC-16 Agent-runtime edit-time checks.** Owns edit-time feedback on agent hosts: the deps-preflight legibility sentinel and typecheck-on-edit, plus their regression harnesses.
|
|
||||||
- **QC-17 Lease-enforcement wiring safety.** Owns the #869 C1/C2/C5 trio: activation capability probe (versioned contract), enforcement-hook wiring gate (default-deny with explicit opt-out), and the doctor check that surfaces a bricked host — with their shell/TS harnesses.
|
|
||||||
- **QC-18 Operator-host drift audit.** Owns host-state health CI cannot see: `mosaic doctor` drift audit (+ fleet transport, both implementations), `fleet doctor` roster classification, `gateway doctor`/`gateway verify` service health, and installer advisories. Advisory exits are part of the contract.
|
|
||||||
- **QC-19 Downstream rails presence check.** Owns "does a scaffolded project still carry its rails files" — today the TS `quality-rails check/doctor` presence loop; per RI-N4 this is the seed that must become the typed evaluator (presence alone is explicitly not parity).
|
|
||||||
- **QC-20 Downstream enforcement verification.** Owns "do the rails actually block" on scaffolded projects: the behavioral planted-commit probe (type error, `any`, lint, gitleaks secret) currently in `verify.sh`/`verify.ps1` behind the `mosaic-quality-verify` adapter.
|
|
||||||
- **QC-21 Downstream rails scaffolding.** Owns putting rails files into a target project: the shell template installer (+ PowerShell twin) and the TS `quality-rails init` scaffolder — currently two paths that must converge.
|
|
||||||
|
|
||||||
## 3. Coverage gaps
|
|
||||||
|
|
||||||
Enforced nowhere but implied, or named in docs/tooling but not wired:
|
|
||||||
|
|
||||||
1. **Publication not yet bound to verification on `next`.** At this base (`8199261c`), `publish.yml` publish steps depend on `build` only; the `verify` step and `scripts/verify-release.mjs` exist on `feat/ri-050-publish-gate` (`46784c8d`) but are not merged. Until RI-1-001 lands, AC-RI-1's negative control cannot hold on the real pipeline.
|
|
||||||
2. **Playwright E2E unwired.** `apps/web` ships `test:e2e` (`playwright test`) with real suites (`admin/auth/chat/navigation.spec.ts`); neither `pnpm test` nor any CI step invokes it. The web UI's user flows are verified only when an operator runs them manually.
|
|
||||||
3. **No secret scanning on this repo.** The framework's own template pre-commit makes gitleaks **required**, and `verify.sh` proves detection with a planted key — but this repository's `.husky/pre-commit` (lint-staged only) and CI run no secret scan. The repo ships the control it does not use.
|
|
||||||
4. **No dependency audit.** The quality `.woodpecker.yml` templates and `docs/CI-SETUP.md` specify `npm audit --audit-level=high` as a pipeline stage; nothing equivalent runs for this repo.
|
|
||||||
5. **No coverage thresholds.** Templates enforce 80% Jest coverage thresholds; this repo's Vitest configs collect coverage with no thresholds — coverage is measured nowhere and enforced nowhere.
|
|
||||||
6. **`test-roster-schema.py` invisible.** A real jsonschema regression suite wired to no surface and invisible to the enumeration guard (its population is `*.sh`; the suite is `.py`). Either enumerate it or sign an exclusion — silence here is the #1017 defect shape.
|
|
||||||
7. **Presence-checker expectations ≠ this repo.** `quality-rails check` expects `.eslintrc`, `biome.json`, `.githooks/pre-commit`, `PR-CHECKLIST.md` for node projects — none describe this monorepo (husky, flat eslint config, no biome, no PR-CHECKLIST.md). The evaluator's check set must be per-subject (versioned, digested), not one global file list.
|
|
||||||
8. **Chain-ordering residual (documented).** `test:framework-shell` is one `&&` chain: a failing link skips every later suite while the step still fails (measured in #1270 — four suites after position 44 had not run since a prior merge). The enumeration guard proves naming, not reachability; both residuals are in-file documented but structurally unfixed.
|
|
||||||
9. **Signed-exclusion burndown open.** 16 signed exclusions remain in `test-enumeration-exclusions.txt`; several are "unmeasured in CI image" or blocked on missing CI tooling (tmux, setsid) — tracked under #1017/#1271. Each is an enforcement promise deferred, not delivered.
|
|
||||||
10. **Windows twins unexercised.** `verify.ps1`, `install.ps1`, `mosaic-doctor.ps1` have no runner anywhere (no Windows CI); behavioral drift from their bash twins is undetectable by construction.
|
|
||||||
11. **QA hook name vs behavior.** `qa-hook-handler.sh` files remediation report templates but performs no verification; the seam's actual gate value is only the deps-preflight sentinel. Anything relying on "QA automation hook" as a check is relying on report-filing.
|
|
||||||
12. **Two test paths, one gated.** CI runs tests against ci-postgres (`DATABASE_URL` set); the local PGlite path is the documented default (AGENTS.md) until KBN-101-02/101-05. Only the CI path is enforced by pipeline.
|
|
||||||
|
|
||||||
## 4. Disposition summary
|
|
||||||
|
|
||||||
| disposition | rows | checks |
|
|
||||||
| ------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| preserve | 43 | Every canonical owner (QC-1..QC-18) plus correct guards-of-the-guard and thin adapters: all of §1.1, the CI-invoked framework probes and adapters in §1.2, all of §1.3, the C1/C2/C5 trio and doctors in §1.4, all of §1.5, all pipeline-only steps in §1.6, §1.7 rows 1 and 3, and §1.8. |
|
|
||||||
| strengthen | 2 | `quality-rails check` and `quality-rails doctor` (QC-19) — the RI-N4 evaluator seed: typed verdicts, versioned/digested check definitions, per-subject check sets. |
|
|
||||||
| strengthen (review) | 9 | `verify.sh` + `verify.ps1` (QC-20), quality `install.sh`/`install.ps1` + `quality-rails init` (QC-21 — scaffold-path convergence), `test-roster-schema.py` (QC-5 — wire or sign), `qa-hook-stdin.sh` seam + `typecheck-hook.sh` (QC-16), `tools/e2e-install-test.sh` (QC-5 — CI lane). |
|
|
||||||
| retire | 0 | None meet the bar: RI-N4 requires effective shell probes be **absorbed before** their paths retire, and no absorption exists yet. The `strengthen (review)` rows are the retirement candidates for RI-3-002 once the evaluator owns their behavior. |
|
|
||||||
|
|
||||||
Row total: 54. Canonical checks: 21 (QC-1..QC-21).
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -35,18 +35,6 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
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) —
|
# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
|
||||||
# never an environment variable — so this opt-out can never sit silently
|
# never an environment variable — so this opt-out can never sit silently
|
||||||
# inherited in a shell profile. See #869 Point-1 C2.
|
# 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/memory"
|
||||||
mkdir -p "$TARGET_DIR/credentials"
|
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
|
# Reconcile contract files from defaults/ into the framework root: framework-owned
|
||||||
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
|
# 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.
|
# 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
|
Requires=mosaic-tmux-holder.service
|
||||||
After=mosaic-tmux-holder.service
|
After=mosaic-tmux-holder.service
|
||||||
PartOf=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]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
|||||||
@@ -128,14 +128,6 @@ EOF
|
|||||||
sleep 30
|
sleep 30
|
||||||
EOF
|
EOF
|
||||||
chmod 700 "$AGENT_BIN/mosaic"
|
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_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
|
||||||
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | 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" \
|
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||||
|
|||||||
@@ -225,54 +225,6 @@ else
|
|||||||
warn "mosaic-ensure-sequential-thinking helper missing"
|
warn "mosaic-ensure-sequential-thinking helper missing"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fleet transport binary (#1240).
|
|
||||||
#
|
|
||||||
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
|
|
||||||
# roster the CLI scaffolds sets `transport: tmux`, but nothing in the install
|
|
||||||
# path provides tmux and, until now, nothing here noticed it was absent. On a
|
|
||||||
# greenfield host that produced a fleet which installed clean, started clean,
|
|
||||||
# and had no live seat; `mosaic fleet ps` was the operator's first and only
|
|
||||||
# signal that anything was wrong.
|
|
||||||
#
|
|
||||||
# The roster's own `transport:` is read rather than assumed, so a host that
|
|
||||||
# declares something other than tmux is told about the binary it actually
|
|
||||||
# needs. Absent a roster the check still runs — `mosaic fleet init` will
|
|
||||||
# scaffold a tmux fleet on this host, and finding out beforehand is the point.
|
|
||||||
#
|
|
||||||
# `tools/install.sh` carries a deliberately parallel check at the end of its
|
|
||||||
# summary. The two are separate because the installer must be able to say this
|
|
||||||
# before the framework's own scripts are guaranteed to be on disk; keep their
|
|
||||||
# wording in step.
|
|
||||||
fleet_declared_transport() {
|
|
||||||
local roster="$MOSAIC_HOME/fleet/roster.yaml"
|
|
||||||
local declared=""
|
|
||||||
|
|
||||||
if [[ -f "$roster" ]]; then
|
|
||||||
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
|
|
||||||
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "${declared:-tmux}"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_fleet_transport() {
|
|
||||||
local transport
|
|
||||||
transport="$(fleet_declared_transport)"
|
|
||||||
|
|
||||||
if command -v "$transport" >/dev/null 2>&1; then
|
|
||||||
pass "Fleet transport available: $transport"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f "$MOSAIC_HOME/fleet/roster.yaml" ]]; then
|
|
||||||
warn "Fleet transport '$transport' is not installed — this host has a roster and no seat can launch. Install it (e.g. sudo apt-get install -y $transport), then 'mosaic fleet start'."
|
|
||||||
else
|
|
||||||
warn "Fleet transport '$transport' is not installed — 'mosaic fleet' cannot run seats here. Install it (e.g. sudo apt-get install -y $transport) before 'mosaic fleet init'."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check_fleet_transport
|
|
||||||
|
|
||||||
# Legacy migration surfaces should no longer contain symlink trees.
|
# Legacy migration surfaces should no longer contain symlink trees.
|
||||||
legacy_paths=(
|
legacy_paths=(
|
||||||
"$HOME/.claude/agent-guides"
|
"$HOME/.claude/agent-guides"
|
||||||
|
|||||||
@@ -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)"
|
|
||||||
@@ -286,36 +286,6 @@ _build_runtime_bin_prefix() {
|
|||||||
MOSAIC_RUNTIME_BIN_PREFIX=$(_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
|
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() {
|
_ensure_claude_workdir_trusted() {
|
||||||
local workdir="$1"
|
local workdir="$1"
|
||||||
local resolved
|
local resolved
|
||||||
@@ -414,19 +384,6 @@ if [ -n "$PANE_PID" ]; then
|
|||||||
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
||||||
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
||||||
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
|
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
|
else
|
||||||
# #1241. This branch used to print a WARNING about the heartbeat sidecar and
|
echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2
|
||||||
# exit 0. It is not a heartbeat problem: tmux destroys a session when its pane
|
|
||||||
# command exits, so an absent session one second after new-session means the
|
|
||||||
# runtime died on startup. Reporting it as success is what let `fleet start`
|
|
||||||
# return 0 over three dead panes — the launcher knew, and said the wrong thing
|
|
||||||
# at the wrong severity to the wrong layer.
|
|
||||||
fail_launch pane-did-not-survive \
|
|
||||||
"the pane exited immediately and tmux destroyed the session;" \
|
|
||||||
"run 'mosaic yolo ${MOSAIC_AGENT_RUNTIME}' in ${MOSAIC_AGENT_WORKDIR} to see why"
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -23,26 +23,8 @@ index=0
|
|||||||
if [ "${args[0]:-}" = -L ]; then index=2; fi
|
if [ "${args[0]:-}" = -L ]; then index=2; fi
|
||||||
case "${args[$index]:-}" in
|
case "${args[$index]:-}" in
|
||||||
has-session)
|
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
|
for argument in "${args[@]}"; do
|
||||||
[ "$argument" = '=_holder:0.0' ] && exit 0
|
[ "$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
|
done
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
@@ -80,30 +62,6 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
|||||||
SHIM
|
SHIM
|
||||||
chmod +x "$FAKE_BIN/mosaic"
|
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() {
|
write_generated() {
|
||||||
local home="$1"
|
local home="$1"
|
||||||
local agent="$2"
|
local agent="$2"
|
||||||
@@ -123,19 +81,6 @@ MOSAIC_TMUX_SOCKET=mosaic-test
|
|||||||
EOF
|
EOF
|
||||||
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
||||||
mkdir -p "$home/work"
|
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() {
|
run_start() {
|
||||||
@@ -143,7 +88,6 @@ run_start() {
|
|||||||
local agent="$2"
|
local agent="$2"
|
||||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||||
MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \
|
|
||||||
MOSAIC_TEST_HOME="$home" \
|
MOSAIC_TEST_HOME="$home" \
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
MOSAIC_HOME="$home" "$START" "$agent"
|
MOSAIC_HOME="$home" "$START" "$agent"
|
||||||
@@ -154,10 +98,7 @@ run_start() {
|
|||||||
HOME_VALID="$ROOT/valid"
|
HOME_VALID="$ROOT/valid"
|
||||||
AGENT_VALID="coder0"
|
AGENT_VALID="coder0"
|
||||||
write_generated "$HOME_VALID" "$AGENT_VALID"
|
write_generated "$HOME_VALID" "$AGENT_VALID"
|
||||||
# A live pane PID is part of what "valid launch" means. Until #1241 this case
|
run_start "$HOME_VALID" "$AGENT_VALID"
|
||||||
# ran with none, so the suite's one success path was itself a dead pane the
|
|
||||||
# launcher reported as fine.
|
|
||||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_VALID" "$AGENT_VALID"
|
|
||||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
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 new-session || fail "valid generated projection did not reach tmux"
|
||||||
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
||||||
@@ -304,13 +245,6 @@ PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
|
|||||||
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
|
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
|
||||||
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
||||||
chmod 600 "$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' \
|
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
|
||||||
BASH_ENV="$PANE_BASH_ENV" \
|
BASH_ENV="$PANE_BASH_ENV" \
|
||||||
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
|
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
|
||||||
@@ -324,7 +258,6 @@ PATH="$PANE_STALE_PATH" \
|
|||||||
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
|
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||||
"MOSAIC_TEST_PANE_PID=$$" \
|
|
||||||
"$START" coder-pane-boundary
|
"$START" coder-pane-boundary
|
||||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||||
@@ -459,75 +392,6 @@ echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
|||||||
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
||||||
fail "interaction pinned-policy check did not follow strict parsing"
|
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
|
# Exact stop derives the socket exclusively from the validated generated
|
||||||
# projection and ignores an ambient socket supplied by the caller.
|
# projection and ignores an ambient socket supplied by the caller.
|
||||||
: > "$TMUX_CALLS"
|
: > "$TMUX_CALLS"
|
||||||
|
|||||||
@@ -62,14 +62,7 @@ EXPECTED_ACTIVATION_CAPABILITY: Final[ActivationCapability] = {
|
|||||||
# capability as compact JSON.
|
# capability as compact JSON.
|
||||||
LEASE_CAPABILITY_PROBE_COMMAND: Final = "__lease-capability"
|
LEASE_CAPABILITY_PROBE_COMMAND: Final = "__lease-capability"
|
||||||
|
|
||||||
# Budget for the out-of-process `mosaic __lease-capability` probe. The CLI
|
PROBE_TIMEOUT_SECONDS: Final = 2.0
|
||||||
# is a Node program whose cold start alone measures 2.2-2.3s on a mid-range
|
|
||||||
# workstation (sb-it-1-dt, 2026-08-13), so a 2s budget made every launch on
|
|
||||||
# such hosts fail closed with the #869 skew message even though the
|
|
||||||
# capability matched. The timeout only bounds the pathological hang case —
|
|
||||||
# the happy path returns as soon as the probe exits — so a generous budget
|
|
||||||
# costs nothing on healthy hosts.
|
|
||||||
PROBE_TIMEOUT_SECONDS: Final = 10.0
|
|
||||||
|
|
||||||
# Override hook: a full shell-style command line (parsed with `shlex.split`)
|
# Override hook: a full shell-style command line (parsed with `shlex.split`)
|
||||||
# to run INSTEAD of resolving `mosaic` on PATH and appending the probe
|
# to run INSTEAD of resolving `mosaic` on PATH and appending the probe
|
||||||
@@ -95,13 +88,7 @@ def _resolve_probe_command(environ: Mapping[str, str]) -> list[str] | None:
|
|||||||
if override:
|
if override:
|
||||||
parsed = shlex.split(override)
|
parsed = shlex.split(override)
|
||||||
return parsed or None
|
return parsed or None
|
||||||
# Resolve against the PROVIDED environment's PATH, not the ambient
|
resolved = shutil.which("mosaic")
|
||||||
# os.environ. Before this, a test passing a hermetic environ still
|
|
||||||
# resolved (and spawned) the host's real `mosaic` — masked only on hosts
|
|
||||||
# where the real probe happened to exceed the old 2s timeout. No PATH in
|
|
||||||
# the provided environment means nothing is resolvable (fail-closed),
|
|
||||||
# matching the probe's overall contract.
|
|
||||||
resolved = shutil.which("mosaic", path=environ.get("PATH", ""))
|
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
return None
|
return None
|
||||||
return [resolved, LEASE_CAPABILITY_PROBE_COMMAND]
|
return [resolved, LEASE_CAPABILITY_PROBE_COMMAND]
|
||||||
|
|||||||
@@ -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)
|
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 ---
|
# --- 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 ---
|
# --- naming-boundary files the strict test-*.sh prefix cannot even name ---
|
||||||
# (#1017: three independent censuses handled the microtest file three different
|
# (#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.)
|
# 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/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
|
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
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"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"
|
"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": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -34,7 +34,6 @@ export {
|
|||||||
resolveInstalledFleetRosterPath,
|
resolveInstalledFleetRosterPath,
|
||||||
} from '../fleet/fleet-roster-v1.js';
|
} from '../fleet/fleet-roster-v1.js';
|
||||||
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
||||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
|
||||||
import {
|
import {
|
||||||
registerFleetAgentCrudCommands,
|
registerFleetAgentCrudCommands,
|
||||||
type FleetAgentCrudCommandDeps,
|
type FleetAgentCrudCommandDeps,
|
||||||
@@ -821,7 +820,7 @@ export function buildEnableLingerCommand(user: string): string[] {
|
|||||||
*/
|
*/
|
||||||
export async function enableFleetUnits(
|
export async function enableFleetUnits(
|
||||||
runner: CommandRunner,
|
runner: CommandRunner,
|
||||||
roster: { readonly agents: readonly { readonly name: string }[] },
|
roster: FleetRoster,
|
||||||
opts: { enable?: boolean },
|
opts: { enable?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (opts.enable === false) {
|
if (opts.enable === false) {
|
||||||
@@ -1528,8 +1527,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||||
.action(async (opts: { enable?: boolean }) => {
|
.action(async (opts: { enable?: boolean }) => {
|
||||||
await installFleet(cmd, frameworkRoot);
|
await installFleet(cmd, frameworkRoot);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
const roster = await loadRosterForCommand(cmd);
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1539,8 +1537,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||||
.action(async (opts: { enable?: boolean }) => {
|
.action(async (opts: { enable?: boolean }) => {
|
||||||
await installFleet(cmd, frameworkRoot);
|
await installFleet(cmd, frameworkRoot);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
const roster = await loadRosterForCommand(cmd);
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1691,9 +1688,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.action(async (opts: { json?: boolean }) => {
|
.action(async (opts: { json?: boolean }) => {
|
||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
// ps only reads, so it takes the version-agnostic read model rather than
|
const roster = await loadRosterForCommand(cmd);
|
||||||
// the v1 parser, which rejects a v2 roster outright.
|
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
const { tenant_id, host } = getDefaultTenantAndHost();
|
const { tenant_id, host } = getDefaultTenantAndHost();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|
||||||
@@ -1913,16 +1908,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
start: boolean;
|
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)) {
|
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
|
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
|
||||||
@@ -1988,12 +1973,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.description('Remove an agent from the fleet roster')
|
.description('Remove an agent from the fleet roster')
|
||||||
.option('--keep-files', 'Skip deleting env and heartbeat files')
|
.option('--keep-files', 'Skip deleting env and heartbeat files')
|
||||||
.action(async (name: string, opts: { keepFiles?: boolean }) => {
|
.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 commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
@@ -2352,9 +2331,7 @@ export function registerFleetAgentCommands(
|
|||||||
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
||||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||||
// Read model first: every file this function places is roster-independent, and
|
const roster = await loadRosterForCommand(cmd);
|
||||||
// the v1 parser would reject a v2 roster before any of them were written.
|
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
||||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||||
@@ -2414,30 +2391,16 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
join(activePaths.systemdUserDir, '[email protected]'),
|
join(activePaths.systemdUserDir, '[email protected]'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// On roster v2 the reconciler owns the generated env: `apply` writes it and
|
for (const agent of roster.agents) {
|
||||||
// `regen` rebuilds it, both from projectRosterV2AgentGeneratedEnv. Writing it
|
|
||||||
// here too — necessarily through the v1 mapping — would be the third writer of
|
|
||||||
// one file and would break the #791 single-SSOT invariant. So v2 gets the tool
|
|
||||||
// files and the units, and nothing else.
|
|
||||||
if (roster.version === 2) {
|
|
||||||
console.log(
|
|
||||||
`Installed fleet tools and systemd units for ${roster.agents.length} agent(s). ` +
|
|
||||||
`Generated env is owned by the reconciler on roster v2 — run: mosaic fleet apply --expected-generation <n>`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const v1Roster = await loadRosterForCommand(cmd);
|
|
||||||
for (const agent of v1Roster.agents) {
|
|
||||||
await writeAgentEnvironmentProjection({
|
await writeAgentEnvironmentProjection({
|
||||||
mosaicHome: activePaths.mosaicHome,
|
mosaicHome: activePaths.mosaicHome,
|
||||||
agentEnvDir: activePaths.agentEnvDir,
|
agentEnvDir: activePaths.agentEnvDir,
|
||||||
agentName: agent.name,
|
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> {
|
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||||
@@ -2464,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 v2→v1 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(
|
async function loadRosterFromAgentCommand(
|
||||||
command: Command,
|
command: Command,
|
||||||
mosaicHomeOverride?: string,
|
mosaicHomeOverride?: string,
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import {
|
import {
|
||||||
LEASE_ACTIVATION_CAPABILITY,
|
LEASE_ACTIVATION_CAPABILITY,
|
||||||
LEASE_CAPABILITY_PROBE_COMMAND,
|
LEASE_CAPABILITY_PROBE_COMMAND,
|
||||||
LEASE_CAPABILITY_PROBE_TIMEOUT_MS,
|
|
||||||
defaultCapabilityProbe,
|
defaultCapabilityProbe,
|
||||||
defaultResolveCliEntry,
|
defaultResolveCliEntry,
|
||||||
defaultSupervisorProbe,
|
defaultSupervisorProbe,
|
||||||
leaseEnforcementActivatable,
|
leaseEnforcementActivatable,
|
||||||
registerLeaseCapabilityProbe,
|
registerLeaseCapabilityProbe,
|
||||||
type CapabilityProbeExecFile,
|
|
||||||
type LeaseActivationCapability,
|
type LeaseActivationCapability,
|
||||||
type SupervisorProbeResult,
|
type SupervisorProbeResult,
|
||||||
} from './lease-activation-probe.js';
|
} from './lease-activation-probe.js';
|
||||||
@@ -37,17 +35,6 @@ const presentSupervisor: SupervisorProbeResult = {
|
|||||||
socketPath: '/run/user/1000/mosaic-lease/broker.sock',
|
socketPath: '/run/user/1000/mosaic-lease/broker.sock',
|
||||||
};
|
};
|
||||||
|
|
||||||
function withScratchCli<T>(run: (cliPath: string) => T): T {
|
|
||||||
const scratchDir = mkdtempSync(join(tmpdir(), 'mosaic-lease-capability-probe-'));
|
|
||||||
try {
|
|
||||||
const cliPath = join(scratchDir, 'cli.js');
|
|
||||||
writeFileSync(cliPath, '// isolated fake; injected execFile means this is never executed\n');
|
|
||||||
return run(cliPath);
|
|
||||||
} finally {
|
|
||||||
rmSync(scratchDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('leaseEnforcementActivatable', () => {
|
describe('leaseEnforcementActivatable', () => {
|
||||||
it('is false when the activation capability is absent (null)', () => {
|
it('is false when the activation capability is absent (null)', () => {
|
||||||
const result = leaseEnforcementActivatable({
|
const result = leaseEnforcementActivatable({
|
||||||
@@ -113,6 +100,15 @@ describe('leaseEnforcementActivatable', () => {
|
|||||||
});
|
});
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the real default probes when no deps are injected (does not throw)', () => {
|
||||||
|
// No live broker / built CLI is guaranteed in a test environment, so this
|
||||||
|
// only asserts the predicate degrades to a safe boolean rather than
|
||||||
|
// throwing — the fail-closed behavior itself is covered by the injected
|
||||||
|
// cases above.
|
||||||
|
expect(() => leaseEnforcementActivatable()).not.toThrow();
|
||||||
|
expect(typeof leaseEnforcementActivatable()).toBe('boolean');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('defaultCapabilityProbe', () => {
|
describe('defaultCapabilityProbe', () => {
|
||||||
@@ -131,61 +127,6 @@ describe('defaultCapabilityProbe', () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes the exact ten-second timeout to the injected child-process transport', () => {
|
|
||||||
withScratchCli((cliPath) => {
|
|
||||||
let captured:
|
|
||||||
| {
|
|
||||||
file: string;
|
|
||||||
args: string[];
|
|
||||||
options: Parameters<CapabilityProbeExecFile>[2];
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
const execFile: CapabilityProbeExecFile = (file, args, options) => {
|
|
||||||
captured = { file, args, options };
|
|
||||||
return JSON.stringify(LEASE_ACTIVATION_CAPABILITY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile });
|
|
||||||
|
|
||||||
expect(result).toEqual(LEASE_ACTIVATION_CAPABILITY);
|
|
||||||
expect(captured).toEqual({
|
|
||||||
file: process.execPath,
|
|
||||||
args: [cliPath, LEASE_CAPABILITY_PROBE_COMMAND],
|
|
||||||
options: {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 10_000,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(captured?.options.timeout).toBe(LEASE_CAPABILITY_PROBE_TIMEOUT_MS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['timeout', Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' })],
|
|
||||||
['spawn error', Object.assign(new Error('spawn failed'), { code: 'ENOENT' })],
|
|
||||||
['nonzero exit', Object.assign(new Error('child exited 1'), { status: 1 })],
|
|
||||||
])('returns null (fail-closed) on child-process %s', (_failure, error) => {
|
|
||||||
withScratchCli((cliPath) => {
|
|
||||||
const execFile: CapabilityProbeExecFile = () => {
|
|
||||||
throw error;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['unparseable JSON', 'not-json'],
|
|
||||||
['malformed object', JSON.stringify({ name: LEASE_ACTIVATION_CAPABILITY.name })],
|
|
||||||
])('returns null (fail-closed) on %s output', (_failure, output) => {
|
|
||||||
withScratchCli((cliPath) => {
|
|
||||||
const execFile: CapabilityProbeExecFile = () => output;
|
|
||||||
|
|
||||||
expect(defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('positive path — injected resolver, isolated scratch dir (never the real dist/)', () => {
|
describe('positive path — injected resolver, isolated scratch dir (never the real dist/)', () => {
|
||||||
// A prior version of this test staged the stub cli.js at the package's
|
// A prior version of this test staged the stub cli.js at the package's
|
||||||
// REAL resolved dist/ path and relied on afterEach to clean up "only
|
// REAL resolved dist/ path and relied on afterEach to clean up "only
|
||||||
|
|||||||
@@ -55,19 +55,6 @@ export const LEASE_ACTIVATION_CAPABILITY: LeaseActivationCapability = {
|
|||||||
/** Hidden CLI probe subcommand name — wired via {@link registerLeaseCapabilityProbe}. */
|
/** Hidden CLI probe subcommand name — wired via {@link registerLeaseCapabilityProbe}. */
|
||||||
export const LEASE_CAPABILITY_PROBE_COMMAND = '__lease-capability';
|
export const LEASE_CAPABILITY_PROBE_COMMAND = '__lease-capability';
|
||||||
|
|
||||||
/**
|
|
||||||
* Budget for the out-of-process capability probe. The probe launches a fresh
|
|
||||||
* Node process on the built CLI entrypoint, whose cold start alone measures
|
|
||||||
* 2.2-2.3s on a mid-range workstation (sb-it-1-dt, 2026-08-13) — so the
|
|
||||||
* previous 2s budget made the probe time out and report NO capability on
|
|
||||||
* such hosts, failing every launch with the #869 skew message even though
|
|
||||||
* the capability matched. The timeout only bounds the pathological hang
|
|
||||||
* case; the happy path returns as soon as the probe exits. Mirrors
|
|
||||||
* PROBE_TIMEOUT_SECONDS in the enforcement half
|
|
||||||
* (framework/tools/lease-broker/activation_version_gate.py).
|
|
||||||
*/
|
|
||||||
export const LEASE_CAPABILITY_PROBE_TIMEOUT_MS = 10_000;
|
|
||||||
|
|
||||||
function capabilityMatches(candidate: LeaseActivationCapability | null): boolean {
|
function capabilityMatches(candidate: LeaseActivationCapability | null): boolean {
|
||||||
return (
|
return (
|
||||||
candidate !== null &&
|
candidate !== null &&
|
||||||
@@ -123,28 +110,12 @@ export function defaultResolveCliEntry(
|
|||||||
return join(dirname(mainEntry), 'cli.js');
|
return join(dirname(mainEntry), 'cli.js');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Narrow injectable seam for the synchronous child process used by the
|
|
||||||
* capability probe. */
|
|
||||||
export type CapabilityProbeExecFile = (
|
|
||||||
file: string,
|
|
||||||
args: string[],
|
|
||||||
options: {
|
|
||||||
encoding: BufferEncoding;
|
|
||||||
timeout: number;
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'];
|
|
||||||
},
|
|
||||||
) => string;
|
|
||||||
|
|
||||||
/** Injectable inputs for {@link defaultCapabilityProbe}. */
|
/** Injectable inputs for {@link defaultCapabilityProbe}. */
|
||||||
export interface CapabilityProbeDeps {
|
export interface CapabilityProbeDeps {
|
||||||
/** Resolve the CLI entrypoint (`cli.js`) to probe. Defaults to
|
/** Resolve the CLI entrypoint (`cli.js`) to probe. Defaults to
|
||||||
* {@link defaultResolveCliEntry}. Inject to point at an isolated scratch
|
* {@link defaultResolveCliEntry}. Inject to point at an isolated scratch
|
||||||
* location in tests — never at the real package's `dist/`. */
|
* location in tests — never at the real package's `dist/`. */
|
||||||
resolveCliEntry?: () => string;
|
resolveCliEntry?: () => string;
|
||||||
/** Execute the resolved CLI entrypoint. Defaults to the real
|
|
||||||
* `execFileSync`. Inject so transport behavior and options can be tested
|
|
||||||
* without spawning a process. */
|
|
||||||
execFile?: CapabilityProbeExecFile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,10 +139,9 @@ export function defaultCapabilityProbe(
|
|||||||
const cliEntry = resolveCliEntry();
|
const cliEntry = resolveCliEntry();
|
||||||
if (!existsSync(cliEntry)) return null;
|
if (!existsSync(cliEntry)) return null;
|
||||||
|
|
||||||
const execFile: CapabilityProbeExecFile = deps.execFile ?? execFileSync;
|
const output = execFileSync(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
|
||||||
const output = execFile(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
|
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: LEASE_CAPABILITY_PROBE_TIMEOUT_MS,
|
timeout: 2000,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import io
|
import io
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import redirect_stderr
|
from contextlib import redirect_stderr
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
|
|
||||||
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||||
@@ -61,20 +57,6 @@ def matching_capability() -> dict[str, object]:
|
|||||||
return dict(VERSION_GATE.EXPECTED_ACTIVATION_CAPABILITY)
|
return dict(VERSION_GATE.EXPECTED_ACTIVATION_CAPABILITY)
|
||||||
|
|
||||||
|
|
||||||
def write_fake_mosaic(directory: Path, marker: Path) -> Path:
|
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
|
||||||
executable = directory / "mosaic"
|
|
||||||
executable.write_text(
|
|
||||||
"#!/bin/sh\n"
|
|
||||||
f"printf '%s\\n' executed >> {shlex.quote(str(marker))}\n"
|
|
||||||
"printf '%s\\n' "
|
|
||||||
"'{\"name\":\"lease-runtime-activation\",\"version\":1}'\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
executable.chmod(0o755)
|
|
||||||
return executable
|
|
||||||
|
|
||||||
|
|
||||||
class AssertActivationCapabilityMatchesTest(unittest.TestCase):
|
class AssertActivationCapabilityMatchesTest(unittest.TestCase):
|
||||||
"""Unit-level coverage of `activation_version_gate.py`'s own assertion,
|
"""Unit-level coverage of `activation_version_gate.py`'s own assertion,
|
||||||
isolated from the launch-runtime.py seam it is wired into below."""
|
isolated from the launch-runtime.py seam it is wired into below."""
|
||||||
@@ -128,102 +110,11 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
|
|||||||
handling — never spawns a real `mosaic` process."""
|
handling — never spawns a real `mosaic` process."""
|
||||||
|
|
||||||
def test_returns_none_when_mosaic_is_not_resolvable_on_path(self) -> None:
|
def test_returns_none_when_mosaic_is_not_resolvable_on_path(self) -> None:
|
||||||
# Keep even a deliberate ambient-lookup mutation away from any host
|
result = VERSION_GATE.default_probe_activation_capability(
|
||||||
# installation. The dedicated hermeticity tests below provide fake
|
{"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
|
||||||
# ambient executables and markers.
|
)
|
||||||
with mock.patch.dict(
|
|
||||||
os.environ, {"PATH": "/nonexistent-ambient-bin-dir-for-869-c4-test"}
|
|
||||||
):
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
{"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
|
|
||||||
)
|
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(result)
|
||||||
|
|
||||||
def test_supplied_path_wins_over_ambient_process_path(self) -> None:
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
supplied_marker = root / "supplied.marker"
|
|
||||||
ambient_marker = root / "ambient.marker"
|
|
||||||
supplied_bin = root / "supplied-bin"
|
|
||||||
ambient_bin = root / "ambient-bin"
|
|
||||||
write_fake_mosaic(supplied_bin, supplied_marker)
|
|
||||||
write_fake_mosaic(ambient_bin, ambient_marker)
|
|
||||||
|
|
||||||
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
{"PATH": str(supplied_bin)}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, matching_capability())
|
|
||||||
self.assertTrue(supplied_marker.exists())
|
|
||||||
self.assertFalse(ambient_marker.exists())
|
|
||||||
|
|
||||||
def test_absent_or_empty_supplied_path_never_falls_back_or_executes(self) -> None:
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
ambient_marker = root / "ambient.marker"
|
|
||||||
current_directory_marker = root / "current-directory.marker"
|
|
||||||
ambient_bin = root / "ambient-bin"
|
|
||||||
current_directory = root / "current-directory"
|
|
||||||
write_fake_mosaic(ambient_bin, ambient_marker)
|
|
||||||
write_fake_mosaic(current_directory, current_directory_marker)
|
|
||||||
original_directory = Path.cwd()
|
|
||||||
|
|
||||||
try:
|
|
||||||
os.chdir(current_directory)
|
|
||||||
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
|
|
||||||
for supplied_environment in ({}, {"PATH": ""}):
|
|
||||||
with self.subTest(environ=supplied_environment):
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
supplied_environment
|
|
||||||
)
|
|
||||||
self.assertIsNone(result)
|
|
||||||
self.assertFalse(ambient_marker.exists())
|
|
||||||
self.assertFalse(current_directory_marker.exists())
|
|
||||||
finally:
|
|
||||||
os.chdir(original_directory)
|
|
||||||
|
|
||||||
def test_valid_override_wins_and_invalid_override_does_not_fall_back_to_path(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
supplied_marker = root / "supplied.marker"
|
|
||||||
ambient_marker = root / "ambient.marker"
|
|
||||||
override_marker = root / "override.marker"
|
|
||||||
supplied_bin = root / "supplied-bin"
|
|
||||||
ambient_bin = root / "ambient-bin"
|
|
||||||
override_bin = root / "override-bin"
|
|
||||||
write_fake_mosaic(supplied_bin, supplied_marker)
|
|
||||||
write_fake_mosaic(ambient_bin, ambient_marker)
|
|
||||||
override_executable = write_fake_mosaic(override_bin, override_marker)
|
|
||||||
|
|
||||||
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
{
|
|
||||||
"PATH": str(supplied_bin),
|
|
||||||
VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: str(override_executable),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.assertEqual(result, matching_capability())
|
|
||||||
self.assertTrue(override_marker.exists())
|
|
||||||
self.assertFalse(supplied_marker.exists())
|
|
||||||
self.assertFalse(ambient_marker.exists())
|
|
||||||
|
|
||||||
override_marker.unlink()
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
{
|
|
||||||
"PATH": str(supplied_bin),
|
|
||||||
VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: str(
|
|
||||||
root / "invalid-override" / "mosaic"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.assertIsNone(result)
|
|
||||||
self.assertFalse(override_marker.exists())
|
|
||||||
self.assertFalse(supplied_marker.exists())
|
|
||||||
self.assertFalse(ambient_marker.exists())
|
|
||||||
|
|
||||||
def test_override_command_is_parsed_and_the_probe_subcommand_is_not_double_appended(
|
def test_override_command_is_parsed_and_the_probe_subcommand_is_not_double_appended(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -244,29 +135,6 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
|
|||||||
self.assertEqual(result, {"name": "lease-runtime-activation", "version": 1})
|
self.assertEqual(result, {"name": "lease-runtime-activation", "version": 1})
|
||||||
self.assertEqual(captured, [["/fake/mosaic", "__lease-capability"]])
|
self.assertEqual(captured, [["/fake/mosaic", "__lease-capability"]])
|
||||||
|
|
||||||
def test_probe_passes_ten_second_timeout_to_runner(self) -> None:
|
|
||||||
captured_argv: list[str] = []
|
|
||||||
captured_kwargs: dict[str, object] = {}
|
|
||||||
|
|
||||||
class FakeCompleted:
|
|
||||||
returncode = 0
|
|
||||||
stdout = '{"name": "lease-runtime-activation", "version": 1}'
|
|
||||||
|
|
||||||
def fake_run(argv: list[str], **kwargs: object) -> FakeCompleted:
|
|
||||||
captured_argv.extend(argv)
|
|
||||||
captured_kwargs.update(kwargs)
|
|
||||||
return FakeCompleted()
|
|
||||||
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic"},
|
|
||||||
run=fake_run,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, matching_capability())
|
|
||||||
self.assertEqual(captured_argv, ["/fake/mosaic"])
|
|
||||||
self.assertEqual(captured_kwargs["timeout"], 10.0)
|
|
||||||
self.assertEqual(captured_kwargs["check"], False)
|
|
||||||
|
|
||||||
def test_fails_closed_on_nonzero_exit_malformed_json_and_missing_fields(self) -> None:
|
def test_fails_closed_on_nonzero_exit_malformed_json_and_missing_fields(self) -> None:
|
||||||
class NonZeroExit:
|
class NonZeroExit:
|
||||||
returncode = 1
|
returncode = 1
|
||||||
@@ -306,7 +174,7 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_fails_closed_on_timeout_and_transport_error(self) -> None:
|
def test_fails_closed_on_timeout_and_transport_error(self) -> None:
|
||||||
def timeout_run(*_args: object, **_kwargs: object) -> None:
|
def timeout_run(*_args: object, **_kwargs: object) -> None:
|
||||||
raise subprocess.TimeoutExpired(cmd="mosaic", timeout=10.0)
|
raise subprocess.TimeoutExpired(cmd="mosaic", timeout=2.0)
|
||||||
|
|
||||||
def oserror_run(*_args: object, **_kwargs: object) -> None:
|
def oserror_run(*_args: object, **_kwargs: object) -> None:
|
||||||
raise OSError("no such file or directory")
|
raise OSError("no such file or directory")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir, platform } from 'node:os';
|
import { homedir, platform } from 'node:os';
|
||||||
|
|
||||||
@@ -21,18 +22,15 @@ export function getShellProfilePath(): string | null {
|
|||||||
|
|
||||||
const shell = detectShell();
|
const shell = detectShell();
|
||||||
switch (shell) {
|
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': {
|
case 'zsh': {
|
||||||
const zdotdir = process.env['ZDOTDIR'] ?? home;
|
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');
|
return join(home, '.profile');
|
||||||
|
}
|
||||||
case 'fish':
|
case 'fish':
|
||||||
return join(home, '.config', 'fish', 'config.fish');
|
return join(home, '.config', 'fish', 'config.fish');
|
||||||
default:
|
default:
|
||||||
|
|||||||
+15
-293
@@ -309,124 +309,6 @@ require_cmd() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# True if any shell rc file already puts $1 on PATH.
|
|
||||||
#
|
|
||||||
# Each file is tested for existence first and grepped one at a time, rather than
|
|
||||||
# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file
|
|
||||||
# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q
|
|
||||||
# matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless.
|
|
||||||
# On the 2 path the caller reads "not present yet" and appends a duplicate PATH
|
|
||||||
# line on every single install.
|
|
||||||
path_entry_exists() {
|
|
||||||
local dir="$1" rc_file
|
|
||||||
for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do
|
|
||||||
if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this
|
|
||||||
# process. An `export` here reaches only the installer; every directory the
|
|
||||||
# install leaves behind has to be written down somewhere a later shell reads.
|
|
||||||
#
|
|
||||||
# Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for
|
|
||||||
# non-interactive shells, so a PATH line appended to the bottom of it is
|
|
||||||
# unreachable to `bash -lc`, to systemd units, and to every agent seat — the
|
|
||||||
# exact consumers that need these binaries. ~/.profile is read by login shells
|
|
||||||
# and Debian's .profile sources .bashrc for interactive ones, so a single line
|
|
||||||
# there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc.
|
|
||||||
#
|
|
||||||
# $1 = directory to add, $2 = label for the comment line.
|
|
||||||
# Returns 1 (having warned) if the profile could not be written.
|
|
||||||
persist_on_path() {
|
|
||||||
local dir="$1" label="$2" profile
|
|
||||||
|
|
||||||
if path_entry_exists "$dir"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
|
|
||||||
profile="$HOME/.zshenv"
|
|
||||||
else
|
|
||||||
profile="$HOME/.profile"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Probe writability in a subshell. A redirection failure on a special built-in
|
|
||||||
# aborts the shell it runs in, so it has to be a child; and the redirection on
|
|
||||||
# the subshell is what silences the "Permission denied" the shell would
|
|
||||||
# otherwise print ahead of our own message.
|
|
||||||
if ! ( : >>"$profile" ) 2>/dev/null; then
|
|
||||||
warn "$dir is not on your PATH and $profile could not be written"
|
|
||||||
dim " Add to your shell rc: export PATH=\"$dir:\$PATH\""
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
{
|
|
||||||
echo ""
|
|
||||||
echo "# $label"
|
|
||||||
echo "export PATH=\"$dir:\$PATH\""
|
|
||||||
} >>"$profile"
|
|
||||||
ok "Added $dir to PATH in $profile"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Persist $PREFIX/bin on PATH instead of only warning about it.
|
|
||||||
#
|
|
||||||
# The warning it replaces was the last step of an otherwise successful install,
|
|
||||||
# so the installer reported success and left `mosaic: command not found` — an
|
|
||||||
# unattended install had no operator to read the advice and act on it.
|
|
||||||
ensure_prefix_on_path() {
|
|
||||||
if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if path_entry_exists "$PREFIX/bin"; then
|
|
||||||
warn "$PREFIX/bin is in your shell profile but not in this shell"
|
|
||||||
elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fleet transport binary (#1240).
|
|
||||||
#
|
|
||||||
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
|
|
||||||
# roster the CLI scaffolds sets `transport: tmux`, but nothing in this script
|
|
||||||
# provides tmux and, until now, nothing in it mentioned tmux at all. A
|
|
||||||
# greenfield host came out of this installer able to install a fleet, start a
|
|
||||||
# fleet, and run no seat — the operator's first signal was `mosaic fleet ps`.
|
|
||||||
#
|
|
||||||
# Not a `require_cmd`: tmux is required by the fleet, not by mosaic. Plenty of
|
|
||||||
# hosts install this to run `mosaic claude` and will never scaffold a roster,
|
|
||||||
# and failing their install over a binary they do not need would be wrong. It
|
|
||||||
# is a warning that names precisely what it blocks.
|
|
||||||
#
|
|
||||||
# `tools/_scripts/mosaic-doctor` carries a deliberately parallel check, so the
|
|
||||||
# same host state gets the same answer from an audit as from an install. They
|
|
||||||
# are separate implementations because this one has to work before the
|
|
||||||
# framework's scripts are guaranteed to be on disk; keep their wording in step.
|
|
||||||
check_fleet_transport() {
|
|
||||||
local transport=tmux
|
|
||||||
local roster="$MOSAIC_HOME/fleet/roster.yaml"
|
|
||||||
local declared=""
|
|
||||||
|
|
||||||
if [[ -f "$roster" ]]; then
|
|
||||||
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
|
|
||||||
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
|
|
||||||
[[ -n "$declared" ]] && transport="$declared"
|
|
||||||
fi
|
|
||||||
|
|
||||||
command -v "$transport" &>/dev/null && return 0
|
|
||||||
|
|
||||||
warn "Fleet transport '$transport' is not installed."
|
|
||||||
echo " The Mosaic fleet runs its agent seats inside $transport. Without it,"
|
|
||||||
echo " ${C}mosaic fleet start${RESET} reports success and no seat comes up."
|
|
||||||
echo " Install it before using the fleet, e.g. ${C}sudo apt-get install -y $transport${RESET}"
|
|
||||||
echo " (this does not affect ${C}mosaic claude${RESET} or the other single-runtime commands)."
|
|
||||||
}
|
|
||||||
|
|
||||||
installed_cli_version() {
|
installed_cli_version() {
|
||||||
local json
|
local json
|
||||||
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
|
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
|
||||||
@@ -634,175 +516,8 @@ install_next_cli_from_registry() {
|
|||||||
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
|
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 ────────────────────────────────────────────────────────────────
|
# ─── preflight ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ensure_node
|
|
||||||
require_cmd node
|
require_cmd node
|
||||||
require_cmd npm
|
require_cmd npm
|
||||||
|
|
||||||
@@ -967,7 +682,11 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
|||||||
ensure_monorepo
|
ensure_monorepo
|
||||||
install_cli_from_source
|
install_cli_from_source
|
||||||
|
|
||||||
ensure_prefix_on_path
|
# PATH check for npm prefix
|
||||||
|
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
|
||||||
|
warn "$PREFIX/bin is not on your PATH"
|
||||||
|
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
|
||||||
|
fi
|
||||||
elif is_next_registry_lane; then
|
elif is_next_registry_lane; then
|
||||||
info "Next mode — trying fast npm @next install from ${REGISTRY}…"
|
info "Next mode — trying fast npm @next install from ${REGISTRY}…"
|
||||||
if install_next_cli_from_registry; then
|
if install_next_cli_from_registry; then
|
||||||
@@ -980,7 +699,11 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
|||||||
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
|
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ensure_prefix_on_path
|
# PATH check for npm prefix
|
||||||
|
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
|
||||||
|
warn "$PREFIX/bin is not on your PATH"
|
||||||
|
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
if [[ -z "$LATEST" ]]; then
|
if [[ -z "$LATEST" ]]; then
|
||||||
warn "Could not reach registry at $REGISTRY — skipping npm CLI."
|
warn "Could not reach registry at $REGISTRY — skipping npm CLI."
|
||||||
@@ -998,7 +721,11 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
|||||||
ok "CLI is at or ahead of registry ($CURRENT ≥ $LATEST)."
|
ok "CLI is at or ahead of registry ($CURRENT ≥ $LATEST)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ensure_prefix_on_path
|
# PATH check for npm prefix
|
||||||
|
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
|
||||||
|
warn "$PREFIX/bin is not on your PATH"
|
||||||
|
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1143,11 +870,6 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
|||||||
ok "Done."
|
ok "Done."
|
||||||
fi
|
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
|
} # end main
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user