Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e06a47fac5 | ||
|
|
8199261caa | ||
|
|
57a2f2b40e | ||
|
|
93c1de51e1 |
+12
-14
@@ -115,19 +115,18 @@ gateway-backed agent catalog.
|
|||||||
|
|
||||||
### Normative requirements
|
### Normative requirements
|
||||||
|
|
||||||
| ID | Requirement |
|
| ID | Requirement |
|
||||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
|
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
|
||||||
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
|
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
|
||||||
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
|
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
|
||||||
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
|
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
|
||||||
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
|
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
|
||||||
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
|
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
|
||||||
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
|
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
|
||||||
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
|
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
|
||||||
| `FCM-REQ-09` | M1–M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
|
| `FCM-REQ-09` | M1–M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
|
||||||
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
|
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
|
||||||
| `FCM-REQ-11` | Fleet provisioning SHALL validate the fleet CLI and distinct runtimes requested by the roster against the exact PATH construction used by the runtime pane, through one shared implementation rather than the operator PATH or a parallel PATH model. Name resolution alone is insufficient: a resolved script's shebang interpreter SHALL also be reachable, and Node SHALL execute a side-effect-free version probe when it is that interpreter. `fleet install` and `install-systemd` SHALL fail before installation effects when a required executable is absent or unreachable. `fleet doctor` SHALL emit the same named checks as non-green evidence. Every runtime failure SHALL name the runtime, all requesting roster rows, the pane PATH searched, and an exact install command. |
|
|
||||||
|
|
||||||
### Acceptance criteria
|
### Acceptance criteria
|
||||||
|
|
||||||
@@ -139,7 +138,6 @@ gateway-backed agent catalog.
|
|||||||
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
|
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
|
||||||
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
|
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
|
||||||
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
|
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
|
||||||
9. `AC-FCM-09`: Red-first isolated tests create (a) a roster whose runtime exists on the operator PATH but is absent from the constructed pane PATH and (b) a greenfield pane where `mosaic` and a runtime resolve as Node-shebang scripts while Node is absent. They prove `fleet install` fails before effects, the launcher creates no doomed session, and `fleet doctor` reports named non-green checks. Diagnostics include the executable or runtime, all requesting rows, searched pane PATH, shebang dependency when present, and exact runtime install command; repeated rows are checked once per distinct runtime/effective pane path. Tests use temporary `--mosaic-home` state and fixture binaries, never host runtime mutation.
|
|
||||||
|
|
||||||
### M0 implementation gate
|
### M0 implementation gate
|
||||||
|
|
||||||
|
|||||||
@@ -59,28 +59,6 @@ valid allowed local data can move to `.env.local`; invalid legacy input is priva
|
|||||||
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
||||||
text, credentials, or other values.
|
text, credentials, or other values.
|
||||||
|
|
||||||
## Pane executable preflight
|
|
||||||
|
|
||||||
The fleet install, install-systemd, and doctor commands plus the session launcher use
|
|
||||||
**pane-runtime-path.sh** as the single pane-PATH implementation. Install inspects every distinct
|
|
||||||
roster runtime and effective MOSAIC_RUNTIME_BIN pair before creating holder identity, tool,
|
|
||||||
projection, or unit files. Doctor reports the same checks as JSON.
|
|
||||||
|
|
||||||
A resolved command is not automatically executable. The helper reads a script shebang, unwraps the
|
|
||||||
common “/usr/bin/env node” and “/usr/bin/env -S node …” forms, then resolves the declared command
|
|
||||||
against the pane PATH. When Node is the declared interpreter, the helper runs the side-effect-free
|
|
||||||
“node --version” probe. It does not run “mosaic --version”, whose startup update check can write cache
|
|
||||||
state. Native binaries have no PATH-resolved shebang dependency and retain their normal executable
|
|
||||||
check. Failures name the executable or runtime, requesting roster rows, searched pane PATH,
|
|
||||||
dependency, and runtime install command.
|
|
||||||
|
|
||||||
Supported runtime install commands are:
|
|
||||||
|
|
||||||
- **Claude:** curl -fsSL https://claude.ai/install.sh | bash
|
|
||||||
- **Codex:** npm install -g @openai/codex
|
|
||||||
- **OpenCode:** npm install -g opencode-ai
|
|
||||||
- **Pi:** npm install -g @earendil-works/pi-coding-agent
|
|
||||||
|
|
||||||
## Launch and stop behavior
|
## Launch and stop behavior
|
||||||
|
|
||||||
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# 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,71 +0,0 @@
|
|||||||
# #1256 — Fleet runtime preflight
|
|
||||||
|
|
||||||
**Agent:** tiny
|
|
||||||
|
|
||||||
**Branch:** `fix/1256-fleet-runtime-preflight` from `origin/next@476db12b92971634b67fd2057b7577ee5894e449`
|
|
||||||
|
|
||||||
**Issue:** `mosaicstack/stack#1256` blocker 1
|
|
||||||
|
|
||||||
**Adjacent PR:** `#1258` (`fix/1256-fleet-pane-path-node`) owns the bootstrapped-Node candidate and must remain a separate change
|
|
||||||
|
|
||||||
**Budget:** 30K-token soft cap; one bounded implementation lane
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make fleet provisioning fail before installation effects when the roster names a runtime binary absent from the exact PATH the tmux pane will receive. Make `mosaic fleet doctor` report the same named runtime check. Diagnostics must name the runtime, every requesting roster row, the pane PATH searched, and an exact install command.
|
|
||||||
|
|
||||||
For Pi the exact command is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
npm install -g @earendil-works/pi-coding-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- TDD: add the failing behavior test and capture RED before implementation.
|
|
||||||
- Runtime resolution uses the launcher's pane-PATH construction; a second PATH model is forbidden.
|
|
||||||
- Operator PATH is non-authoritative and must not cause a false pass.
|
|
||||||
- Tests use an isolated `--mosaic-home`/temporary HOME and never mutate host runtime binaries.
|
|
||||||
- No install, removal, or binary-resolution changes on sb-it-1-dt.
|
|
||||||
- PR targets `next` and requires a reviewer other than fred.
|
|
||||||
- Commit identity is `tiny <[email protected]>`.
|
|
||||||
- #1258's Node candidate is a dependency/adjacent change, never reimplemented here.
|
|
||||||
|
|
||||||
## Planned seam
|
|
||||||
|
|
||||||
1. Factor the shell pane-path builder/resolver into one sourceable and executable fleet helper.
|
|
||||||
2. Have `start-agent-session.sh` source that helper, preserving one definition of the pane PATH.
|
|
||||||
3. Have the TypeScript fleet command invoke the same helper under the unit-equivalent clean launcher environment.
|
|
||||||
4. Group roster rows by distinct runtime and effective pane PATH, then report requesting row names.
|
|
||||||
5. Run the preflight before `installFleet` performs any write.
|
|
||||||
6. Add the named result to roster-v2 `fleet doctor` JSON and set a failing exit when a runtime is absent.
|
|
||||||
7. Install/copy the helper alongside `start-agent-session.sh` and update framework manifest/docs as required.
|
|
||||||
|
|
||||||
This seam overlaps #1258 only at the location of the existing shell function. Development may use #1258 as a local dependency, but the final PR diff must exclude #1258's separately owned Node change after that PR lands or after an agreed rebase order.
|
|
||||||
|
|
||||||
## Acceptance evidence
|
|
||||||
|
|
||||||
| Requirement | Evidence |
|
|
||||||
|---|---|
|
|
||||||
| Missing Pi blocks install before effects | isolated CLI test: nonzero + no installed files/runner effects |
|
|
||||||
| Operator PATH cannot create false green | test puts Pi only on operator PATH and omits it from constructed pane PATH |
|
|
||||||
| Exact pane PATH reused | launcher and CLI call one shared shell helper; contract test exercises both |
|
|
||||||
| Actionable diagnosis | runtime + roster rows + searched PATH + exact install command assertions |
|
|
||||||
| Distinct runtimes | repeated rows produce one check with all row names |
|
|
||||||
| Doctor reports named check | JSON assertion + nonzero exit for missing runtime |
|
|
||||||
| Present runtime passes | isolated pane-path fixture with executable binary |
|
|
||||||
| No host mutation | tests use temporary HOME/Mosaic home and fixture binaries only |
|
|
||||||
| Baseline safety | focused tests, package typecheck/lint/format, full relevant suite, CI |
|
|
||||||
|
|
||||||
## Progress log
|
|
||||||
|
|
||||||
- 2026-08-16: Dispatch received from fred; issue #1256 and PR #1258 measured.
|
|
||||||
- 2026-08-16: Fresh clone created under `~/agent-work/tiny-fleet-runtime-preflight`; local Git identity pinned to tiny so retired global `mos-dt-0` identity cannot win.
|
|
||||||
- 2026-08-16: Design inspection found the pane PATH exists only inside `start-agent-session.sh`; the right seam is a shared shell helper rather than a parallel TypeScript reconstruction.
|
|
||||||
- 2026-08-16: RED measured on `origin/next@476db12b`: focused `fleet-roster-v2-dispatch.spec.ts` ran 11 tests; the new case failed because install returned success, wrote units for two agents, and emitted no `runtime=pi` diagnosis while Pi existed only on operator PATH.
|
|
||||||
- 2026-08-16: Factored pane home/PATH/resolution into sourceable and executable `pane-runtime-path.sh`; install invokes it before the first effect, doctor emits the same named checks, and the launcher sources it.
|
|
||||||
- 2026-08-16: Fred/rhodey review exposed the #1241 name-resolution blind spot: `mosaic` can resolve while its `#!/usr/bin/env node` interpreter cannot. Measurement confirmed every supported current Mosaic package shape is a Node-shebang script, but executing `mosaic --version` is not observational because CLI startup runs the cache-writing/network update checker before Commander handles the flag.
|
|
||||||
- 2026-08-16: Final executable check reads and unwraps direct and `/usr/bin/env` shebangs (including `env -S`), resolves the declared dependency against pane PATH, and runs only side-effect-free `node --version` when Node is declared. Native binaries do not inherit a permanent Node requirement. Install, doctor, and launcher share this implementation.
|
|
||||||
- 2026-08-16: Isolated greenfield fixture places resolved Mosaic and Pi Node-shebang scripts in pane-visible npm-global bin while using an empty system suffix; both checks become `unexecutable` with `dependency=node`, and install leaves holder/tools/units absent. No host binary or HOME is changed.
|
|
||||||
- 2026-08-16: GREEN evidence before #1258 rebase: focused install/doctor/preflight suites pass; `fleet.spec.ts` 209/209; full Vitest 87 files / 1,557 tests; launcher shell suite, typecheck, lint, build, and focused format check pass. Full framework-shell reaches an unrelated host-measurement drift in unchanged `invariant_r_unittest.py` (expected Pi 0.84.1, host resolves 0.84.2); no invariant was changed in this lane.
|
|
||||||
- 2026-08-16: Merge-order gate remains: `origin/next` is still `476db12b`; #1258 is unmerged at `6dc35e5`. Rebase after it lands, relocate its Node candidate into the helper with explicit provenance, rerun gates, then open the PR to `next` for an independent non-fred review.
|
|
||||||
@@ -51,12 +51,8 @@ See `docs/fleet/reference/generated-env-boundary.md` for the full contract.
|
|||||||
## Manual canary sequence
|
## Manual canary sequence
|
||||||
|
|
||||||
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
||||||
edit a generated projection. Before it writes any holder identity, tool, projection, or unit file,
|
edit a generated projection. `mosaic fleet install` validates the roster, installs the units and
|
||||||
`mosaic fleet install` validates the fleet CLI and every distinct roster runtime through the exact
|
helpers, and writes private roster-derived projections before any service is started.
|
||||||
pane PATH. The shared helper also unwraps `/usr/bin/env` shebangs, so a resolved Node script with no
|
|
||||||
pane-visible Node fails before effects. `mosaic fleet doctor` reports the same named executable
|
|
||||||
checks without mutation. After that preflight, install places the units and helpers and writes private
|
|
||||||
roster-derived projections before any service starts.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Canonical fleet-pane PATH construction and executable reachability checks.
|
|
||||||
#
|
|
||||||
# This file is both sourceable by start-agent-session.sh and executable by the
|
|
||||||
# fleet CLI preflight. Keep the pane PATH in one implementation: provisioning
|
|
||||||
# checks and the eventual pane must answer the same question.
|
|
||||||
|
|
||||||
mosaic_fleet_pane_home() {
|
|
||||||
local mosaic_home="$1"
|
|
||||||
local fallback_home="$2"
|
|
||||||
case "$mosaic_home" in
|
|
||||||
*/.config/mosaic) printf '%s' "${mosaic_home%/.config/mosaic}" ;;
|
|
||||||
*) printf '%s' "$fallback_home" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
mosaic_fleet_build_runtime_bin_prefix() {
|
|
||||||
local pane_home="$1"
|
|
||||||
local runtime_bin="${2:-}"
|
|
||||||
local candidates=()
|
|
||||||
if [ -n "$runtime_bin" ]; then candidates+=("$runtime_bin"); fi
|
|
||||||
if command -v npm >/dev/null 2>&1; then
|
|
||||||
local npm_prefix
|
|
||||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
|
||||||
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
|
||||||
fi
|
|
||||||
candidates+=("$pane_home/.npm-global/bin" "$pane_home/.local/bin")
|
|
||||||
|
|
||||||
local prefix="" dir
|
|
||||||
for dir in "${candidates[@]}"; do
|
|
||||||
[ -d "$dir" ] || continue
|
|
||||||
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
|
||||||
done
|
|
||||||
printf '%s' "$prefix"
|
|
||||||
}
|
|
||||||
|
|
||||||
mosaic_fleet_build_pane_path() {
|
|
||||||
local pane_home="$1"
|
|
||||||
local runtime_bin="${2:-}"
|
|
||||||
local system_path="${3:-/usr/local/bin:/usr/bin:/bin}"
|
|
||||||
local prefix
|
|
||||||
prefix=$(mosaic_fleet_build_runtime_bin_prefix "$pane_home" "$runtime_bin")
|
|
||||||
printf '%s' "${prefix:+${prefix}:}${system_path}"
|
|
||||||
}
|
|
||||||
|
|
||||||
mosaic_fleet_resolve_in_pane_path() {
|
|
||||||
local pane_path="$1"
|
|
||||||
local binary="$2"
|
|
||||||
PATH="$pane_path" command -v -- "$binary" 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# Sets executable evidence in MOSAIC_FLEET_EXECUTABLE_* and returns nonzero when
|
|
||||||
# a resolved script's shebang interpreter cannot run in the pane. Native/ELF
|
|
||||||
# binaries have no PATH-resolved interpreter dependency and pass the executable
|
|
||||||
# bit check. Node receives an additional side-effect-free `node --version`
|
|
||||||
# execution check; invoking `mosaic --version` itself is intentionally avoided
|
|
||||||
# because Mosaic performs a cache-writing/network update check at CLI startup.
|
|
||||||
mosaic_fleet_check_resolved_executable() {
|
|
||||||
local pane_path="$1"
|
|
||||||
local resolved="$2"
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY=""
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_PROBE=""
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_EXIT=""
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT=""
|
|
||||||
|
|
||||||
[ -x "$resolved" ] || {
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="resolved path is not executable"
|
|
||||||
return 70
|
|
||||||
}
|
|
||||||
|
|
||||||
local magic=""
|
|
||||||
IFS= read -r -n 2 magic < "$resolved" || true
|
|
||||||
[ "$magic" = '#!' ] || return 0
|
|
||||||
|
|
||||||
local shebang
|
|
||||||
IFS= read -r shebang < "$resolved" || true
|
|
||||||
shebang=${shebang%$'\r'}
|
|
||||||
shebang=${shebang#\#!}
|
|
||||||
local parts=()
|
|
||||||
read -r -a parts <<< "$shebang"
|
|
||||||
local interpreter="${parts[0]:-}"
|
|
||||||
[[ "$interpreter" = /* ]] && [ -x "$interpreter" ] || {
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$interpreter"
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="shebang interpreter is absent or not executable"
|
|
||||||
return 70
|
|
||||||
}
|
|
||||||
|
|
||||||
local dependency="$interpreter"
|
|
||||||
local dependency_path="$interpreter"
|
|
||||||
if [ "${interpreter##*/}" = env ]; then
|
|
||||||
local index=1
|
|
||||||
if [ "${parts[$index]:-}" = -S ]; then index=$((index + 1)); fi
|
|
||||||
dependency="${parts[$index]:-}"
|
|
||||||
if [ -z "$dependency" ] || [[ "$dependency" = -* ]]; then
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$dependency"
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="unsupported env shebang"
|
|
||||||
return 70
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$dependency"
|
|
||||||
if [ "${dependency##*/}" = node ]; then
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_PROBE="node --version"
|
|
||||||
fi
|
|
||||||
if [ "${interpreter##*/}" = env ]; then
|
|
||||||
if ! dependency_path=$(mosaic_fleet_resolve_in_pane_path "$pane_path" "$dependency"); then
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="shebang command is not on the pane PATH"
|
|
||||||
return 70
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${dependency##*/}" = node ]; then
|
|
||||||
if MOSAIC_FLEET_EXECUTABLE_OUTPUT=$(PATH="$pane_path" "$dependency_path" --version 2>&1); then
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_EXIT=0
|
|
||||||
else
|
|
||||||
MOSAIC_FLEET_EXECUTABLE_EXIT=$?
|
|
||||||
return 70
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
mosaic_fleet_runtime_path_main() {
|
|
||||||
local mosaic_home=""
|
|
||||||
local runtime_bin=""
|
|
||||||
local system_path="/usr/local/bin:/usr/bin:/bin"
|
|
||||||
local binary=""
|
|
||||||
local check_executable=0
|
|
||||||
|
|
||||||
while [ "$#" -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--mosaic-home)
|
|
||||||
[ "$#" -ge 2 ] || return 64
|
|
||||||
mosaic_home="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--runtime-bin)
|
|
||||||
[ "$#" -ge 2 ] || return 64
|
|
||||||
runtime_bin="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--binary)
|
|
||||||
[ "$#" -ge 2 ] || return 64
|
|
||||||
binary="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--check-executable)
|
|
||||||
check_executable=1
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
# Test seam for measuring a greenfield host with no system Node. The
|
|
||||||
# launcher and production CLI omit it and retain the fixed system suffix.
|
|
||||||
--system-path)
|
|
||||||
[ "$#" -ge 2 ] || return 64
|
|
||||||
system_path="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*) return 64 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[ -n "$mosaic_home" ] && [ -n "$binary" ] || return 64
|
|
||||||
local pane_home pane_path resolved
|
|
||||||
pane_home=$(mosaic_fleet_pane_home "$mosaic_home" "${HOME:-}")
|
|
||||||
# npm config is HOME-sensitive. Pin it to the derived pane home before asking
|
|
||||||
# for its prefix so an operator's unrelated npmrc cannot influence preflight.
|
|
||||||
HOME=$pane_home
|
|
||||||
export HOME
|
|
||||||
pane_path=$(mosaic_fleet_build_pane_path "$pane_home" "$runtime_bin" "$system_path")
|
|
||||||
if ! resolved=$(mosaic_fleet_resolve_in_pane_path "$pane_path" "$binary"); then
|
|
||||||
printf 'pane_path\0%s\0status\0missing\0binary_path\0\0dependency\0\0probe_command\0\0probe_exit\0\0probe_output\0\0' \
|
|
||||||
"$pane_path"
|
|
||||||
return 69
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$check_executable" -eq 1 ]; then
|
|
||||||
if mosaic_fleet_check_resolved_executable "$pane_path" "$resolved"; then
|
|
||||||
printf 'pane_path\0%s\0status\0present\0binary_path\0%s\0dependency\0%s\0probe_command\0%s\0probe_exit\0%s\0probe_output\0%s\0' \
|
|
||||||
"$pane_path" "$resolved" "$MOSAIC_FLEET_EXECUTABLE_DEPENDENCY" \
|
|
||||||
"$MOSAIC_FLEET_EXECUTABLE_PROBE" "$MOSAIC_FLEET_EXECUTABLE_EXIT" \
|
|
||||||
"$MOSAIC_FLEET_EXECUTABLE_OUTPUT"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
printf 'pane_path\0%s\0status\0unexecutable\0binary_path\0%s\0dependency\0%s\0probe_command\0%s\0probe_exit\0%s\0probe_output\0%s\0' \
|
|
||||||
"$pane_path" "$resolved" "$MOSAIC_FLEET_EXECUTABLE_DEPENDENCY" \
|
|
||||||
"$MOSAIC_FLEET_EXECUTABLE_PROBE" "$MOSAIC_FLEET_EXECUTABLE_EXIT" \
|
|
||||||
"$MOSAIC_FLEET_EXECUTABLE_OUTPUT"
|
|
||||||
return 70
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf 'pane_path\0%s\0status\0present\0binary_path\0%s\0dependency\0\0probe_command\0\0probe_exit\0\0probe_output\0\0' \
|
|
||||||
"$pane_path" "$resolved"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
|
||||||
set -euo pipefail
|
|
||||||
mosaic_fleet_runtime_path_main "$@"
|
|
||||||
fi
|
|
||||||
@@ -258,22 +258,46 @@ if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
||||||
# that home. The provisioning preflight executes this same helper under the
|
# that home. Derive the pane home from the canonical path when available so an
|
||||||
# unit's clean launcher environment, so operator PATH cannot produce a false
|
# inherited pane/session HOME cannot become runtime authority.
|
||||||
# green result for a binary the pane will never see.
|
PANE_HOME=$HOME
|
||||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
case "$MOSAIC_HOME" in
|
||||||
# shellcheck source=pane-runtime-path.sh
|
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
|
||||||
. "$SCRIPT_DIR/pane-runtime-path.sh"
|
esac
|
||||||
PANE_HOME=$(mosaic_fleet_pane_home "$MOSAIC_HOME" "$HOME")
|
|
||||||
PANE_PATH=$(mosaic_fleet_build_pane_path "$PANE_HOME" "$MOSAIC_RUNTIME_BIN")
|
|
||||||
|
|
||||||
# #1241/#1256. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a
|
_build_runtime_bin_prefix() {
|
||||||
# cleared environment. Resolve both names and validate any shebang interpreter
|
local candidates=()
|
||||||
# here, before an effect, where the failure remains attributable. Name
|
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
||||||
# resolution alone is insufficient: an `#!/usr/bin/env node` script resolves
|
if command -v npm >/dev/null 2>&1; then
|
||||||
# even when the pane cannot execute it because Node is absent.
|
local npm_prefix
|
||||||
|
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||||
|
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
||||||
|
fi
|
||||||
|
candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")
|
||||||
|
|
||||||
|
local prefix="" dir
|
||||||
|
for dir in "${candidates[@]}"; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
||||||
|
done
|
||||||
|
printf '%s' "$prefix"
|
||||||
|
}
|
||||||
|
|
||||||
|
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
||||||
|
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
||||||
|
|
||||||
|
# #1241. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a cleared
|
||||||
|
# environment. A binary missing from *that* path is a pane that dies in under a
|
||||||
|
# second, inside a session nobody is attached to, with its diagnostic scrolled
|
||||||
|
# into a pane tmux then destroys. Resolve both here, before any effect, where
|
||||||
|
# the failure is still attributable to the thing that caused it.
|
||||||
|
#
|
||||||
|
# `mosaic yolo <runtime>` runs checkRuntime(runtime) and the binary it looks for
|
||||||
|
# is named exactly like the runtime, so resolving the runtime name is the same
|
||||||
|
# question the pane will ask a moment later — asked while an operator can still
|
||||||
|
# see the answer.
|
||||||
_resolve_in_pane_path() {
|
_resolve_in_pane_path() {
|
||||||
mosaic_fleet_resolve_in_pane_path "$PANE_PATH" "$1"
|
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
||||||
@@ -288,15 +312,8 @@ fail_launch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
||||||
resolved_binary=$(_resolve_in_pane_path "$required_binary") ||
|
_resolve_in_pane_path "$required_binary" >/dev/null ||
|
||||||
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
|
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
|
||||||
if mosaic_fleet_check_resolved_executable "$PANE_PATH" "$resolved_binary"; then
|
|
||||||
continue
|
|
||||||
else
|
|
||||||
executable_exit=$?
|
|
||||||
fi
|
|
||||||
fail_launch unexecutable-binary \
|
|
||||||
"'${required_binary}' resolves to '${resolved_binary}' but dependency '${MOSAIC_FLEET_EXECUTABLE_DEPENDENCY:-unknown}' is not executable on the pane PATH (${PANE_PATH}); check_exit=${executable_exit} detail=${MOSAIC_FLEET_EXECUTABLE_OUTPUT:-unavailable}"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
_ensure_claude_workdir_trusted() {
|
_ensure_claude_workdir_trusted() {
|
||||||
|
|||||||
@@ -484,27 +484,6 @@ assert_missing_pane_binary_rejected() {
|
|||||||
assert_missing_pane_binary_rejected mosaic
|
assert_missing_pane_binary_rejected mosaic
|
||||||
assert_missing_pane_binary_rejected pi
|
assert_missing_pane_binary_rejected pi
|
||||||
|
|
||||||
# #1256. Name resolution is not executable reachability. A script can resolve
|
|
||||||
# while its /usr/bin/env shebang command is absent from PANE_PATH; reject that
|
|
||||||
# before tmux creates the doomed session.
|
|
||||||
: > "$TMUX_CALLS"
|
|
||||||
HOME_UNEXECUTABLE="$ROOT/unexecutable-shebang"
|
|
||||||
write_generated "$HOME_UNEXECUTABLE" "coder-unexecutable"
|
|
||||||
rm -f "$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
|
||||||
printf '#!/usr/bin/env mosaic-test-absent-interpreter\n' > \
|
|
||||||
"$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
|
||||||
chmod +x "$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
|
||||||
if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_UNEXECUTABLE" coder-unexecutable 2>&1); then
|
|
||||||
fail "launcher accepted a resolved mosaic script with an absent shebang command"
|
|
||||||
fi
|
|
||||||
echo "$output" | grep -qF 'code=unexecutable-binary' || \
|
|
||||||
fail "unexecutable shebang diagnostic missing: $output"
|
|
||||||
echo "$output" | grep -qF 'mosaic-test-absent-interpreter' || \
|
|
||||||
fail "unexecutable shebang diagnostic did not name the missing dependency"
|
|
||||||
if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then
|
|
||||||
fail "launcher created a session after its shebang dependency check failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# #1241. tmux destroys a session when its pane command exits, so no pane PID a
|
# #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
|
# 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
|
# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it
|
||||||
|
|||||||
@@ -39,3 +39,20 @@ 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/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
|
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, resolve } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
||||||
import {
|
|
||||||
type FleetRuntimeProbeResult,
|
|
||||||
type FleetRuntimeProbeRunner,
|
|
||||||
} from '../fleet/fleet-runtime-preflight.js';
|
|
||||||
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||||
|
|
||||||
const roster = `
|
const roster = `
|
||||||
@@ -69,15 +65,12 @@ function program(
|
|||||||
mosaicHome: string,
|
mosaicHome: string,
|
||||||
runner: FleetCommandDeps['runner'],
|
runner: FleetCommandDeps['runner'],
|
||||||
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
||||||
runtimeProbeRunner: FleetRuntimeProbeRunner = runtimeProbe('present'),
|
|
||||||
): Command {
|
): Command {
|
||||||
const result = new Command();
|
const result = new Command();
|
||||||
result.exitOverride();
|
result.exitOverride();
|
||||||
registerFleetCommand(result, {
|
registerFleetCommand(result, {
|
||||||
mosaicHome,
|
mosaicHome,
|
||||||
runner,
|
runner,
|
||||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
|
||||||
runtimeProbeRunner,
|
|
||||||
reconcileDeps: {
|
reconcileDeps: {
|
||||||
homeDirectory: '/home/mosaic',
|
homeDirectory: '/home/mosaic',
|
||||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||||
@@ -90,24 +83,6 @@ function program(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeProbe(status: 'present' | 'missing'): FleetRuntimeProbeRunner {
|
|
||||||
return async (_command, args): Promise<FleetRuntimeProbeResult> => {
|
|
||||||
const binaryFlag = args.indexOf('--binary');
|
|
||||||
const binary = binaryFlag >= 0 ? args[binaryFlag + 1] : undefined;
|
|
||||||
const effectiveStatus = binary === 'mosaic' ? 'present' : status;
|
|
||||||
return {
|
|
||||||
stdout:
|
|
||||||
`pane_path\u0000/fixture/runtime-bin:/usr/bin:/bin\u0000status\u0000${effectiveStatus}\u0000` +
|
|
||||||
`binary_path\u0000${effectiveStatus === 'present' ? `/fixture/runtime-bin/${binary ?? 'unknown'}` : ''}\u0000` +
|
|
||||||
`dependency\u0000${effectiveStatus === 'present' ? 'node' : ''}\u0000` +
|
|
||||||
`probe_command\u0000${effectiveStatus === 'present' ? 'node --version' : ''}\u0000` +
|
|
||||||
`probe_exit\u0000${effectiveStatus === 'present' ? '0' : ''}\u0000probe_output\u0000\u0000`,
|
|
||||||
stderr: '',
|
|
||||||
exitCode: effectiveStatus === 'present' ? 0 : 69,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function capture(): string[] {
|
function capture(): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||||
@@ -177,64 +152,13 @@ describe('mosaic fleet reconciler commands', (): void => {
|
|||||||
|
|
||||||
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
||||||
{ applied: false, lifecycle: 'not-applied' },
|
{ applied: false, lifecycle: 'not-applied' },
|
||||||
{
|
{ applied: false, lifecycle: 'not-applied' },
|
||||||
applied: false,
|
|
||||||
lifecycle: 'not-applied',
|
|
||||||
checks: {
|
|
||||||
fleetCliExecutable: [
|
|
||||||
{
|
|
||||||
check: 'fleet-cli-executable',
|
|
||||||
status: 'ok',
|
|
||||||
requestedBy: ['coder0'],
|
|
||||||
dependency: 'node',
|
|
||||||
probeCommand: 'node --version',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
fleetRuntimeAvailability: [
|
|
||||||
{
|
|
||||||
check: 'fleet-runtime-available',
|
|
||||||
runtime: 'pi',
|
|
||||||
status: 'ok',
|
|
||||||
requestedBy: ['coder0'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a missing roster runtime as a named non-green doctor check', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const lines = capture();
|
|
||||||
|
|
||||||
await program(home, ownedRunner([]), {}, runtimeProbe('missing')).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'doctor',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
|
||||||
applied: false,
|
|
||||||
checks: {
|
|
||||||
fleetRuntimeAvailability: [
|
|
||||||
{
|
|
||||||
check: 'fleet-runtime-available',
|
|
||||||
runtime: 'pi',
|
|
||||||
status: 'missing',
|
|
||||||
requestedBy: ['coder0'],
|
|
||||||
panePath: '/fixture/runtime-bin:/usr/bin:/bin',
|
|
||||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['start', 'stop', 'restart'] as const)(
|
it.each(['start', 'stop', 'restart'] as const)(
|
||||||
'uses exact roster-owned systemd targeting for %s',
|
'uses exact roster-owned systemd targeting for %s',
|
||||||
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
||||||
|
|||||||
@@ -8,18 +8,10 @@ import {
|
|||||||
type FleetReconcileCommand,
|
type FleetReconcileCommand,
|
||||||
type FleetReconcileDeps,
|
type FleetReconcileDeps,
|
||||||
} from '../fleet/fleet-reconciler.js';
|
} from '../fleet/fleet-reconciler.js';
|
||||||
import {
|
|
||||||
inspectFleetRuntimeAvailability,
|
|
||||||
type FleetRuntimeInspection,
|
|
||||||
type FleetRuntimePreflightCheck,
|
|
||||||
type FleetRuntimeProbeRunner,
|
|
||||||
} from '../fleet/fleet-runtime-preflight.js';
|
|
||||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||||
|
|
||||||
export interface FleetReconcilerCommandDeps {
|
export interface FleetReconcilerCommandDeps {
|
||||||
readonly runner: CommandRunner;
|
readonly runner: CommandRunner;
|
||||||
readonly runtimeProbeRunner?: FleetRuntimeProbeRunner;
|
|
||||||
readonly frameworkRoot?: string;
|
|
||||||
readonly mosaicHome?: string;
|
readonly mosaicHome?: string;
|
||||||
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
||||||
}
|
}
|
||||||
@@ -79,10 +71,6 @@ export async function executeReconcilerCommand(
|
|||||||
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
||||||
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
||||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||||
const runtimeInspection =
|
|
||||||
operation === 'doctor'
|
|
||||||
? await inspectRuntimeAvailability(roster.agents, mosaicHome, deps)
|
|
||||||
: undefined;
|
|
||||||
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
||||||
const expectedGeneration = mutating
|
const expectedGeneration = mutating
|
||||||
? parseExpectedGeneration(opts.expectedGeneration)
|
? parseExpectedGeneration(opts.expectedGeneration)
|
||||||
@@ -102,31 +90,8 @@ export async function executeReconcilerCommand(
|
|||||||
...(deps.reconcileDeps ?? {}),
|
...(deps.reconcileDeps ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
printJson(operation === 'doctor' ? { ...result, checks: runtimeInspection } : result);
|
printJson(result);
|
||||||
const executableFailure =
|
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
|
||||||
runtimeInspection !== undefined &&
|
|
||||||
[...runtimeInspection.fleetCliExecutable, ...runtimeInspection.fleetRuntimeAvailability].some(
|
|
||||||
(check: FleetRuntimePreflightCheck): boolean => check.status !== 'ok',
|
|
||||||
);
|
|
||||||
process.exitCode =
|
|
||||||
result.recovery === undefined && result.cleanup === undefined && !executableFailure ? 0 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inspectRuntimeAvailability(
|
|
||||||
agents: readonly { readonly name: string; readonly runtime: string }[],
|
|
||||||
mosaicHome: string,
|
|
||||||
deps: FleetReconcilerCommandDeps,
|
|
||||||
): Promise<FleetRuntimeInspection> {
|
|
||||||
if (deps.frameworkRoot === undefined || deps.runtimeProbeRunner === undefined) {
|
|
||||||
throw new Error('Fleet doctor runtime preflight dependencies are unavailable.');
|
|
||||||
}
|
|
||||||
return inspectFleetRuntimeAvailability({
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
|
||||||
helperPath: join(deps.frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'),
|
|
||||||
agents,
|
|
||||||
runner: deps.runtimeProbeRunner,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { FleetRuntimeProbeRunner } from '../fleet/fleet-runtime-preflight.js';
|
|
||||||
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +69,6 @@ agents:
|
|||||||
let tempHome: string | undefined;
|
let tempHome: string | undefined;
|
||||||
const savedHome = process.env.HOME;
|
const savedHome = process.env.HOME;
|
||||||
const savedMosaicHome = process.env.MOSAIC_HOME;
|
const savedMosaicHome = process.env.MOSAIC_HOME;
|
||||||
const savedPath = process.env.PATH;
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
afterEach(async (): Promise<void> => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -79,8 +77,6 @@ afterEach(async (): Promise<void> => {
|
|||||||
else process.env.HOME = savedHome;
|
else process.env.HOME = savedHome;
|
||||||
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
||||||
else process.env.MOSAIC_HOME = savedMosaicHome;
|
else process.env.MOSAIC_HOME = savedMosaicHome;
|
||||||
if (savedPath === undefined) delete process.env.PATH;
|
|
||||||
else process.env.PATH = savedPath;
|
|
||||||
if (tempHome) await rm(tempHome, { recursive: true, force: true });
|
if (tempHome) await rm(tempHome, { recursive: true, force: true });
|
||||||
tempHome = undefined;
|
tempHome = undefined;
|
||||||
});
|
});
|
||||||
@@ -89,7 +85,7 @@ afterEach(async (): Promise<void> => {
|
|||||||
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
||||||
* anything has been installed, applied or started.
|
* anything has been installed, applied or started.
|
||||||
*/
|
*/
|
||||||
async function v2Home(options: { withPaneRuntime?: boolean } = {}): Promise<string> {
|
async function v2Home(): Promise<string> {
|
||||||
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
||||||
process.env.HOME = tempHome;
|
process.env.HOME = tempHome;
|
||||||
delete process.env.MOSAIC_HOME;
|
delete process.env.MOSAIC_HOME;
|
||||||
@@ -101,12 +97,6 @@ async function v2Home(options: { withPaneRuntime?: boolean } = {}): Promise<stri
|
|||||||
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
const runtimeDir = join(tempHome, '.npm-global', 'bin');
|
|
||||||
await mkdir(runtimeDir, { recursive: true });
|
|
||||||
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
||||||
if (options.withPaneRuntime !== false) {
|
|
||||||
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
||||||
}
|
|
||||||
return mosaicHome;
|
return mosaicHome;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,17 +113,10 @@ const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult>
|
|||||||
return { stdout: '', stderr: '', exitCode: 1 };
|
return { stdout: '', stderr: '', exitCode: 1 };
|
||||||
};
|
};
|
||||||
|
|
||||||
function program(
|
function program(runner: CommandRunner = greenfieldRunner): Command {
|
||||||
runner: CommandRunner = greenfieldRunner,
|
|
||||||
runtimeProbeRunner?: FleetRuntimeProbeRunner,
|
|
||||||
): Command {
|
|
||||||
const result = new Command();
|
const result = new Command();
|
||||||
result.exitOverride();
|
result.exitOverride();
|
||||||
registerFleetCommand(result, {
|
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
|
||||||
runner,
|
|
||||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
|
||||||
...(runtimeProbeRunner === undefined ? {} : { runtimeProbeRunner }),
|
|
||||||
});
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,93 +166,6 @@ describe('mosaic fleet ps — roster v2', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('mosaic fleet install — roster v2', (): void => {
|
describe('mosaic fleet install — roster v2', (): void => {
|
||||||
it('rejects a roster runtime missing from the pane PATH before installing any files', async (): Promise<void> => {
|
|
||||||
const mosaicHome = await v2Home({ withPaneRuntime: false });
|
|
||||||
const operatorBin = join(tempHome!, 'operator-bin');
|
|
||||||
await mkdir(operatorBin, { recursive: true });
|
|
||||||
await writeFile(join(operatorBin, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
||||||
process.env.PATH = `${operatorBin}:${savedPath ?? '/usr/bin:/bin'}`;
|
|
||||||
|
|
||||||
let message = '';
|
|
||||||
try {
|
|
||||||
await program().parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'--mosaic-home',
|
|
||||||
mosaicHome,
|
|
||||||
'install',
|
|
||||||
'--no-enable',
|
|
||||||
]);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
message = error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(message).toContain('runtime=pi');
|
|
||||||
expect(message).toContain('requested_by=coder0,coder1');
|
|
||||||
expect(message).toContain('pane_path=');
|
|
||||||
expect(message).toContain('npm install -g @earendil-works/pi-coding-agent');
|
|
||||||
expect(message).not.toContain(operatorBin);
|
|
||||||
expect(
|
|
||||||
await exists(join(tempHome!, '.config', 'systemd', 'user', '[email protected]')),
|
|
||||||
).toBe(false);
|
|
||||||
expect(await exists(join(mosaicHome, 'fleet', 'holder.id'))).toBe(false);
|
|
||||||
expect(await exists(join(mosaicHome, 'tools'))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects resolved Node-shebang commands when Node is absent from the pane PATH', async (): Promise<void> => {
|
|
||||||
const mosaicHome = await v2Home();
|
|
||||||
const runtimeDir = join(tempHome!, '.npm-global', 'bin');
|
|
||||||
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
|
||||||
await writeFile(join(runtimeDir, 'mosaic'), nodeScript, { mode: 0o755 });
|
|
||||||
await writeFile(join(runtimeDir, 'pi'), nodeScript, { mode: 0o755 });
|
|
||||||
await writeFile(join(tempHome!, '.npmrc'), `prefix=${join(tempHome!, 'absent-prefix')}\n`);
|
|
||||||
const isolatedSystemPath = join(tempHome!, 'system-bin');
|
|
||||||
await mkdir(isolatedSystemPath, { recursive: true });
|
|
||||||
const isolatedProbeRunner: FleetRuntimeProbeRunner = async (
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
): Promise<CommandResult> =>
|
|
||||||
new Promise((settle) => {
|
|
||||||
const child = execFile(
|
|
||||||
command,
|
|
||||||
[...args, '--system-path', isolatedSystemPath],
|
|
||||||
{ encoding: 'utf8' },
|
|
||||||
(error, stdout, stderr) => {
|
|
||||||
settle({
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
exitCode: child.exitCode ?? (error === null ? 0 : 1),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
let message = '';
|
|
||||||
try {
|
|
||||||
await program(greenfieldRunner, isolatedProbeRunner).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'--mosaic-home',
|
|
||||||
mosaicHome,
|
|
||||||
'install',
|
|
||||||
'--no-enable',
|
|
||||||
]);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
message = error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(message).toContain('check=fleet-cli-executable');
|
|
||||||
expect(message).toContain('binary=mosaic');
|
|
||||||
expect(message).toContain('dependency=node');
|
|
||||||
expect(message).toContain('check=fleet-runtime-available');
|
|
||||||
expect(message).toContain('runtime=pi');
|
|
||||||
expect(message).not.toContain('/usr/bin');
|
|
||||||
expect(await exists(join(mosaicHome, 'fleet', 'holder.id'))).toBe(false);
|
|
||||||
expect(await exists(join(mosaicHome, 'tools'))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('places the tool files and unit templates', async (): Promise<void> => {
|
it('places the tool files and unit templates', async (): Promise<void> => {
|
||||||
const mosaicHome = await v2Home();
|
const mosaicHome = await v2Home();
|
||||||
capture();
|
capture();
|
||||||
@@ -287,11 +183,9 @@ describe('mosaic fleet install — roster v2', (): void => {
|
|||||||
]) {
|
]) {
|
||||||
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
||||||
}
|
}
|
||||||
for (const tool of ['start-agent-session.sh', 'pane-runtime-path.sh']) {
|
const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
|
||||||
const toolPath = join(mosaicHome, 'tools', 'fleet', tool);
|
expect(await exists(launcher)).toBe(true);
|
||||||
expect(await exists(toolPath)).toBe(true);
|
expect((await stat(launcher)).mode & 0o777).toBe(0o755);
|
||||||
expect((await stat(toolPath)).mode & 0o777).toBe(0o755);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
||||||
|
|||||||
@@ -1277,10 +1277,6 @@ describe('fleet command construction', () => {
|
|||||||
const home = await tempDir();
|
const home = await tempDir();
|
||||||
process.env.HOME = home;
|
process.env.HOME = home;
|
||||||
delete process.env.MOSAIC_HOME;
|
delete process.env.MOSAIC_HOME;
|
||||||
const runtimeDir = join(home, '.npm-global', 'bin');
|
|
||||||
await mkdir(runtimeDir, { recursive: true });
|
|
||||||
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
||||||
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
||||||
const mosaicHome = join(home, '.config', 'mosaic');
|
const mosaicHome = join(home, '.config', 'mosaic');
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.exitOverride();
|
program.exitOverride();
|
||||||
@@ -1319,10 +1315,6 @@ describe('fleet command construction', () => {
|
|||||||
const originalHome = process.env.HOME;
|
const originalHome = process.env.HOME;
|
||||||
const home = await tempDir();
|
const home = await tempDir();
|
||||||
process.env.HOME = home;
|
process.env.HOME = home;
|
||||||
const runtimeDir = join(home, '.npm-global', 'bin');
|
|
||||||
await mkdir(runtimeDir, { recursive: true });
|
|
||||||
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
||||||
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
||||||
const mosaicHome = join(home, '.config', 'mosaic');
|
const mosaicHome = join(home, '.config', 'mosaic');
|
||||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||||
const fleetDir = join(mosaicHome, 'fleet');
|
const fleetDir = join(mosaicHome, 'fleet');
|
||||||
|
|||||||
@@ -60,12 +60,6 @@ import {
|
|||||||
writeAgentEnvironmentProjection,
|
writeAgentEnvironmentProjection,
|
||||||
writeManagedFleetRoster,
|
writeManagedFleetRoster,
|
||||||
} from '../fleet/generated-env-boundary.js';
|
} from '../fleet/generated-env-boundary.js';
|
||||||
import {
|
|
||||||
assertFleetRuntimeAvailability,
|
|
||||||
FleetRuntimePreflightError,
|
|
||||||
inspectFleetRuntimeAvailability,
|
|
||||||
type FleetRuntimeProbeRunner,
|
|
||||||
} from '../fleet/fleet-runtime-preflight.js';
|
|
||||||
import { registerFleetBacklogCommand } from './fleet-backlog.js';
|
import { registerFleetBacklogCommand } from './fleet-backlog.js';
|
||||||
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
||||||
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
||||||
@@ -95,8 +89,6 @@ export type SleepFn = (ms: number) => Promise<void>;
|
|||||||
|
|
||||||
export interface FleetCommandDeps {
|
export interface FleetCommandDeps {
|
||||||
runner?: CommandRunner;
|
runner?: CommandRunner;
|
||||||
/** Executes the pane-PATH helper under a clean launcher environment. */
|
|
||||||
runtimeProbeRunner?: FleetRuntimeProbeRunner;
|
|
||||||
/** Injectable interactive runner for commands needing inherited TTY (e.g., `tmux attach`). */
|
/** Injectable interactive runner for commands needing inherited TTY (e.g., `tmux attach`). */
|
||||||
interactiveRunner?: InteractiveRunner;
|
interactiveRunner?: InteractiveRunner;
|
||||||
/**
|
/**
|
||||||
@@ -1437,10 +1429,6 @@ export function isSendAccepted(capturedOutput: string): SendVerifyResult {
|
|||||||
|
|
||||||
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
||||||
const runner = deps.runner ?? runCommand;
|
const runner = deps.runner ?? runCommand;
|
||||||
const runtimeProbeRunner: FleetRuntimeProbeRunner =
|
|
||||||
deps.runtimeProbeRunner ??
|
|
||||||
(async (command: string, args: readonly string[]): Promise<CommandResult> =>
|
|
||||||
runCommand(command, [...args]));
|
|
||||||
const sleepFn = deps.sleepFn ?? defaultSleep;
|
const sleepFn = deps.sleepFn ?? defaultSleep;
|
||||||
const paths = resolveFleetPaths(deps.mosaicHome);
|
const paths = resolveFleetPaths(deps.mosaicHome);
|
||||||
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
||||||
@@ -1539,7 +1527,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.description('Install local fleet tools and user systemd units')
|
.description('Install local fleet tools and user systemd units')
|
||||||
.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, runtimeProbeRunner);
|
await installFleet(cmd, frameworkRoot);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
// Unit enablement needs agent names only, so it reads either version.
|
||||||
const roster = await loadRosterReadModel(cmd);
|
const roster = await loadRosterReadModel(cmd);
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
@@ -1550,7 +1538,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.description('Install local fleet tools and user systemd units')
|
.description('Install local fleet tools and user systemd units')
|
||||||
.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, runtimeProbeRunner);
|
await installFleet(cmd, frameworkRoot);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
// Unit enablement needs agent names only, so it reads either version.
|
||||||
const roster = await loadRosterReadModel(cmd);
|
const roster = await loadRosterReadModel(cmd);
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
@@ -2096,8 +2084,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
});
|
});
|
||||||
registerFleetReconcilerCommands(cmd, {
|
registerFleetReconcilerCommands(cmd, {
|
||||||
runner,
|
runner,
|
||||||
runtimeProbeRunner,
|
|
||||||
frameworkRoot,
|
|
||||||
mosaicHome: deps.mosaicHome,
|
mosaicHome: deps.mosaicHome,
|
||||||
reconcileDeps: deps.reconcileDeps,
|
reconcileDeps: deps.reconcileDeps,
|
||||||
});
|
});
|
||||||
@@ -2363,68 +2349,18 @@ export function registerFleetAgentCommands(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installFleet(
|
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
||||||
cmd: Command,
|
|
||||||
frameworkRoot: string,
|
|
||||||
runtimeProbeRunner: FleetRuntimeProbeRunner,
|
|
||||||
): 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 and preflight before the first mkdir/copy/chmod/write. A successful
|
// Read model first: every file this function places is roster-independent, and
|
||||||
// install must mean every roster runtime is executable in the eventual pane,
|
// the v1 parser would reject a v2 roster before any of them were written.
|
||||||
// not merely visible to the operator who invoked this command.
|
|
||||||
const roster = await loadRosterReadModel(cmd);
|
const roster = await loadRosterReadModel(cmd);
|
||||||
const v1Roster = roster.version === 1 ? await loadRosterForCommand(cmd) : undefined;
|
|
||||||
const preflightV1Projections =
|
|
||||||
v1Roster === undefined
|
|
||||||
? []
|
|
||||||
: await Promise.all(
|
|
||||||
v1Roster.agents.map((agent: FleetAgent) =>
|
|
||||||
prepareAgentEnvironmentProjection({
|
|
||||||
mosaicHome: activePaths.mosaicHome,
|
|
||||||
agentEnvDir: activePaths.agentEnvDir,
|
|
||||||
agentName: agent.name,
|
|
||||||
generated: generateAgentEnvValues(v1Roster, agent),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const preflightAgents =
|
|
||||||
v1Roster === undefined
|
|
||||||
? roster.agents
|
|
||||||
: v1Roster.agents.map((agent: FleetAgent, index: number) => {
|
|
||||||
const prepared = preflightV1Projections[index];
|
|
||||||
if (prepared === undefined) {
|
|
||||||
throw new Error(`Missing prepared environment projection for ${agent.name}.`);
|
|
||||||
}
|
|
||||||
const local = parseAgentEnvironment(prepared.local, 'local');
|
|
||||||
return {
|
|
||||||
name: agent.name,
|
|
||||||
runtime: agent.runtime,
|
|
||||||
runtimeBin: local['MOSAIC_RUNTIME_BIN'] ?? '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const runtimeInspection = await inspectFleetRuntimeAvailability({
|
|
||||||
mosaicHome: activePaths.mosaicHome,
|
|
||||||
agentEnvDir: activePaths.agentEnvDir,
|
|
||||||
helperPath: join(frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'),
|
|
||||||
agents: preflightAgents,
|
|
||||||
runner: runtimeProbeRunner,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
assertFleetRuntimeAvailability(runtimeInspection);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof FleetRuntimePreflightError) {
|
|
||||||
cmd.error(error.message, { code: 'fleet.runtime-preflight', exitCode: 1 });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
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 });
|
||||||
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
||||||
|
|
||||||
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||||
const paneRuntimePath = join(activePaths.fleetToolsDir, 'pane-runtime-path.sh');
|
|
||||||
const startInteractionServicePath = join(
|
const startInteractionServicePath = join(
|
||||||
activePaths.fleetToolsDir,
|
activePaths.fleetToolsDir,
|
||||||
'start-interaction-service.sh',
|
'start-interaction-service.sh',
|
||||||
@@ -2438,7 +2374,6 @@ async function installFleet(
|
|||||||
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
||||||
const executableToolPaths = [
|
const executableToolPaths = [
|
||||||
startAgentSessionPath,
|
startAgentSessionPath,
|
||||||
paneRuntimePath,
|
|
||||||
startInteractionServicePath,
|
startInteractionServicePath,
|
||||||
startTmuxHolderPath,
|
startTmuxHolderPath,
|
||||||
printInteractionPolicyPath,
|
printInteractionPolicyPath,
|
||||||
@@ -2449,7 +2384,6 @@ async function installFleet(
|
|||||||
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
||||||
startAgentSessionPath,
|
startAgentSessionPath,
|
||||||
);
|
);
|
||||||
await copyFile(join(frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'), paneRuntimePath);
|
|
||||||
await copyFile(
|
await copyFile(
|
||||||
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
||||||
startInteractionServicePath,
|
startInteractionServicePath,
|
||||||
@@ -2493,9 +2427,7 @@ async function installFleet(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (v1Roster === undefined) {
|
const v1Roster = await loadRosterForCommand(cmd);
|
||||||
throw new Error('Roster version changed while installing fleet files.');
|
|
||||||
}
|
|
||||||
for (const agent of v1Roster.agents) {
|
for (const agent of v1Roster.agents) {
|
||||||
await writeAgentEnvironmentProjection({
|
await writeAgentEnvironmentProjection({
|
||||||
mosaicHome: activePaths.mosaicHome,
|
mosaicHome: activePaths.mosaicHome,
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
inspectFleetRuntimeAvailability,
|
|
||||||
type FleetRuntimeProbeResult,
|
|
||||||
type FleetRuntimeProbeRunner,
|
|
||||||
} from './fleet-runtime-preflight.js';
|
|
||||||
|
|
||||||
const helperPath = resolve(process.cwd(), 'framework', 'tools', 'fleet', 'pane-runtime-path.sh');
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
interface FleetFixture {
|
|
||||||
readonly root: string;
|
|
||||||
readonly mosaicHome: string;
|
|
||||||
readonly agentEnvDir: string;
|
|
||||||
readonly runtimeDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fleetHome(): Promise<FleetFixture> {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), 'mosaic-fleet-runtime-preflight-'));
|
|
||||||
cleanup = root;
|
|
||||||
const mosaicHome = join(root, '.config', 'mosaic');
|
|
||||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
|
||||||
const runtimeDir = join(root, '.npm-global', 'bin');
|
|
||||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
||||||
await mkdir(runtimeDir, { recursive: true });
|
|
||||||
for (const directory of [mosaicHome, join(mosaicHome, 'fleet'), agentEnvDir]) {
|
|
||||||
await chmod(directory, 0o700);
|
|
||||||
}
|
|
||||||
await writeExecutable(runtimeDir, 'mosaic', '#!/bin/sh\nexit 0\n');
|
|
||||||
return { root, mosaicHome, agentEnvDir, runtimeDir };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeExecutable(directory: string, name: string, content: string): Promise<void> {
|
|
||||||
await mkdir(directory, { recursive: true });
|
|
||||||
await writeFile(join(directory, name), content, { mode: 0o755 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const processRunner: FleetRuntimeProbeRunner = async (
|
|
||||||
command: string,
|
|
||||||
args: readonly string[],
|
|
||||||
): Promise<FleetRuntimeProbeResult> =>
|
|
||||||
new Promise((settle) => {
|
|
||||||
const child = spawn(command, [...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
child.stdout.setEncoding('utf8');
|
|
||||||
child.stderr.setEncoding('utf8');
|
|
||||||
child.stdout.on('data', (chunk: string): void => {
|
|
||||||
stdout += chunk;
|
|
||||||
});
|
|
||||||
child.stderr.on('data', (chunk: string): void => {
|
|
||||||
stderr += chunk;
|
|
||||||
});
|
|
||||||
child.on('error', (error: Error): void => {
|
|
||||||
settle({ stdout, stderr: `${stderr}${error.message}`, exitCode: 127 });
|
|
||||||
});
|
|
||||||
child.on('close', (code: number | null): void => {
|
|
||||||
settle({ stdout, stderr, exitCode: code ?? 1 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fleet runtime preflight', (): void => {
|
|
||||||
it('executes one distinct pane runtime and aggregates every requesting roster row', async (): Promise<void> => {
|
|
||||||
const fixture = await fleetHome();
|
|
||||||
await writeExecutable(fixture.runtimeDir, 'pi', '#!/bin/sh\nexit 0\n');
|
|
||||||
let probes = 0;
|
|
||||||
|
|
||||||
const inspection = await inspectFleetRuntimeAvailability({
|
|
||||||
mosaicHome: fixture.mosaicHome,
|
|
||||||
agentEnvDir: fixture.agentEnvDir,
|
|
||||||
helperPath,
|
|
||||||
agents: [
|
|
||||||
{ name: 'coder1', runtime: 'pi' },
|
|
||||||
{ name: 'coder0', runtime: 'pi' },
|
|
||||||
],
|
|
||||||
runner: async (command, args): Promise<FleetRuntimeProbeResult> => {
|
|
||||||
probes += 1;
|
|
||||||
return processRunner(command, args);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(probes).toBe(2);
|
|
||||||
expect(inspection.fleetCliExecutable).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
check: 'fleet-cli-executable',
|
|
||||||
status: 'ok',
|
|
||||||
requestedBy: ['coder0', 'coder1'],
|
|
||||||
binaryPath: join(fixture.runtimeDir, 'mosaic'),
|
|
||||||
dependency: '/bin/sh',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(inspection.fleetRuntimeAvailability).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
check: 'fleet-runtime-available',
|
|
||||||
runtime: 'pi',
|
|
||||||
status: 'ok',
|
|
||||||
requestedBy: ['coder0', 'coder1'],
|
|
||||||
binaryPath: join(fixture.runtimeDir, 'pi'),
|
|
||||||
dependency: '/bin/sh',
|
|
||||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(inspection.fleetRuntimeAvailability[0]?.panePath).toContain(fixture.runtimeDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an actionable non-green check when the pane PATH lacks the runtime', async (): Promise<void> => {
|
|
||||||
const fixture = await fleetHome();
|
|
||||||
|
|
||||||
const inspection = await inspectFleetRuntimeAvailability({
|
|
||||||
mosaicHome: fixture.mosaicHome,
|
|
||||||
agentEnvDir: fixture.agentEnvDir,
|
|
||||||
helperPath,
|
|
||||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
|
||||||
runner: processRunner,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(inspection.fleetCliExecutable[0]?.status).toBe('ok');
|
|
||||||
expect(inspection.fleetRuntimeAvailability).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
check: 'fleet-runtime-available',
|
|
||||||
runtime: 'pi',
|
|
||||||
status: 'missing',
|
|
||||||
requestedBy: ['coder0'],
|
|
||||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(inspection.fleetRuntimeAvailability[0]?.panePath).not.toContain(
|
|
||||||
process.env['PATH'] ?? 'operator-path-absent',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('executes the side-effect-free Node version probe for Node-shebang commands', async (): Promise<void> => {
|
|
||||||
const fixture = await fleetHome();
|
|
||||||
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
|
||||||
await writeExecutable(fixture.runtimeDir, 'mosaic', nodeScript);
|
|
||||||
await writeExecutable(fixture.runtimeDir, 'pi', nodeScript);
|
|
||||||
await writeExecutable(
|
|
||||||
fixture.runtimeDir,
|
|
||||||
'node',
|
|
||||||
'#!/bin/sh\n[ "$1" = --version ] || exit 9\nprintf "v-fixture-node\\n"\n',
|
|
||||||
);
|
|
||||||
|
|
||||||
const inspection = await inspectFleetRuntimeAvailability({
|
|
||||||
mosaicHome: fixture.mosaicHome,
|
|
||||||
agentEnvDir: fixture.agentEnvDir,
|
|
||||||
helperPath,
|
|
||||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
|
||||||
runner: processRunner,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const check of [
|
|
||||||
...inspection.fleetCliExecutable,
|
|
||||||
...inspection.fleetRuntimeAvailability,
|
|
||||||
]) {
|
|
||||||
expect(check).toMatchObject({
|
|
||||||
status: 'ok',
|
|
||||||
dependency: 'node',
|
|
||||||
probeCommand: 'node --version',
|
|
||||||
probeExit: 0,
|
|
||||||
probeOutput: 'v-fixture-node',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reddens when resolved Node-shebang commands cannot execute without pane Node', async (): Promise<void> => {
|
|
||||||
const fixture = await fleetHome();
|
|
||||||
const isolatedSystemPath = join(fixture.root, 'system-bin');
|
|
||||||
await mkdir(isolatedSystemPath, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
join(fixture.root, '.npmrc'),
|
|
||||||
`prefix=${join(fixture.root, 'absent-prefix')}\n`,
|
|
||||||
);
|
|
||||||
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
|
||||||
await writeExecutable(fixture.runtimeDir, 'mosaic', nodeScript);
|
|
||||||
await writeExecutable(fixture.runtimeDir, 'pi', nodeScript);
|
|
||||||
|
|
||||||
const inspection = await inspectFleetRuntimeAvailability({
|
|
||||||
mosaicHome: fixture.mosaicHome,
|
|
||||||
agentEnvDir: fixture.agentEnvDir,
|
|
||||||
helperPath,
|
|
||||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
|
||||||
runner: processRunner,
|
|
||||||
systemPath: isolatedSystemPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(inspection.fleetCliExecutable).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
check: 'fleet-cli-executable',
|
|
||||||
status: 'unexecutable',
|
|
||||||
binaryPath: join(fixture.runtimeDir, 'mosaic'),
|
|
||||||
dependency: 'node',
|
|
||||||
probeCommand: 'node --version',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(inspection.fleetRuntimeAvailability).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
check: 'fleet-runtime-available',
|
|
||||||
runtime: 'pi',
|
|
||||||
status: 'unexecutable',
|
|
||||||
binaryPath: join(fixture.runtimeDir, 'pi'),
|
|
||||||
dependency: 'node',
|
|
||||||
probeCommand: 'node --version',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(inspection.fleetCliExecutable[0]?.probeOutput).toBe(
|
|
||||||
'shebang command is not on the pane PATH',
|
|
||||||
);
|
|
||||||
expect(inspection.fleetCliExecutable[0]?.panePath).not.toContain('/usr/bin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps distinct effective local runtime-bin paths as distinct checks', async (): Promise<void> => {
|
|
||||||
const fixture = await fleetHome();
|
|
||||||
const firstBin = join(fixture.root, 'first-bin');
|
|
||||||
const secondBin = join(fixture.root, 'second-bin');
|
|
||||||
for (const override of [
|
|
||||||
{ agent: 'coder0', runtimeBin: firstBin },
|
|
||||||
{ agent: 'coder1', runtimeBin: secondBin },
|
|
||||||
]) {
|
|
||||||
await writeExecutable(override.runtimeBin, 'pi', '#!/bin/sh\nexit 0\n');
|
|
||||||
await writeFile(
|
|
||||||
join(fixture.agentEnvDir, `${override.agent}.env.local`),
|
|
||||||
`MOSAIC_RUNTIME_BIN=${override.runtimeBin}\n`,
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inspection = await inspectFleetRuntimeAvailability({
|
|
||||||
mosaicHome: fixture.mosaicHome,
|
|
||||||
agentEnvDir: fixture.agentEnvDir,
|
|
||||||
helperPath,
|
|
||||||
agents: [
|
|
||||||
{ name: 'coder0', runtime: 'pi' },
|
|
||||||
{ name: 'coder1', runtime: 'pi' },
|
|
||||||
],
|
|
||||||
runner: processRunner,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(inspection.fleetCliExecutable).toHaveLength(2);
|
|
||||||
expect(inspection.fleetRuntimeAvailability).toHaveLength(2);
|
|
||||||
expect(inspection.fleetRuntimeAvailability.map((check) => check.requestedBy)).toEqual([
|
|
||||||
['coder0'],
|
|
||||||
['coder1'],
|
|
||||||
]);
|
|
||||||
expect(inspection.fleetRuntimeAvailability.map((check) => check.binaryPath)).toEqual([
|
|
||||||
join(firstBin, 'pi'),
|
|
||||||
join(secondBin, 'pi'),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports each distinct roster runtime with its exact install command', async (): Promise<void> => {
|
|
||||||
const fixture = await fleetHome();
|
|
||||||
let probes = 0;
|
|
||||||
|
|
||||||
const inspection = await inspectFleetRuntimeAvailability({
|
|
||||||
mosaicHome: fixture.mosaicHome,
|
|
||||||
agentEnvDir: fixture.agentEnvDir,
|
|
||||||
helperPath,
|
|
||||||
agents: [
|
|
||||||
{ name: 'pi-seat', runtime: 'pi' },
|
|
||||||
{ name: 'claude-seat', runtime: 'claude' },
|
|
||||||
{ name: 'codex-seat', runtime: 'codex' },
|
|
||||||
{ name: 'opencode-seat', runtime: 'opencode' },
|
|
||||||
],
|
|
||||||
runner: async (): Promise<FleetRuntimeProbeResult> => {
|
|
||||||
probes += 1;
|
|
||||||
return {
|
|
||||||
stdout:
|
|
||||||
'pane_path\u0000/fixture/bin:/usr/bin:/bin\u0000status\u0000missing\u0000' +
|
|
||||||
'binary_path\u0000\u0000probe_exit\u0000\u0000probe_output\u0000\u0000',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 69,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(probes).toBe(5);
|
|
||||||
expect(
|
|
||||||
inspection.fleetRuntimeAvailability.map((check) => ({
|
|
||||||
runtime: check.runtime,
|
|
||||||
installCommand: check.installCommand,
|
|
||||||
})),
|
|
||||||
).toEqual([
|
|
||||||
{
|
|
||||||
runtime: 'claude',
|
|
||||||
installCommand: 'curl -fsSL https://claude.ai/install.sh | bash',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
runtime: 'codex',
|
|
||||||
installCommand: 'npm install -g @openai/codex',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
runtime: 'opencode',
|
|
||||||
installCommand: 'npm install -g opencode-ai',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
runtime: 'pi',
|
|
||||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when the shared helper returns malformed evidence', async (): Promise<void> => {
|
|
||||||
const fixture = await fleetHome();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
inspectFleetRuntimeAvailability({
|
|
||||||
mosaicHome: fixture.mosaicHome,
|
|
||||||
agentEnvDir: fixture.agentEnvDir,
|
|
||||||
helperPath,
|
|
||||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
|
||||||
runner: async (): Promise<FleetRuntimeProbeResult> => ({
|
|
||||||
stdout: 'not-a-field-protocol',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('malformed field output');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
import { homedir } from 'node:os';
|
|
||||||
import { getInstallInstructions } from '../runtime/detector.js';
|
|
||||||
import type { RuntimeName } from '../types.js';
|
|
||||||
import { compareCodePoints } from './deterministic-order.js';
|
|
||||||
import {
|
|
||||||
GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES,
|
|
||||||
readAgentLocalEnvironment,
|
|
||||||
} from './generated-env-boundary.js';
|
|
||||||
|
|
||||||
const RUNTIME_SET = new Set<string>(GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES);
|
|
||||||
|
|
||||||
export interface FleetRuntimeRequestedAgent {
|
|
||||||
readonly name: string;
|
|
||||||
readonly runtime: string;
|
|
||||||
/** Planned effective local override, when provisioning has already prepared it. */
|
|
||||||
readonly runtimeBin?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetRuntimeProbeResult {
|
|
||||||
readonly stdout: string;
|
|
||||||
readonly stderr: string;
|
|
||||||
readonly exitCode: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FleetRuntimeProbeRunner = (
|
|
||||||
command: string,
|
|
||||||
args: readonly string[],
|
|
||||||
) => Promise<FleetRuntimeProbeResult>;
|
|
||||||
|
|
||||||
export interface FleetRuntimePreflightOptions {
|
|
||||||
readonly mosaicHome: string;
|
|
||||||
readonly agentEnvDir: string;
|
|
||||||
readonly helperPath: string;
|
|
||||||
readonly agents: readonly FleetRuntimeRequestedAgent[];
|
|
||||||
readonly runner: FleetRuntimeProbeRunner;
|
|
||||||
/** Test-only system suffix; production and the launcher use the helper default. */
|
|
||||||
readonly systemPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FleetExecutableStatus = 'ok' | 'missing' | 'unexecutable';
|
|
||||||
|
|
||||||
interface FleetExecutableEvidence {
|
|
||||||
readonly status: FleetExecutableStatus;
|
|
||||||
readonly panePath: string;
|
|
||||||
readonly binaryPath?: string;
|
|
||||||
readonly dependency?: string;
|
|
||||||
readonly probeCommand?: string;
|
|
||||||
readonly probeExit?: number;
|
|
||||||
readonly probeOutput?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetCliExecutableCheck extends FleetExecutableEvidence {
|
|
||||||
readonly check: 'fleet-cli-executable';
|
|
||||||
readonly binary: 'mosaic';
|
|
||||||
readonly requestedBy: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetRuntimeCheck extends FleetExecutableEvidence {
|
|
||||||
readonly check: 'fleet-runtime-available';
|
|
||||||
readonly runtime: RuntimeName;
|
|
||||||
readonly requestedBy: readonly string[];
|
|
||||||
readonly installCommand: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FleetRuntimePreflightCheck = FleetCliExecutableCheck | FleetRuntimeCheck;
|
|
||||||
|
|
||||||
export interface FleetRuntimeInspection {
|
|
||||||
readonly fleetCliExecutable: readonly FleetCliExecutableCheck[];
|
|
||||||
readonly fleetRuntimeAvailability: readonly FleetRuntimeCheck[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EffectiveAgent {
|
|
||||||
readonly name: string;
|
|
||||||
readonly runtime: RuntimeName;
|
|
||||||
readonly runtimeBin: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaneProbeGroup {
|
|
||||||
readonly runtimeBin: string;
|
|
||||||
readonly requestedBy: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RuntimeProbeGroup extends PaneProbeGroup {
|
|
||||||
readonly runtime: RuntimeName;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BinaryProbeRequest {
|
|
||||||
readonly binary: string;
|
|
||||||
readonly runtimeBin: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FleetRuntimePreflightError extends Error {
|
|
||||||
readonly checks: readonly FleetRuntimePreflightCheck[];
|
|
||||||
|
|
||||||
constructor(checks: readonly FleetRuntimePreflightCheck[]) {
|
|
||||||
super(formatFleetRuntimePreflightError(checks));
|
|
||||||
this.name = FleetRuntimePreflightError.name;
|
|
||||||
this.checks = checks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FleetRuntimeProbeError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = FleetRuntimeProbeError.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proves the fleet CLI and every distinct runtime/effective-bin pair resolve
|
|
||||||
* with an executable shebang interpreter through the eventual pane PATH. The
|
|
||||||
* helper runs under the unit's clean launcher environment, so operator PATH can
|
|
||||||
* neither create a false green nor provide a hidden interpreter.
|
|
||||||
*/
|
|
||||||
export async function inspectFleetRuntimeAvailability(
|
|
||||||
options: FleetRuntimePreflightOptions,
|
|
||||||
): Promise<FleetRuntimeInspection> {
|
|
||||||
const agents = await resolveEffectiveAgents(options);
|
|
||||||
const paneGroups = groupPaneRequests(agents);
|
|
||||||
const runtimeGroups = groupRuntimeRequests(agents);
|
|
||||||
|
|
||||||
const fleetCliExecutable: FleetCliExecutableCheck[] = [];
|
|
||||||
for (const group of paneGroups) {
|
|
||||||
const evidence = await probeBinary(options, {
|
|
||||||
binary: 'mosaic',
|
|
||||||
runtimeBin: group.runtimeBin,
|
|
||||||
});
|
|
||||||
fleetCliExecutable.push({
|
|
||||||
check: 'fleet-cli-executable',
|
|
||||||
binary: 'mosaic',
|
|
||||||
requestedBy: sortedRequestedBy(group.requestedBy),
|
|
||||||
...evidence,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fleetRuntimeAvailability: FleetRuntimeCheck[] = [];
|
|
||||||
for (const group of runtimeGroups) {
|
|
||||||
const evidence = await probeBinary(options, {
|
|
||||||
binary: group.runtime,
|
|
||||||
runtimeBin: group.runtimeBin,
|
|
||||||
});
|
|
||||||
fleetRuntimeAvailability.push({
|
|
||||||
check: 'fleet-runtime-available',
|
|
||||||
runtime: group.runtime,
|
|
||||||
requestedBy: sortedRequestedBy(group.requestedBy),
|
|
||||||
installCommand: getInstallInstructions(group.runtime),
|
|
||||||
...evidence,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze({
|
|
||||||
fleetCliExecutable: Object.freeze(fleetCliExecutable),
|
|
||||||
fleetRuntimeAvailability: Object.freeze(fleetRuntimeAvailability),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertFleetRuntimeAvailability(inspection: FleetRuntimeInspection): void {
|
|
||||||
const checks: FleetRuntimePreflightCheck[] = [
|
|
||||||
...inspection.fleetCliExecutable,
|
|
||||||
...inspection.fleetRuntimeAvailability,
|
|
||||||
];
|
|
||||||
const failures = checks.filter(
|
|
||||||
(check: FleetRuntimePreflightCheck): boolean => check.status !== 'ok',
|
|
||||||
);
|
|
||||||
if (failures.length > 0) throw new FleetRuntimePreflightError(failures);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFleetRuntimePreflightError(
|
|
||||||
checks: readonly FleetRuntimePreflightCheck[],
|
|
||||||
): string {
|
|
||||||
const lines = ['Fleet runtime preflight failed:'];
|
|
||||||
for (const check of checks) {
|
|
||||||
const dependency = check.dependency === undefined ? '' : ` dependency=${check.dependency}`;
|
|
||||||
const probe = check.probeCommand === undefined ? '' : ` dependency_probe=${check.probeCommand}`;
|
|
||||||
const execution =
|
|
||||||
check.status === 'unexecutable'
|
|
||||||
? ` probe_exit=${check.probeExit?.toString() ?? 'not-run'} ` +
|
|
||||||
`probe_output=${JSON.stringify(check.probeOutput ?? '')}`
|
|
||||||
: '';
|
|
||||||
if (check.check === 'fleet-cli-executable') {
|
|
||||||
lines.push(
|
|
||||||
`check=${check.check} binary=${check.binary} ` +
|
|
||||||
`requested_by=${check.requestedBy.join(',')} pane_path=${check.panePath}` +
|
|
||||||
`${dependency}${probe}${execution} ` +
|
|
||||||
'action=repair the Mosaic installation until its pane dependencies resolve',
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
lines.push(
|
|
||||||
`check=${check.check} runtime=${check.runtime} ` +
|
|
||||||
`requested_by=${check.requestedBy.join(',')} pane_path=${check.panePath}` +
|
|
||||||
`${dependency}${probe}${execution} install_command=${check.installCommand}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveEffectiveAgents(
|
|
||||||
options: FleetRuntimePreflightOptions,
|
|
||||||
): Promise<readonly EffectiveAgent[]> {
|
|
||||||
const agents: EffectiveAgent[] = [];
|
|
||||||
for (const agent of options.agents) {
|
|
||||||
if (!isRuntimeName(agent.runtime)) {
|
|
||||||
throw new FleetRuntimeProbeError(`Unsupported fleet runtime: ${agent.runtime}`);
|
|
||||||
}
|
|
||||||
const runtimeBin =
|
|
||||||
agent.runtimeBin ??
|
|
||||||
(
|
|
||||||
await readAgentLocalEnvironment({
|
|
||||||
mosaicHome: options.mosaicHome,
|
|
||||||
agentEnvDir: options.agentEnvDir,
|
|
||||||
agentName: agent.name,
|
|
||||||
})
|
|
||||||
)['MOSAIC_RUNTIME_BIN'] ??
|
|
||||||
'';
|
|
||||||
agents.push({ name: agent.name, runtime: agent.runtime, runtimeBin });
|
|
||||||
}
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupPaneRequests(agents: readonly EffectiveAgent[]): readonly PaneProbeGroup[] {
|
|
||||||
const groups = new Map<string, PaneProbeGroup>();
|
|
||||||
for (const agent of agents) {
|
|
||||||
const current = groups.get(agent.runtimeBin);
|
|
||||||
if (current === undefined) {
|
|
||||||
groups.set(agent.runtimeBin, { runtimeBin: agent.runtimeBin, requestedBy: [agent.name] });
|
|
||||||
} else {
|
|
||||||
current.requestedBy.push(agent.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...groups.values()].sort((left, right): number =>
|
|
||||||
compareCodePoints(left.runtimeBin, right.runtimeBin),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupRuntimeRequests(agents: readonly EffectiveAgent[]): readonly RuntimeProbeGroup[] {
|
|
||||||
const groups = new Map<string, RuntimeProbeGroup>();
|
|
||||||
for (const agent of agents) {
|
|
||||||
const key = JSON.stringify([agent.runtime, agent.runtimeBin]);
|
|
||||||
const current = groups.get(key);
|
|
||||||
if (current === undefined) {
|
|
||||||
groups.set(key, {
|
|
||||||
runtime: agent.runtime,
|
|
||||||
runtimeBin: agent.runtimeBin,
|
|
||||||
requestedBy: [agent.name],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
current.requestedBy.push(agent.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...groups.values()].sort((left, right): number =>
|
|
||||||
compareCodePoints(
|
|
||||||
`${left.runtime}\u0000${left.runtimeBin}`,
|
|
||||||
`${right.runtime}\u0000${right.runtimeBin}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probeBinary(
|
|
||||||
options: FleetRuntimePreflightOptions,
|
|
||||||
probe: BinaryProbeRequest,
|
|
||||||
): Promise<FleetExecutableEvidence> {
|
|
||||||
const args = [
|
|
||||||
'-i',
|
|
||||||
`HOME=${process.env['HOME'] ?? homedir()}`,
|
|
||||||
'PATH=/usr/bin:/bin',
|
|
||||||
`MOSAIC_HOME=${options.mosaicHome}`,
|
|
||||||
'/bin/bash',
|
|
||||||
'--noprofile',
|
|
||||||
'--norc',
|
|
||||||
options.helperPath,
|
|
||||||
'--mosaic-home',
|
|
||||||
options.mosaicHome,
|
|
||||||
'--binary',
|
|
||||||
probe.binary,
|
|
||||||
'--check-executable',
|
|
||||||
];
|
|
||||||
if (probe.runtimeBin !== '') args.push('--runtime-bin', probe.runtimeBin);
|
|
||||||
if (options.systemPath !== undefined) args.push('--system-path', options.systemPath);
|
|
||||||
|
|
||||||
const result = await options.runner('/usr/bin/env', args);
|
|
||||||
const fields = parseNulFields(result.stdout);
|
|
||||||
const panePath = requiredField(fields, 'pane_path');
|
|
||||||
const status = requiredField(fields, 'status');
|
|
||||||
if (result.exitCode === 0 && status === 'present') {
|
|
||||||
return executableEvidence('ok', panePath, fields);
|
|
||||||
}
|
|
||||||
if (result.exitCode === 69 && status === 'missing') {
|
|
||||||
return { status: 'missing', panePath };
|
|
||||||
}
|
|
||||||
if (result.exitCode === 70 && status === 'unexecutable') {
|
|
||||||
return executableEvidence('unexecutable', panePath, fields);
|
|
||||||
}
|
|
||||||
throw new FleetRuntimeProbeError(
|
|
||||||
`Fleet executable probe failed: binary=${probe.binary} exit=${result.exitCode.toString()} ` +
|
|
||||||
`stderr=${JSON.stringify(result.stderr.trim())}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function executableEvidence(
|
|
||||||
status: 'ok' | 'unexecutable',
|
|
||||||
panePath: string,
|
|
||||||
fields: ReadonlyMap<string, string>,
|
|
||||||
): FleetExecutableEvidence {
|
|
||||||
const dependency = requiredField(fields, 'dependency');
|
|
||||||
const probeCommand = requiredField(fields, 'probe_command');
|
|
||||||
const probeExit = requiredField(fields, 'probe_exit');
|
|
||||||
const probeOutput = requiredField(fields, 'probe_output');
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
panePath,
|
|
||||||
binaryPath: requiredField(fields, 'binary_path'),
|
|
||||||
...(dependency === '' ? {} : { dependency }),
|
|
||||||
...(probeCommand === '' ? {} : { probeCommand }),
|
|
||||||
...(probeExit === '' ? {} : { probeExit: parseProbeExit(probeExit) }),
|
|
||||||
...(probeOutput === '' ? {} : { probeOutput }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortedRequestedBy(requestedBy: readonly string[]): readonly string[] {
|
|
||||||
return Object.freeze(
|
|
||||||
[...requestedBy].sort((left: string, right: string): number => compareCodePoints(left, right)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNulFields(source: string): ReadonlyMap<string, string> {
|
|
||||||
const parts = source.split('\u0000');
|
|
||||||
if (parts.at(-1) === '') parts.pop();
|
|
||||||
if (parts.length % 2 !== 0) {
|
|
||||||
throw new FleetRuntimeProbeError('Fleet runtime probe returned malformed field output.');
|
|
||||||
}
|
|
||||||
const fields = new Map<string, string>();
|
|
||||||
for (let index = 0; index < parts.length; index += 2) {
|
|
||||||
const key = parts[index];
|
|
||||||
const value = parts[index + 1];
|
|
||||||
if (key === undefined || value === undefined || key === '' || fields.has(key)) {
|
|
||||||
throw new FleetRuntimeProbeError('Fleet runtime probe returned malformed field output.');
|
|
||||||
}
|
|
||||||
fields.set(key, value);
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredField(fields: ReadonlyMap<string, string>, key: string): string {
|
|
||||||
const value = fields.get(key);
|
|
||||||
if (value === undefined) {
|
|
||||||
throw new FleetRuntimeProbeError(`Fleet runtime probe omitted ${key}.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProbeExit(value: string): number {
|
|
||||||
const exitCode = Number(value);
|
|
||||||
if (!Number.isSafeInteger(exitCode) || exitCode < 0) {
|
|
||||||
throw new FleetRuntimeProbeError('Fleet runtime probe returned an invalid execution status.');
|
|
||||||
}
|
|
||||||
return exitCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRuntimeName(value: string): value is RuntimeName {
|
|
||||||
return RUNTIME_SET.has(value);
|
|
||||||
}
|
|
||||||
@@ -25,12 +25,6 @@ export interface AgentGeneratedProjectionDeletionOptions {
|
|||||||
readonly agentName: string;
|
readonly agentName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentLocalEnvironmentReadOptions {
|
|
||||||
readonly mosaicHome: string;
|
|
||||||
readonly agentEnvDir: string;
|
|
||||||
readonly agentName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentEnvironmentProjectionResult {
|
export interface AgentEnvironmentProjectionResult {
|
||||||
readonly generatedPath: string;
|
readonly generatedPath: string;
|
||||||
readonly localPath: string;
|
readonly localPath: string;
|
||||||
@@ -151,23 +145,6 @@ export function parseAgentEnvironment(
|
|||||||
return Object.freeze(values);
|
return Object.freeze(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads one agent's optional local overrides through the same path, file-type,
|
|
||||||
* permission, key, and value boundary used by projection/launch handling.
|
|
||||||
*/
|
|
||||||
export async function readAgentLocalEnvironment(
|
|
||||||
options: AgentLocalEnvironmentReadOptions,
|
|
||||||
): Promise<Readonly<Record<string, string>>> {
|
|
||||||
if (!AGENT_NAME.test(options.agentName)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
|
||||||
}
|
|
||||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
|
||||||
const source = await readOptionalPrivateFile(
|
|
||||||
join(options.agentEnvDir, `${options.agentName}.env.local`),
|
|
||||||
);
|
|
||||||
return source === undefined ? Object.freeze({}) : parseAgentEnvironment(source, 'local');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
||||||
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||||
const normalized = normalizeGeneratedValues(values);
|
const normalized = normalizeGeneratedValues(values);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const RUNTIME_DEFS: Record<
|
|||||||
label: 'Claude Code',
|
label: 'Claude Code',
|
||||||
command: 'claude',
|
command: 'claude',
|
||||||
versionFlag: '--version',
|
versionFlag: '--version',
|
||||||
installHint: 'curl -fsSL https://claude.ai/install.sh | bash',
|
installHint: 'npm install -g @anthropic-ai/claude-code',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
label: 'Codex',
|
label: 'Codex',
|
||||||
@@ -31,13 +31,13 @@ const RUNTIME_DEFS: Record<
|
|||||||
label: 'OpenCode',
|
label: 'OpenCode',
|
||||||
command: 'opencode',
|
command: 'opencode',
|
||||||
versionFlag: 'version',
|
versionFlag: 'version',
|
||||||
installHint: 'npm install -g opencode-ai',
|
installHint: 'See https://opencode.ai for install instructions',
|
||||||
},
|
},
|
||||||
pi: {
|
pi: {
|
||||||
label: 'Pi',
|
label: 'Pi',
|
||||||
command: 'pi',
|
command: 'pi',
|
||||||
versionFlag: '--version',
|
versionFlag: '--version',
|
||||||
installHint: 'npm install -g @earendil-works/pi-coding-agent',
|
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user