Compare commits

..
Author SHA1 Message Date
fargo 2097379e25 fix(macp): fail-closed typed gate states — no placeholder/empty-command passes (#1275)
ci/woodpecker/pr/ci Pipeline was successful
RI-N2 / SDLC-D-035. Every GateResult now carries a typed status
discriminator (passed|failed|simulated|waiting|capability_failure);
passed:true remains true only for really-executed, really-green gates.

- ci-pipeline without a CI provider → capability_failure (MACP_NO_CI_PIPELINE),
  never the placeholder pass
- empty-command gate → capability_failure (MACP_NO_COMMAND / MACP_NO_REVIEWER),
  and runGates no longer silently skips it
- manual gate type waits (MACP_AUTHORITY_REQUIRED) — neither pass nor fail
- explicit simulation only (simulate option / --simulate): typed simulated
  results can never make the aggregate passed (RunGatesResult.state)
- CLI stubs (tasks list, submit, events tail) exit nonzero with typed
  MACP_NOT_IMPLEMENTED; macp gate is now wired to runGates with --simulate
- legacy __tests__/gate-runner.test.ts consolidated into src/gate-runner.spec.ts
  (root eslint project service does not cover packages/macp/__tests__)
2026-08-17 16:40:16 -05:00
jason.woltje 8199261caa Merge pull request 'fix(ci): unwire test-start-agent-session.sh, restore its signed exclusion — unblocks every PR on next' (#1270) from fix/1269-ci-chain-unblock into next
ci/woodpecker/push/publish Pipeline failed
Reviewed-on: #1270
2026-08-17 20:44:59 +00:00
fred 57a2f2b40e docs(ci): point the exclusion at tracking issue #1271, not the closed first filing
ci/woodpecker/pr/ci Pipeline was successful
The first PR for this change was filed under the retired mos-dt-0 principal
(pr-create.sh has no --login flag and find_tea_login_for_host returns the first
host match) and was closed and refiled as #1270. That left in-tree references
pointing at a closed duplicate PR rather than at the burn-down issue, which is
the wrong target for them anyway: the open design question belongs on #1271.
2026-08-16 18:03:02 -05:00
fred 93c1de51e1 fix(ci): unwire test-start-agent-session.sh, restore its signed exclusion (#1269)
ci/woodpecker/pr/ci Pipeline was canceled
The `test` step has failed on every `next` pipeline since #1017 on exactly one
assertion, and it is the same one on unrelated PRs:

    FAIL: host provides 'pi' in the system path; missing-binary cases are not
    measurable here            (framework/tools/fleet/test-start-agent-session.sh:103)

Measured 2026-08-16 across pipelines 2444 (#1256), 2438 (#1240) and 2441
(#1017-quality): exactly one FAIL line in each full log, identical, this line.
Control `zzz-not-present-zzz` -> 0 on all three.

Cause. #1241 (5c35a250) added the guard: the suite shims fake mosaic/pi/npm 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 and a green run would mean nothing. The guard says so instead of
passing. Its own pipeline 2430 was green only because the suite was CI-excluded
at the time, so the guard had never run in CI. #1017 (c56483eb) then enumerated
it and dropped the exclusion. The CI image installs
@earendil-works/[email protected].1 on purpose, so the precondition is
unsatisfiable there. Both commits are mine.

The guard is correct and is not being softened. A check that cannot measure its
property and reports success is the failure mode this repo has been cataloguing
all week; the error was wiring the suite into an image that violates its
precondition, so the wiring is what gets reverted.

Second effect, which is the reason this cost a day rather than an hour:
test:framework-shell is one && chain and this sat at position 44 of 48, so
glpi/test-list-http-status.sh, orchestrator/test-board-roll.sh,
woodpecker/test-ci-wait-exit-matrix.sh and _scripts/test-fleet-transport-check.sh
have not run at all since the merge. The pipeline reported one failure, never
"one failure plus four unrun". All four are green when run directly on
sb-it-1-dt, so the mask hid nothing broken -- but that is a local result on one
host, not a CI-image result.

Verification, with controls:
- enumeration guard OK (population 52, enumerated 36, signed-excluded 16).
- control A, exclusion line removed while unwired -> FAIL UNENUMERATED.
- control B, exclusion line kept while rewired -> FAIL CONTRADICTORY EXCLUSION.
  The gate discriminates in both directions, so its OK is load-bearing.
- the four formerly-masked suites: rc=0 each, run directly.
- the full chain cannot be run to completion on sb-it-1-dt: it stops earlier, at
  the lease-broker Invariant R test, because this host carries the quarantined
  operator-global pi 0.84.2 against a measured 0.84.1. That is host-specific and
  out of scope here -- CI pins 0.84.1, and the single FAIL line in those three
  pipelines proves positions 1-43 passed there.

Burn-down is to control the tail of PANE_PATH inside the test, not to remove pi
from the image. Recorded in the exclusion reason and in #1269.
2026-08-16 17:58:49 -05:00
32 changed files with 999 additions and 1822 deletions
+9 -9
View File
@@ -9,19 +9,19 @@ This book is the canonical home for installation, configuration, deployment, rou
- [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries.
- [Documentation sitemap](../SITEMAP.md) — resolvable current navigation and authority-gated migration summary.
- [Product requirements](../PRD.md) — normative requirements, currently marked draft.
- [Operations index](operations/README.md) — current local procedures, unattended fleet first-start handling, and explicitly held operational outlines.
- [Operations index](operations/README.md) — current local procedures and explicitly held operational outlines.
- [Security index](security/README.md) — current SSO provider configuration.
## Chapter map
| Chapter | Scope | Status |
| ------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. |
| `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. |
| `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. |
| [`operations/`](operations/README.md) | Health, observability, routine operation, and maintenance. | Local upgrade/recovery and fleet first start are current; connector lease operations are held. |
| [`security/`](security/README.md) | Authentication, authorization, SSO, secrets, and security controls. | SSO provider guide is current; other pages are planned. |
| `recovery/` | Incident response, backup, rollback, and recovery. | Scaffold only. |
| Chapter | Scope | Status |
| ------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. |
| `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. |
| `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. |
| [`operations/`](operations/README.md) | Health, observability, routine operation, and maintenance. | Local upgrade/recovery is current; connector lease operations are held. |
| [`security/`](security/README.md) | Authentication, authorization, SSO, secrets, and security controls. | SSO provider guide is current; other pages are planned. |
| `recovery/` | Incident response, backup, rollback, and recovery. | Scaffold only. |
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
-1
View File
@@ -5,7 +5,6 @@
## Current procedures
- [Upgrade safety and recovery](upgrade-safety-and-recovery.md) — installed-CLI and local-PGlite upgrade, rollback, and framework-configuration recovery.
- [Fleet unattended first start](fleet-unattended-first-start.md) — systemd/no-TTY identity initialization, failure handling, and isolated verification.
## Held procedures
@@ -1,70 +0,0 @@
# Fleet Unattended First-Start Operations
> **Status:** Current after issue #1264 lands. This runbook covers only Mosaic's first-run identity
> gate; it does not install runtimes or credentials.
## Operational contract
A systemd fleet unit launches under a sanitized environment with no TTY. The generated environment
sets `MOSAIC_AGENT_NAME`; Mosaic resolves that exact value against the canonical installed roster
before writing identity files.
If top-level identity contracts are missing, Mosaic atomically seeds them from the shipped generic
sources:
| Destination | Source | New-file mode |
| ---------------------- | ------------------------------- | ------------- |
| `$MOSAIC_HOME/SOUL.md` | `$MOSAIC_HOME/defaults/SOUL.md` | `0600` |
| `$MOSAIC_HOME/USER.md` | `$MOSAIC_HOME/defaults/USER.md` | `0600` |
Creation is no-clobber and safe under concurrent seat starts. Existing regular files remain
byte-for-byte and mode-for-mode unchanged. The runtime composer then injects the exact roster name
and class; the generic source files grant no seat authority.
## Failure handling
The fleet path never falls back to an interactive wizard. It exits nonzero before runtime execution
when:
- `MOSAIC_AGENT_NAME` is not an exact roster member;
- a defined ambient `MOSAIC_AGENT_CLASS` is blank/whitespace or disagrees with that member's
canonical class;
- a missing destination has no safe regular default source;
- a source or existing destination is a symlink (including dangling), directory, unavailable, or over the bounded size;
- the fleet communications helper/roster cannot be validated; or
- `USER.md` cannot be securely re-read at the point where its content is composed.
Diagnostics begin with:
```text
[mosaic] ERROR: unattended fleet identity initialization failed: ...
```
Repair the exact named source, destination, roster, or helper and retry only that roster member. Do
not delete or replace an existing personalized `SOUL.md`/`USER.md` merely to clear the check.
## Verification without a live seat
The source gate is:
```bash
pnpm --filter @mosaicstack/mosaic... build && \
pnpm --filter @mosaicstack/mosaic exec vitest run \
src/commands/launch-first-start.spec.ts
```
The build leg is load-bearing: `dist/` is ignored, so a direct Vitest invocation could otherwise run
absent or stale CLI output. The gate runs the exact-source built CLI in subprocesses with piped stdin,
temporary homes, a canonical fixture roster, fake runtime/broker executables, and no provider call. It
covers no-TTY launch, exact identity,
private modes, no-clobber, missing/symlink defaults, unknown members, blank/mismatched class,
portable standalone composition/wizard preservation, and concurrent first start.
Do not use this fixture as proof that a real provider credential is present or that a package has
been deployed. Those require separate environment-specific evidence.
## Related
- [User workflow](../../USER-GUIDE/workflows/fleet-unattended-first-start.md)
- [Developer architecture](../../DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md)
- [Verification report](../../reports/qa/2026-08-16-1264-unattended-first-start.md)
-1
View File
@@ -26,7 +26,6 @@ This book is the canonical home for architecture, package and application guides
- [Lease-broker operations and verification](testing/lease-broker-operations.md) — safe static/test commands plus explicitly held live operations.
- [Channel adapters](integrations/channel-adapters.md) — current shared contracts and Discord reference boundary; future adapter parity is draft.
- [Fleet first-start identity](architecture/fleet-first-start-identity.md) — no-TTY launch boundary, roster authority, and no-clobber filesystem design.
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
@@ -11,7 +11,6 @@ This chapter is the canonical home for Mosaic Stack's system model, component bo
- [`mutator-class-gate.md`](mutator-class-gate.md) — default-deny tool authorization, runtime adapters, launch choke point, and parser assurance boundary.
- [`compaction-revocation.md`](compaction-revocation.md) — Claude/Pi observer lifecycle, runtime generations, revocation, and the bounded residual stale window.
- [`channel-protocol.md`](channel-protocol.md) — current shared channel DTOs and Discord compatibility baseline, with unimplemented adapter work explicitly marked draft.
- [`fleet-first-start-identity.md`](fleet-first-start-identity.md) — roster-owned identity bootstrap for concurrent no-TTY fleet launches.
- [`decisions/mos-runtime-portability-m1.md`](decisions/mos-runtime-portability-m1.md) — current logical identity, connector lease, grant, audit, and fencing decision; connector activation remains held.
These pages are current security-contract references and are consumed by the lease-broker acceptance suites. Their live deployment gaps remain explicitly labeled in the pages; this migration does not change runtime behavior.
@@ -1,81 +0,0 @@
# Fleet First-Start Identity Boundary
> **Status:** Implemented by issue #1264. Requirements: `FCM-REQ-12`, `AC-FCM-10`.
## Problem
`launchRuntime()` called `checkSoul()` before runtime execution. A missing top-level `SOUL.md`
caused `checkSoul()` to spawn a child `mosaic wizard` with inherited stdio. Under a systemd-created
fleet pane with no TTY, that child blocked or failed before the runtime boundary even though generic
`defaults/SOUL.md` and `defaults/USER.md` already shipped in the same `MOSAIC_HOME`.
## Chosen boundary
The fix remains at `checkSoul()` and does not add flags to `yolo`, fleet commands, systemd units, or
`start-agent-session.sh`:
1. A present, nonblank, whitespace-exact `MOSAIC_AGENT_NAME` selects the fleet path.
2. `resolveFleetIdentity()` must resolve that exact member through the existing roster/helper
boundary, and any defined `MOSAIC_AGENT_CLASS` (including blank/whitespace) must canonicalize to
the roster class, before any identity seed. Only undefined means absent.
3. `lstatSync()` preflights every destination directory entry without following links, so a dangling
link is rejected before its counterpart can be published.
4. Safe bounded snapshots are read from only the missing contracts under `defaults/`.
5. Each snapshot is written to a random owner-private temporary file in `MOSAIC_HOME`.
6. `linkSync()` publishes the complete file without overwriting an existing path. `EEXIST` means a
concurrent seat or operator won; the existing path is preserved and revalidated.
7. Temporary files are removed, and both installed contracts are re-opened through the no-symlink
secure-file reader before launch continues.
8. `composeContract()` independently re-resolves the roster, securely reads fleet `USER.md` through
a Linux descriptor at the point of use, and injects exact member identity and communications data.
A standalone launch with no `MOSAIC_AGENT_NAME` retains the portable tolerant USER read and the
interactive wizard. Fleet-only no-follow enforcement must not make supported standalone macOS
composition depend on Linux `/proc` descriptor traversal.
## Identity and authority
The copied defaults deliberately say “Mosaic agent”; they are a generic behavioral base. They are
not the source of a fleet seat's identity. The canonical roster controls:
- exact agent/session name;
- canonical role/class and persona;
- peer rows and point of contact;
- tmux socket and helper target; and
- communications generation.
An unknown/padded ambient name, mismatched class, or explicitly blank/whitespace class fails before
any file is seeded. This avoids replacing the interactive wall with a fleet of indistinguishable or
ambiently invented identities.
## Concurrency and filesystem properties
- Sources and final destinations are bounded regular files beneath `MOSAIC_HOME`; target and dangling
symlinks are not followed.
- New files have mode `0600`.
- Hard-link publication is same-filesystem, atomic, and no-clobber.
- A temporary path is removed only when this process successfully created it.
- All required source snapshots are validated before the first destination is published, preventing
a missing second default from leaving a partial seed.
- Existing operator files are never chmodded or rewritten.
## Verification
`src/commands/launch-first-start.spec.ts` uses the production-kind boundary: the real built CLI in a
no-TTY subprocess, not a direct wizard test. The package `test:vitest` gate builds Mosaic before
Vitest, while the clean-checkout command builds its workspace dependencies first, so ignored
`dist/cli.js` cannot be absent or stale. A fake lease launcher records whether execution reached the
runtime boundary and captures the composed prompt.
Positive and negative cases prove the check can both proceed and refuse. Fleet composition coverage
replaces a previously validated `USER.md` with an external symlink and proves point-of-use refusal;
a standalone unreadable-optional-USER case proves the portable tolerant branch remains separate.
Real Pi authentication and provider task execution remain environment tests, not claims of this
fixture.
## Non-goals
- Runtime installation or pane-PATH resolution (#1256/#1258).
- The held `~/.mosaic` launch-composition layer in PR #1213.
- Personalizing the operator's standalone identity without a wizard.
- Changing fleet systemd or shell launcher code.
-23
View File
@@ -146,29 +146,6 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
### Unattended fleet first-start amendment (#1264)
`FCM-REQ-11` is reserved by #1256's concurrent runtime-preflight delivery. This amendment therefore
uses the next non-colliding identifiers.
1. `FCM-REQ-12`: A roster-owned fleet launch SHALL NOT invoke an interactive identity wizard when
top-level `SOUL.md` or `USER.md` is absent. It SHALL initialize only missing top-level identity
contracts from the shipped generic `defaults/` contracts without overwriting operator-owned
bytes. The canonical roster member remains the sole source of the seat's exact name and class;
generic defaults grant no fleet identity or authority. Missing or unsafe defaults SHALL fail
closed with actionable diagnostics before runtime execution. Non-fleet launches retain the
interactive identity flow.
2. `AC-FCM-10`: A systemd-equivalent no-TTY test with a clean temporary Mosaic home SHALL prove a
named fleet seat reaches the runtime boundary without starting `mosaic wizard`, creates
byte-equal owner-private `SOUL.md` and `USER.md` seeds, and receives its exact roster name/class in
composed context. Tests SHALL also prove no-clobber behavior, concurrent/idempotent first start,
fail-closed invalid defaults, and preservation of the standalone interactive path.
`ASSUMPTION:` `MOSAIC_AGENT_NAME` is the existing launch discriminator for roster-owned fleet
processes. This amendment does not add a second fleet flag because generated fleet environments
already set that value and the runtime composer independently resolves it against the canonical
roster before execution.
---
## Exact Cross-Harness Fleet Communications Contract (#766)
-4
View File
@@ -38,13 +38,11 @@ These paths remain canonical because current source/tests consume them or becaus
- [Quickstart](USER-GUIDE/getting-started/quickstart.md) — installed-CLI first-use route with local PGlite safety boundaries.
- [Web dashboard](USER-GUIDE/product/web-dashboard.md) — current routes, views, chat persistence, settings, and admin behavior.
- [Discord conversations](USER-GUIDE/workflows/discord-conversations.md) — current authorized parent-channel, thread, attachment, and control workflow.
- [Fleet unattended first start](USER-GUIDE/workflows/fleet-unattended-first-start.md) — no-TTY identity bootstrap and exact roster identity.
## Administrator documentation
- [Administrator operations](ADMIN-GUIDE/operations/README.md) — current local procedures and explicitly held outlines.
- [Upgrade safety and recovery](ADMIN-GUIDE/operations/upgrade-safety-and-recovery.md) — installed-CLI/local-PGlite upgrade and framework recovery.
- [Fleet unattended first-start operations](ADMIN-GUIDE/operations/fleet-unattended-first-start.md) — systemd identity initialization, refusal paths, and isolated verification.
- [Mos connector lease operations](ADMIN-GUIDE/operations/mos-connector-lease-operations.md) — held/non-operative M1 outline while policy remains deny-all.
- [Administrator security](ADMIN-GUIDE/security/README.md) — current security chapter index.
- [SSO providers](ADMIN-GUIDE/security/sso-providers.md) — Authentik, WorkOS, and Keycloak configuration and discovery.
@@ -58,7 +56,6 @@ These paths remain canonical because current source/tests consume them or becaus
- [Lease-broker security](DEVELOPER-GUIDE/architecture/lease-broker-security.md) — identity, ancestry, filesystem, observer, and residual boundaries.
- [Whole mutator-class gate](DEVELOPER-GUIDE/architecture/mutator-class-gate.md) — default-deny tool authorization and launch choke point.
- [Compaction revocation](DEVELOPER-GUIDE/architecture/compaction-revocation.md) — lifecycle observers, generation fencing, and residual stale window.
- [Fleet first-start identity](DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md) — roster authority and atomic no-clobber identity seeding.
- [Architecture decisions](DEVELOPER-GUIDE/architecture/decisions/README.md) — implemented and accepted boundaries.
- [Mos runtime portability M1](DEVELOPER-GUIDE/architecture/decisions/mos-runtime-portability-m1.md) — logical identity, connector lease, grants, audit, and fencing.
- [Architecture RFCs](DEVELOPER-GUIDE/architecture/rfcs/README.md) — draft proposals without operational authority.
@@ -78,7 +75,6 @@ These paths remain canonical because current source/tests consume them or becaus
- [Archived planning](archive/planning/README.md) — historical briefs, board reviews, and work-package specifications.
- [Archived work records](archive/work-records/README.md) — historical task scratchpads without live consumers.
- [P8-003 performance report](reports/qa/p8-003-performance-optimization.md) — historical implementation evidence, not a current SLO.
- [Issue #1264 unattended fleet first-start verification](reports/qa/2026-08-16-1264-unattended-first-start.md) — RED/GREEN no-TTY CLI evidence and explicit untested bounds.
- [Plans index](plans/README.md) — approved intent and implementation/audit plans.
- [Documentation information-architecture design](plans/2026-08-10-docs-information-architecture-design.md) — approved documentation structure decision.
- [Documentation catalog-audit plan](plans/2026-08-10-docs-catalog-audit.md) — evidence method and migration acceptance criteria.
+1 -3
View File
@@ -11,7 +11,6 @@ This book is the canonical home for end-user workflows, user-visible behavior, p
- [Quickstart](getting-started/quickstart.md) — install Mosaic, complete setup, and launch a session.
- [Web dashboard](product/web-dashboard.md) — current routes, navigation, chat persistence, projects/tasks views, settings, and admin behavior.
- [Discord conversations](workflows/discord-conversations.md) — current authorized parent-channel, thread, attachment, and control workflow.
- [Fleet unattended first start](workflows/fleet-unattended-first-start.md) — no-TTY identity bootstrap, exact roster identity, and separate runtime prerequisites.
## Chapter map
@@ -19,7 +18,7 @@ This book is the canonical home for end-user workflows, user-visible behavior, p
| ------------------ | ------------------------------------------------------------- | ---------------------------------------------------- |
| `getting-started/` | First-use setup, orientation, and quickstarts. | Quickstart is current; additional pages are planned. |
| `concepts/` | User-facing terminology, product concepts, and mental models. | Scaffold only. |
| `workflows/` | Task-oriented procedures for using Mosaic Stack. | Discord and fleet first-start workflows are current. |
| `workflows/` | Task-oriented procedures for using Mosaic Stack. | Discord conversation workflow is current. |
| `product/` | Current product surfaces and visible behavior. | Web dashboard reference is current. |
| `troubleshooting/` | User-visible failures, diagnostics, and fixes. | Scaffold only. |
@@ -28,7 +27,6 @@ This book is the canonical home for end-user workflows, user-visible behavior, p
- [Quickstart](getting-started/quickstart.md) — the verified installed-CLI first-use path.
- [Web dashboard](product/web-dashboard.md) — verified current Next.js dashboard behavior and limitations.
- [Discord conversations](workflows/discord-conversations.md) — verified current Discord user workflow.
- [Fleet unattended first start](workflows/fleet-unattended-first-start.md) — verified no-TTY first-start behavior and prerequisite boundaries.
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
@@ -1,61 +0,0 @@
# Fleet Unattended First Start
> **Status:** Current for roster-owned local fleet launches after issue #1264 lands. Runtime
> installation and provider authentication remain separate prerequisites.
A fleet seat started by systemd has no operator at its pane. On its first launch, Mosaic must not
stop at the interactive identity wizard.
## What happens on first start
When `MOSAIC_AGENT_NAME` names an exact member of the installed fleet roster and top-level identity
contracts are absent, the launcher:
1. validates the exact roster member, its canonical class, and the installed fleet communications
helper;
2. reads the shipped generic contracts from
`~/.config/mosaic/defaults/SOUL.md` and `defaults/USER.md`;
3. creates only the missing top-level `SOUL.md` and `USER.md` as owner-private files;
4. preserves any existing top-level identity file byte-for-byte; and
5. launches the runtime with the roster member's exact agent/session name and role/class in composed
context.
The generic defaults do **not** make every seat the same identity. They provide a shared behavioral
base. The canonical roster row supplies each seat's exact name, class, peers, socket, and authority.
## Operator behavior
A normal standalone launch without a fleet identity retains its portable configuration path and still
uses the interactive wizard when `SOUL.md` is absent:
```bash
mosaic pi
```
A roster-owned seat may be started without attaching to its pane:
```bash
mosaic fleet start <exact-roster-name>
```
Mosaic refuses before runtime execution if the requested member is absent, its explicitly supplied
ambient class is blank or conflicts with the roster, a required default is missing or unsafe, or an existing identity contract
is not a safe regular file. Repair the named component and retry the same exact roster member; do not
copy another seat's personalized identity.
## Separate prerequisites
This behavior clears the Mosaic identity-wizard wall only. A clean host still needs:
- the declared runtime installed on the pane PATH;
- the fleet transport and generated unit assets; and
- runtime/provider authentication appropriate to that seat.
Those checks are separate so a successful identity bootstrap is not reported as a fully authenticated
agent session.
## Related
- [Administrator runbook](../../ADMIN-GUIDE/operations/fleet-unattended-first-start.md)
- [Developer architecture](../../DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md)
- [Verification report](../../reports/qa/2026-08-16-1264-unattended-first-start.md)
-3
View File
@@ -12,13 +12,11 @@ Use the canonical guide, API contract, source, and tests to determine current be
- [Issue #756 documentation checklist](documentation/756-discord-plugin-checklist.md) — historical completion checklist for the official Discord plugin workstream.
- [Framework consistency audit — 2026-02-17](documentation/AUDIT-2026-02-17-framework-consistency.md) — historical framework consistency and remediation snapshot.
- [Compaction-refresh #830 checklist](compaction-refresh/830-documentation-checklist.md) — historical incomplete-at-snapshot documentation checklist.
- [Issue #1264 documentation checklist](documentation/1264-documentation-checklist.md) — current in-repo user/admin/developer/report coverage and review gate.
## Code-review evidence
- [Issue #756 independent code review](code-review/756-code-review.md) — historical exact-scope review of the official Discord plugin workstream.
- [Gateway security-hardening code review — 2026-03-13](code-review/gateway-security-20260313.md) — historical no-blocker review snapshot.
- [Issue #1264 independent code and security review](code-review/1264-code-review.md) — initial finding, remediation, clean re-review, and remaining formal PR-review gate.
## Security evidence
@@ -28,7 +26,6 @@ Use the canonical guide, API contract, source, and tests to determine current be
- [P8-003 performance optimization report](qa/p8-003-performance-optimization.md) — historical implementation evidence; not a current SLO or production benchmark.
- [Gateway security-hardening QA report — 2026-03-13](qa/gateway-security-20260313.md) — historical test report with its original live-smoke-test limitation.
- [Issue #1264 unattended fleet first-start verification](qa/2026-08-16-1264-unattended-first-start.md) — RED/GREEN no-TTY CLI evidence, baseline gates, and explicit real-provider limitation.
## Native Kanban/SOT evidence
@@ -1,97 +0,0 @@
# Issue #1264 Code and Security Review
> Branch: `fix/1264-fleet-unattended-first-start` | Base:
> `origin/next@476db12b92971634b67fd2057b7577ee5894e449`
## Initial automated review
Codex reviewed the pre-PR uncommitted delta with:
```bash
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted \
-o /tmp/1264-codex-code-review.json
```
Result: `request-changes`, confidence `0.93`, 20 files, one should-fix. `checkSoul()` trimmed
`MOSAIC_AGENT_NAME` for pre-seed resolution while composition used the original value, so a padded
name could seed files before later refusal.
Remediation rejected blank/leading/trailing-whitespace values before roster lookup or writes and
added three built-CLI no-side-effect regressions. Automated re-review approved that delta with no
findings (confidence `0.86`). Initial security review reported risk `none` (confidence `0.91`).
## Formal exact-head review
Daphne reviewed PR #1268 at exact head `43fa0477877e0d0f110da8d11c3033b40ddeb191` and filed Gitea
review ID 168 as `REQUEST_CHANGES`. The review was source/PR-only; the canary remained untouched.
Blocking groups:
1. class mismatch was validated after first-start mutation;
2. secure `USER.md` validation was discarded before ordinary path-following composition;
3. `existsSync()` treated a dangling destination symlink as missing, allowing counterpart partial
publication; and
4. the built-CLI/evidence chain allowed stale ignored `dist/`, cited an unshipped canary object, and
carried conflicting test totals/pane wording.
The diagnostic's defaults-only repair advice was also inaccurate for roster/class/destination
failures.
## Formal-review remediation
All four blocking groups received regressions before production changes. The RED run produced four
failures while 1,568 existing tests passed. Remediation then:
- validates canonical name and class before seeding;
- preflights destination directory entries with `lstatSync()` so target and dangling symlinks fail
before publication;
- securely reads `USER.md` through an `O_NOFOLLOW` descriptor at composition time;
- adds a Mosaic build before package Vitest and a dependency build in the clean-checkout command;
- replaces defaults-only advice with neutral named-component repair guidance; and
- reconciles shipping canary provenance, pane chronology, commands, and totals.
Remediation code review:
```bash
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted \
-o /tmp/1264-remediation-code-review.json
```
Result: `approve`, confidence `0.88`, 6 files, no findings. Summary: the fail-closed destination
checks, class-validation order, secure composition, and build-before-Vitest path are coherent.
Remediation security review:
```bash
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted \
-o /tmp/1264-remediation-security-review.json
```
Result: risk `none`, confidence `0.93`, 9 files, no critical/high/medium/low findings. The sandbox
could not run Vitest because Vite attempted to create a temporary config artifact on its read-only
mount (`EROFS`); executor-owned focused and full results are recorded in the QA report.
## Second exact-head review
Daphne reviewed exact head `9dc90be7e13b1cd609f6df97d43d890ef5392ca0` and filed Gitea review
ID 169 as `REQUEST_CHANGES`. Review 169 confirmed all review-168 closures, then found:
1. the new point-of-use reader was Linux-only but had been applied to every standalone USER read,
breaking supported non-fleet macOS composition; and
2. explicit blank/whitespace `MOSAIC_AGENT_CLASS` was treated as absent and could seed before
runtime, while only undefined should mean absent.
Red-first remediation preserves legacy `readOptional()` for standalone composition, keeps descriptor
no-follow consumption fleet-only, moves the replacement-symlink case under a valid fleet identity,
and rejects defined blank/whitespace classes before seeding. Three blank-class CLI cases and one
tolerant standalone composition case failed before the source change and pass after it.
Review-169 remediation code review approved at confidence `0.90` (4 files, no findings). Security
review reported risk `none` at confidence `0.90` (4 files, no findings). The review sandbox retained
its known Vite `EROFS` limitation; executor-owned tests are in the QA report.
## Remaining review gate
Daphne must re-review the next exact pushed head. This report cannot record that future verdict
without changing the reviewed head, so the authoritative terminal verdict belongs to PR #1268's
Gitea review record. Fred and goals are excluded as reviewers.
@@ -1,32 +0,0 @@
# #1264 Documentation Completion Checklist
## Required artifacts
- [x] `docs/PRD.md` updated with `FCM-REQ-12` and `AC-FCM-10`.
- [x] User workflow documents unattended fleet first start and separate prerequisites.
- [x] Administrator operations page documents source/destination ownership, failure handling, and an
exact-source build-before-Vitest verification gate.
- [x] Developer architecture page documents control flow, identity authority, concurrency, and non-goals.
- [x] `docs/SITEMAP.md` and book indexes updated.
- [x] QA evidence is under `docs/reports/qa/`; working notes are under `docs/scratchpads/`.
- [x] Framework defaults README reflects fleet-versus-standalone behavior.
## API coverage
- [x] No HTTP/API endpoint or DTO changed; OpenAPI and endpoint indexes are not applicable.
## Structural standards
- [x] User, administrator, developer, report, and sitemap indexes link the new pages.
- [x] No noncanonical file was added at the `docs/` root.
- [x] Canonical documentation remains in-repo; no external publication was requested or performed.
## Review gate
- [x] Initial padded-name finding remediated and automated re-review approved.
- [x] Daphne formal review ID 168 completed on exact first head `43fa0477` and requested changes.
- [x] Four review-168 groups reproduced red and remediated; automated reviews are clean.
- [x] Daphne review ID 169 completed on exact head `9dc90be7` and confirmed review-168 closures.
- [x] Review-169 standalone-portability and blank-class blockers reproduced red and remediated;
automated reviews are clean.
- [ ] Daphne exact-second-remediation-head re-review completed after push (Fred/goals excluded).
@@ -1,220 +0,0 @@
# #1264 Unattended Fleet First-Start Verification
> Status: **IN PROGRESS — review-169 remediation complete locally; push/re-review pending** |
> Executor: goals | Date: 2026-08-16 | Target: isolated local fixtures only
## Objective
Verify that a named fleet seat launched through a systemd-equivalent, no-TTY environment on a clean
host reaches its runtime boundary without an interactive Mosaic identity wizard. Preserve standalone
wizard behavior and canonical-roster ownership of exact seat identity.
## Source evidence accepted for local verification
Daphne's canary Run-7 report is reachable from jarvis-brain `origin/main` at
`8bf94afeb8c7d5df96cdd4a4508e75a1d2999710`,
`docs/reports/2026-08-16_sbx-canary-greenfield-e2e.md`. The earlier local object
`6c0b6fc70ae6a179a1b7ff9dedfc54e9adccd19a` is not reachable from an origin ref and is not used as
shipping provenance. Run 7 measured:
```text
systemd -> start-agent-session.sh -> mosaic yolo pi (PID 3726)
-> child mosaic wizard (PID 3762)
```
The pane was preserved when Run 7 was captured. Formal review ID 168 records that an authorized
rollback occurred later. This task never accessed or altered the canary VM, pane, snapshot, or
rollback state. Product behavior is independently tested here with temporary roots and fake runtime
executables.
## Controls
- Original base: `origin/next@476db12b92971634b67fd2057b7577ee5894e449`.
- PR: #1268, first pushed head `43fa0477877e0d0f110da8d11c3033b40ddeb191`.
- `DATABASE_URL` remains unset for local tests.
- No runtime/provider credential or token value, VM, installed Mosaic tree, unit, timer, PATH profile,
or live tmux session is read or mutated. Standard Gitea/Woodpecker wrappers authenticate metadata
reads/writes without exposing credential values.
- Tiny's runtime-preflight and `start-agent-session.sh` PATH work remain out of scope.
- Held PR #1213 is not a dependency.
## Requirements-to-evidence map
| Acceptance criterion | Method | Evidence |
| ---------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------- |
| No-TTY fleet first start avoids wizard and reaches runtime | Exact-source built CLI with piped stdin | CLI GREEN |
| Missing top-level identity files are initialized from shipped defaults | Exact-byte and `0600` assertions | CLI + filesystem GREEN |
| Exact seat identity remains roster-owned | Captured argv; mismatched/blank class no-side-effect refusals | CLI GREEN |
| Existing operator identity is never overwritten | Custom bytes/mode with defaults removed | CLI + filesystem GREEN |
| Concurrent/repeated first start is safe | Four parallel CLIs plus repeated launch | CLI GREEN |
| Missing/unsafe defaults and destinations fail before partial mutation | Missing, target/dangling symlink, oversized, invalid-root cases | Filesystem/CLI GREEN |
| Validated `USER.md` cannot be replaced by an external symlink | Seed, replace, compose at point of use | Composition GREEN |
| Standalone launch retains wizard | Same built CLI without fleet identity | CLI GREEN |
| Built-CLI evidence cannot use stale ignored `dist/` | Build-with-dependencies gate before Vitest | Package script + command gate |
## Initial RED
Production source remained unchanged after adding the first reproducer. The CLI was built from
`origin/next@476db12` before the test.
```bash
env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic exec vitest run \
src/commands/launch-first-start.spec.ts
```
Exit `1`; one file and one test failed. Output included:
```text
[mosaic] SOUL.md not found. Running setup wizard...
◆ What would you like to do?
[mosaic] Setup failed. Run: mosaic wizard
AssertionError: expected 1 to be +0
```
The fake runtime-boundary capture was not created. Complete stdout/stderr was retained at
`/tmp/1264-red.out` during that work session.
## Formal-review remediation RED
Daphne's exact-head review ID 168 requested changes at `43fa0477`. Before changing production code,
new regressions were run against an exact-source build. Four tests failed while the existing 1,568
passed:
1. valid roster name plus mismatched ambient class seeded both files before refusal;
2. dangling `SOUL.md` allowed `USER.md` to be published before refusal;
3. dangling `USER.md` allowed `SOUL.md` to be published before refusal; and
4. replacing a securely validated `USER.md` with an external symlink was followed by composition.
This establishes that all four review-168 findings were observable on the pushed implementation.
Daphne's review ID 169 then found two more exact-head failures at `9dc90be7`. Before production
changes, four new assertions failed:
1. standalone composition routed an unreadable optional `USER.md` through the Linux-only descriptor
reader instead of the legacy portable tolerant path; and
2. explicit `MOSAIC_AGENT_CLASS` values `""`, `" "`, and tab were treated as absent, seeded both
identity files, and reached runtime.
The replacement-symlink case was also moved under a valid roster identity so it tests the fleet-only
security boundary rather than standalone behavior.
## Final GREEN
The production-kind command builds Mosaic and all workspace dependencies before invoking Vitest,
because `dist/` is ignored and may otherwise be absent or stale:
```bash
env -u DATABASE_URL sh -c '
pnpm --filter @mosaicstack/mosaic... build &&
pnpm --filter @mosaicstack/mosaic exec vitest run \
src/commands/fleet-first-start-identity.spec.ts \
src/commands/launch-first-start.spec.ts \
src/commands/launch.spec.ts \
src/commands/compose-contract.spec.ts \
src/config/file-adapter.test.ts \
src/cli-smoke.spec.ts
'
```
Exit `0`: `6/6` files, `128/128` tests.
- 15 real-CLI/no-TTY tests cover exact roster name/class, byte-equal `0600` seeds, no-clobber,
partial seed, missing/symlink defaults, unknown/padded/blank name, mismatched or explicitly blank
class, standalone wizard preservation, and four concurrent starts.
- 12 direct filesystem tests cover complete publication, existing operators, idempotence, source
prevalidation, target and dangling destination links, invalid roots, oversized input, and
unexpected link errors.
- Composition coverage deterministically replaces a valid fleet seat's validated `USER.md` with an
external symlink and requires refusal at point of use. A separate standalone case proves tolerant
optional composition remains outside the Linux-only fleet reader.
Full package gate (which rebuilds Mosaic itself after the clean-checkout dependency build):
```bash
env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic run test:vitest
```
Exit `0`: `88/88` files, `1,577/1,577` tests.
Focused helper + point-of-use coverage:
```text
2 files, 53/53 tests
Statements 97.84% | Branches 91.66% | Functions 100% | Lines 97.84%
Exit 0
```
Final repository gates after remediation:
```text
pnpm preflight exit 0
pnpm typecheck 45/45 tasks, exit 0
pnpm lint 25/25 tasks, exit 0
pnpm build 25/25 tasks, exit 0
pnpm format:check exit 0
git diff --check exit 0
```
Pre-PR targeted shell runs on the unchanged shell surfaces also passed:
```text
bash framework/tools/fleet/test-start-agent-session.sh exit 0 locally
bash framework/tools/quality/scripts/test-install-migration.sh 21 passed, 0 failed
bash framework/tools/_scripts/test-mosaic-init-rce.sh PASS
```
The aggregate local `test:framework-shell` run stopped at `invariant_r_unittest.py`: installed
operator-global Pi is `0.84.2`, while the invariant is measured for `0.84.1`. Later aggregate stages
remain unmeasured except the targeted suites above. Root `pnpm test` remains locally **UNTESTED**
because this checkout prohibits the PostgreSQL-dependent gateway isolation path.
## Review and security evidence
- Initial Codex review found padded-name mutation-before-refusal; it was fixed with three
no-side-effect regressions.
- Codex review of the formal-review remediation: `approve`, confidence `0.88`, 6 files, no findings.
- Codex security review of the remediation: risk `none`, confidence `0.93`, 9 files, no findings.
Its sandbox could not execute Vitest because Vite attempted a write on a read-only mount; the
executor-owned results above are the test evidence.
- Daphne formal review ID 168 at exact head `43fa0477`: `REQUEST_CHANGES`, four blocking groups; all
closed by review 169.
- Daphne formal review ID 169 at exact head `9dc90be7`: `REQUEST_CHANGES`, two blocking groups
(standalone portability and explicit blank class). Both now have red-first regressions and local
green remediation.
- Codex review of review-169 remediation: `approve`, confidence `0.90`, 4 files, no findings.
- Codex security review of review-169 remediation: risk `none`, confidence `0.90`, 4 files, no
findings. Exact-new-head Daphne re-review is pending until that head is pushed.
## CI evidence and external blocker
Pipeline 2445 ran against exact first head `43fa0477`:
- install, sanitization, upgrade guard, typecheck, lint, and format passed;
- Mosaic Vitest passed `88/88`, `1,568/1,568`; and
- the test step emitted exactly one `FAIL:` line:
```text
FAIL: host provides 'pi' in the system path; missing-binary cases are not measurable here
```
That line comes from the inherited `test-start-agent-session.sh` CI-fit guard, not #1264. Fred filed
the correction as PR #1270. Its pipeline 2448 is terminal green and proves the four formerly masked
suites execute, but #1270 is not merged, so `next` still carries the failing chain. A new #1268
pipeline 2449 at `9dc90be7` reproduced the same single inherited `FAIL:` after Mosaic passed
`1,573/1,573`. A new pipeline is pending the review-169 remediation push. Terminal-green #1268 CI is
not claimed.
PR #1268's envelope was read back as `user.login=mos-dt-0`; its commit is explicitly authored and
committed by `goals <[email protected]>`. No goals Gitea login exists on this host, and no
other principal was borrowed. The cross-wrapper principal defect is tracked in #1272.
## Explicitly untested
- Canary VM remediation/restart: **UNTESTED and prohibited**.
- Real Pi authentication/provider prompt and task execution: **UNTESTED**.
- PR #1213 composition layer: **UNTESTED and not required**.
- Deployment/published npm behavior: **UNTESTED until merge/release**.
- Local PostgreSQL execution/migration: **UNTESTED and prohibited**.
The local gate proves Mosaic crosses its identity boundary and reaches a fake lease-runtime boundary;
it does not claim provider readiness, deployment, or a currently running canary seat.
@@ -1,104 +0,0 @@
# #1264 — Unattended fleet first start
## Tracking
- Issue: `mosaicstack/stack#1264`
- PR: `mosaicstack/stack#1268`
- Branch: `fix/1264-fleet-unattended-first-start`
- Base: `origin/next@476db12b92971634b67fd2057b7577ee5894e449`
- First pushed head: `43fa0477877e0d0f110da8d11c3033b40ddeb191`
- Current remediation worktree: `/var/home/jason.woltje/agent-work/1264-review2-remediation`
- Coordinator: Fred; reviewer must be neither Fred nor this implementation seat.
- `docs/TASKS.md` is orchestrator-owned and is not modified by this worker.
The original `/var/home/jason.woltje/agent-work/1264-unattended-first-start` and first remediation
worktrees were removed without force after each pushed head and clean state were verified. The
Fred-authorized plain-Git worktree exception was reused for exact-head review remediation because
`/src` remains unavailable.
## Objective
A roster-owned fleet seat launched from systemd on a clean host must cross Mosaic's first-run identity
gate without a human or TTY, while retaining exact name/class from the canonical roster and
preserving the standalone interactive wizard.
## Intake and boundaries
- Shipping canary provenance is jarvis-brain `origin/main` commit
`8bf94afeb8c7d5df96cdd4a4508e75a1d2999710`. The earlier local `6c0b6fc...` object is not used.
- The Run-7 pane was preserved when evidence was captured; formal review records a later authorized
rollback. This task never accessed or altered the canary.
- Tiny's concurrent runtime-preflight, `start-agent-session.sh`, and #1258 PATH seam remain untouched.
- Held PR #1213 is not a dependency.
- No runtime/provider credential values or provider calls, installed-host changes, PostgreSQL, unit,
timer, or profile mutation. Tests use temporary roots and fake executables only; Gitea/Woodpecker
metadata operations use standard wrappers without exposing credentials.
## Requirements and design
- PRD IDs: `FCM-REQ-12`, `AC-FCM-10`; `FCM-REQ-11` is reserved by #1256.
- A present fleet name must be nonblank, whitespace-exact, and resolve through the canonical roster.
- Any defined ambient class, including blank/whitespace, must canonicalize to the roster class before
mutation; only undefined means absent.
- Preflight all destination directory entries with no-follow existence semantics so dangling links
fail before counterpart publication.
- Seed only missing top-level files from bounded regular defaults with owner-private, atomic,
no-clobber hard links.
- Generic defaults are behavior, not identity or authority.
- Securely consume fleet `USER.md` through a Linux descriptor at composition time.
- Standalone composition retains the portable tolerant USER read and missing identity retains the
wizard.
## Progress
- [x] Issue, canary report, Tiny collision state, and PRD read/amended.
- [x] Initial production-kind RED captured with a real built CLI and no TTY.
- [x] Implementation, tests, user/admin/developer docs, QA, and indexes delivered.
- [x] Initial automated review finding (padded name before write) remediated.
- [x] Commit `43fa0477` pushed; PR #1268 opened against `next`; original worktree removed cleanly.
- [x] Daphne formal review ID 168 completed on exact first head: `REQUEST_CHANGES` with four groups.
- [x] All four review-168 groups reproduced red before remediation and passed at `9dc90be7`.
- [x] Daphne review ID 169 completed on `9dc90be7`: review-168 closures confirmed; two new blockers.
- [x] Review-169 portability and blank-class blockers reproduced red and now pass locally.
- [x] Review-169 Codex review approved; security review risk `none`.
- [ ] Commit/push second remediation with explicit goals author/committer; verify remote object/content.
- [ ] Daphne exact-new-head re-review.
- [ ] Terminal #1268 CI. Pipeline 2445's only `FAIL:` was the inherited Pi-PATH CI-fit guard; PR
#1270's pipeline 2448 is green, but #1270 is not merged.
- [ ] Remove the clean remediation worktree after push.
## Test evidence
### Initial RED
The built `origin/next` CLI entered `mosaic wizard`, rendered `What would you like to do?`, exited 1,
and never created the fake runtime-boundary capture.
### Formal-review RED
Against exact first-head production code, four new tests failed while 1,568 existing tests passed:
class mismatch mutated before refusal; each dangling destination left its counterpart; and a
replacement `USER.md` symlink was consumed by composition. Review-169 RED then proved standalone
composition hit the Linux-only reader and three explicit blank/whitespace class cases seeded and
launched.
### Final local GREEN
- Exact-source focused gate: `6/6` files, `128/128` tests.
- Full exact-source Mosaic Vitest: `88/88` files, `1,577/1,577` tests.
- Helper + point-of-use coverage: `53/53`; 97.84% statements/lines, 91.66% branches, 100% functions.
- Root preflight passed; typecheck `45/45`, lint `25/25`, build `25/25`.
- Initial targeted shell gates passed: start-agent-session, install migration `21/21`, init-RCE.
- Local aggregate framework shell stops at operator-global Pi `0.84.2` versus measured `0.84.1`.
- Local root `pnpm test` remains unrun because the checkout prohibits its PostgreSQL-dependent path.
The full evidence and command boundaries are in
`docs/reports/qa/2026-08-16-1264-unattended-first-start.md`.
## Review / delivery notes
- Review-168 remediation Codex review: approve `0.88`; security risk `none` `0.93`.
- Review-169 remediation Codex review: approve `0.90`; security risk `none` `0.90`.
- PR envelope reads `mos-dt-0`; the commit reads goals/goals. No goals Gitea principal exists on this
host, so no other principal will be borrowed. Tracked in #1272.
- PR #1270 is pushed, not merged. Do not represent `next` or #1268 CI as green until measured.
-4
View File
@@ -7,10 +7,6 @@
- [DOCS-IA-001 — information architecture](DOCS-IA-001.md) — completed structure-design and documentation-contract record.
- [DOCS-IA-002 — catalog audit and migration](DOCS-IA-002-catalog-audit.md) — active coordinator progress, autonomous lane state, verification evidence, and authority blockers.
## Active implementation records
- [Issue #1264 — unattended fleet first start](1264-unattended-first-start.md) — plan, RED/GREEN evidence, collision boundaries, and PR lifecycle state.
Completed scratchpads may remain here when they provide useful delivery provenance. Their conclusions must be reflected in the owning canonical page before the scratchpad is treated as complete.
## Related
-253
View File
@@ -1,253 +0,0 @@
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { normalizeGate, countAIFindings, runGate, runGates } from '../src/gate-runner.js';
function makeTmpDir(): string {
const dir = join(tmpdir(), `macp-gate-${randomUUID()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe('normalizeGate', () => {
it('normalizes a string to mechanical gate', () => {
expect(normalizeGate('echo test')).toEqual({
command: 'echo test',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('normalizes an object gate with defaults', () => {
expect(normalizeGate({ command: 'lint' })).toEqual({
command: 'lint',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('preserves explicit type and fail_on', () => {
expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({
command: 'review',
type: 'ai-review',
fail_on: 'any',
});
});
it('handles non-string/non-object input', () => {
expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
});
});
describe('countAIFindings', () => {
it('returns zeros for non-object', () => {
expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 });
});
it('counts from stats block', () => {
const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } };
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 });
});
it('counts from findings array when stats has no blockers', () => {
const output = {
stats: { blockers: 0 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 });
});
it('uses stats blockers over findings array when stats has blockers', () => {
const output = {
stats: { blockers: 5 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }],
};
// stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero
expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 });
});
it('counts findings length as total when stats has zero total', () => {
const output = {
findings: [{ severity: 'warning' }, { severity: 'info' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 });
});
});
describe('runGate', () => {
let tmp: string;
let logPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = join(tmp, 'gate.log');
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('passes mechanical gate on exit 0', () => {
const result = runGate('echo hello', tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.exit_code).toBe(0);
expect(result.type).toBe('mechanical');
expect(result.output).toContain('hello');
});
it('fails mechanical gate on non-zero exit', () => {
const result = runGate('exit 1', tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(1);
});
it('ci-pipeline always passes', () => {
const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.type).toBe('ci-pipeline');
expect(result.output).toBe('CI pipeline gate placeholder');
});
it('empty command passes', () => {
const result = runGate({ command: '' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
});
it('ai-review gate parses JSON output', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.blockers).toBe(0);
expect(result.findings).toBe(1);
});
it('ai-review gate fails on blockers', () => {
const json = JSON.stringify({ stats: { blockers: 2 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.blockers).toBe(2);
});
it('ai-review gate with fail_on=any fails on any findings', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate(
{ command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' },
tmp,
logPath,
30,
);
expect(result.passed).toBe(false);
expect(result.fail_on).toBe('any');
});
it('ai-review gate fails on invalid JSON output', () => {
const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.parse_error).toBeDefined();
});
it('writes to log file', () => {
runGate('echo logged', tmp, logPath, 30);
const log = readFileSync(logPath, 'utf-8');
expect(log).toContain('COMMAND: echo logged');
expect(log).toContain('logged');
expect(log).toContain('EXIT:');
});
});
describe('runGates', () => {
let tmp: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = join(tmp, 'gates.log');
eventsPath = join(tmp, 'events.ndjson');
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('runs multiple gates and returns results', () => {
const { allPassed, gateResults } = runGates(
['echo one', 'echo two'],
tmp,
logPath,
30,
eventsPath,
'task-1',
);
expect(allPassed).toBe(true);
expect(gateResults).toHaveLength(2);
});
it('reports failure when any gate fails', () => {
const { allPassed, gateResults } = runGates(
['echo ok', 'exit 1'],
tmp,
logPath,
30,
eventsPath,
'task-2',
);
expect(allPassed).toBe(false);
expect(gateResults[0]!.passed).toBe(true);
expect(gateResults[1]!.passed).toBe(false);
});
it('emits events for each gate', () => {
runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3');
const events = readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
expect(events).toHaveLength(2); // started + passed
expect(events[0].event_type).toBe('rail.check.started');
expect(events[1].event_type).toBe('rail.check.passed');
});
it('skips gates with empty command (non ci-pipeline)', () => {
const { gateResults } = runGates(
[{ command: '', type: 'mechanical' }, 'echo real'],
tmp,
logPath,
30,
eventsPath,
'task-4',
);
expect(gateResults).toHaveLength(1);
});
it('does not skip ci-pipeline even with empty command', () => {
const { gateResults } = runGates(
[{ command: '', type: 'ci-pipeline' }],
tmp,
logPath,
30,
eventsPath,
'task-5',
);
expect(gateResults).toHaveLength(1);
expect(gateResults[0]!.passed).toBe(true);
});
it('emits failed event with correct message', () => {
runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6');
const events = readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
const failEvent = events.find(
(e: Record<string, unknown>) => e.event_type === 'rail.check.failed',
);
expect(failEvent).toBeDefined();
expect(failEvent.message).toContain('Gate failed (');
});
});
+163 -1
View File
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { Command } from 'commander';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { registerMacpCommand } from './cli.js';
describe('registerMacpCommand', () => {
@@ -75,3 +78,162 @@ describe('registerMacpCommand', () => {
expect(topLevel).toContain('events');
});
});
/**
* RI-N2 fail-closed CLI behavior: an unimplemented capability is a failure,
* never a success. Every stub exits nonzero with a typed message, and the
* implemented `macp gate` mirrors the typed gate-runner states.
*/
describe('registerMacpCommand fail-closed (RI-N2)', () => {
let tmpDir: string;
function buildProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeErr: () => {} });
registerMacpCommand(program);
return program;
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'macp-cli-failclosed-'));
process.exitCode = 0;
});
afterEach(() => {
process.exitCode = 0;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('macp tasks list exits nonzero (unimplemented capability)', async () => {
const program = buildProgram();
await program.parseAsync(['macp', 'tasks', 'list'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
});
it('macp submit exits nonzero with a typed MACP_NOT_IMPLEMENTED message', async () => {
const program = buildProgram();
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await program.parseAsync(['macp', 'submit', 'spec.json'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
const errText = errSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(errText).toContain('MACP_NOT_IMPLEMENTED');
} finally {
errSpy.mockRestore();
}
});
it('macp events tail exits nonzero (unimplemented capability)', async () => {
const program = buildProgram();
await program.parseAsync(['macp', 'events', 'tail'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
});
it('macp gate runs a green inline command and exits 0', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
'exit 0',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).toBe(0);
});
it('macp gate exits nonzero on a failing command', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
'exit 9',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
it('macp gate with an unimplemented ci-pipeline capability exits nonzero', async () => {
const program = buildProgram();
const specPath = path.join(tmpDir, 'gates.json');
fs.writeFileSync(specPath, JSON.stringify([{ type: 'ci-pipeline' }]));
await program.parseAsync(
[
'macp',
'gate',
specPath,
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
it('macp gate --simulate completes (exit 0) but reports simulated results', async () => {
const program = buildProgram();
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await program.parseAsync(
[
'macp',
'gate',
'exit 0',
'--simulate',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
// completes only because the caller explicitly asked to simulate
expect(process.exitCode).toBe(0);
const outText = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(outText).toContain('simulated');
expect(outText).toContain('SIMULATED');
} finally {
logSpy.mockRestore();
}
});
it('macp gate with an empty spec exits nonzero with a typed error', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
' ',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
});
+129 -19
View File
@@ -1,5 +1,73 @@
import { existsSync, readFileSync } from 'node:fs';
import type { Command } from 'commander';
import { runGates } from './gate-runner.js';
import { MACPCapabilityError, type MacpErrorCode } from './errors.js';
/**
* Load gates from a spec: an existing file (JSON gates array, a JSON object
* with `quality_gates`, a JSON gate object, or one command per line) or an
* inline command string. Fails closed with a typed capability error when the
* spec contains no executable gate definition.
*/
function loadGateSpec(spec: string): unknown[] {
if (existsSync(spec)) {
const raw = readFileSync(spec, 'utf-8');
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
if (parsed.length === 0) {
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' contains an empty gates array`,
);
}
return parsed;
}
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>;
if (Array.isArray(obj['quality_gates'])) {
return obj['quality_gates'];
}
return [parsed];
}
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' parsed to ${typeof parsed} — expected a gates array, a task with quality_gates, or a gate object`,
);
} catch (exc) {
if (exc instanceof MACPCapabilityError) throw exc;
// Not JSON — treat each non-empty line as a command gate.
const lines = raw
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length > 0) return lines;
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' contains no gates`,
);
}
}
if (spec.trim().length > 0) return [spec];
throw new MACPCapabilityError('MACP_NO_COMMAND', 'gate-spec', 'gate spec is empty');
}
/** Print a typed not-implemented failure and exit nonzero (RI-N2 fail-closed). */
function notImplemented(subcommand: string, capability: string, hint: string): void {
const err = new MACPCapabilityError(
'MACP_NOT_IMPLEMENTED',
capability,
`${subcommand} is not implemented in @mosaicstack/macp yet (${capability} capability absent) — ${hint}`,
);
console.error(`[macp] ${subcommand}: ${err.message} [${err.code}]`);
process.exitCode = 1;
}
/**
* Register macp subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the
@@ -24,15 +92,14 @@ export function registerMacpCommand(parent: Command): void {
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
)
.action((opts: { status?: string; type?: string }) => {
// not yet wired — task persistence layer is not present in @mosaicstack/macp
console.log('[macp] tasks list: not yet wired — use macp package programmatically');
// unimplemented capability — a failure, never a success (RI-N2)
if (opts.status) {
console.log(` status filter: ${opts.status}`);
}
if (opts.type) {
console.log(` type filter: ${opts.type}`);
}
process.exitCode = 0;
notImplemented('tasks list', 'task-persistence', 'use the macp package programmatically');
});
// ─── submit ──────────────────────────────────────────────────────────────
@@ -41,12 +108,11 @@ export function registerMacpCommand(parent: Command): void {
.command('submit <path>')
.description('Submit a task from a JSON/YAML spec file')
.action((specPath: string) => {
// not yet wired — task submission requires a running MACP server
console.log('[macp] submit: not yet wired — use macp package programmatically');
// unimplemented capability — a failure, never a success (RI-N2)
console.log(` spec path: ${specPath}`);
console.log(' task id: (unavailable — no MACP server connected)');
console.log(' status: (unavailable — no MACP server connected)');
process.exitCode = 0;
notImplemented('submit', 'macp-server', 'use the macp package programmatically');
});
// ─── gate ────────────────────────────────────────────────────────────────
@@ -58,16 +124,58 @@ export function registerMacpCommand(parent: Command): void {
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
.action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => {
// not yet wired — gate execution requires a task context and event sink
console.log('[macp] gate: not yet wired — use macp package programmatically');
console.log(` spec: ${spec}`);
console.log(` fail-on: ${opts.failOn}`);
console.log(` cwd: ${opts.cwd}`);
console.log(` log: ${opts.log}`);
console.log(` timeout: ${opts.timeout}s`);
process.exitCode = 0;
});
.option(
'--simulate',
'Simulate gates instead of executing them; results are typed simulated and never satisfy a check',
)
.action(
(
spec: string,
opts: { failOn: string; cwd: string; log: string; timeout: string; simulate?: boolean },
) => {
let gates: unknown[];
try {
gates = loadGateSpec(spec);
} catch (exc) {
if (exc instanceof MACPCapabilityError) {
console.error(`[macp] gate: ${exc.message} [${exc.code}]`);
} else {
console.error(`[macp] gate: ${String(exc)}`);
}
process.exitCode = 1;
return;
}
const timeoutSec = Number.parseInt(opts.timeout, 10) || 60;
const eventsPath = `${opts.log}.events.ndjson`;
const { state, gateResults } = runGates(
gates,
opts.cwd,
opts.log,
timeoutSec,
eventsPath,
'macp-cli-gate',
{
simulate: opts.simulate,
},
);
for (const r of gateResults) {
const label = r.command || r.type;
const reason = r.reason ? `${r.reason}` : '';
console.log(`[macp] gate ${r.status}: ${label}${reason}`);
}
if (opts.simulate) {
console.log(
'[macp] SIMULATED run — every result is typed simulated and can never satisfy a gate, dependency, or release check',
);
}
// Simulated runs may complete (exit 0) only because the caller
// explicitly passed --simulate; the typed state stays 'simulated'.
process.exitCode = state === 'passed' || state === 'simulated' ? 0 : 1;
},
);
// ─── events ──────────────────────────────────────────────────────────────
@@ -79,14 +187,16 @@ export function registerMacpCommand(parent: Command): void {
.option('--file <path>', 'Path to the MACP events NDJSON file')
.option('--follow', 'Follow the file for new events (like tail -f)')
.action((opts: { file?: string; follow?: boolean }) => {
// not yet wired — event streaming requires a live event source
console.log('[macp] events tail: not yet wired — use macp package programmatically');
// unimplemented capability — a failure, never a success (RI-N2)
if (opts.file) {
console.log(` file: ${opts.file}`);
}
if (opts.follow) {
console.log(' mode: follow');
}
process.exitCode = 0;
notImplemented('events tail', 'event-source', 'use the macp package programmatically');
});
}
// Re-export so CLI consumers can surface typed capability codes.
export type { MacpErrorCode };
+35
View File
@@ -0,0 +1,35 @@
/** Typed error code from the closed MACP_ERROR_CODES set. */
export type MacpErrorCode = (typeof MACP_ERROR_CODES)[number];
/**
* Typed fail-closed capability errors (RI-N2, SDLC-D-035).
*
* MACP must fail closed when a required capability (executor, reviewer,
* command, CI provider, human authority) is absent. These typed codes mirror
* the Forge failure vocabulary (FORGE_NO_*) so both packages speak the same
* language: an unimplemented capability is a failure, never a stub success.
*/
/** Closed set of typed MACP capability error codes. */
export const MACP_ERROR_CODES = [
'MACP_NOT_IMPLEMENTED',
'MACP_NO_COMMAND',
'MACP_NO_REVIEWER',
'MACP_NO_CI_PIPELINE',
'MACP_NO_PROVIDER',
'MACP_AUTHORITY_REQUIRED',
] as const;
/** Raised when a required capability is missing and execution must fail closed. */
export class MACPCapabilityError extends Error {
/** Typed error code from the closed MACP_ERROR_CODES set. */
readonly code: MacpErrorCode;
/** The missing capability, e.g. `ci-provider`, `task-persistence`, `command`. */
readonly capability: string;
constructor(code: MacpErrorCode, capability: string, message: string) {
super(message);
this.name = 'MACPCapabilityError';
this.code = code;
this.capability = capability;
}
}
+429
View File
@@ -0,0 +1,429 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { countAIFindings, normalizeGate, runGate, runGates } from './gate-runner.js';
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'macp-gate-'));
}
describe('normalizeGate', () => {
it('normalizes a string to mechanical gate', () => {
expect(normalizeGate('echo test')).toEqual({
command: 'echo test',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('normalizes an object gate with defaults', () => {
expect(normalizeGate({ command: 'lint' })).toEqual({
command: 'lint',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('preserves explicit type and fail_on', () => {
expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({
command: 'review',
type: 'ai-review',
fail_on: 'any',
});
});
it('handles non-string/non-object input', () => {
expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
});
});
describe('countAIFindings', () => {
it('returns zeros for non-object', () => {
expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 });
});
it('counts from stats block', () => {
const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } };
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 });
});
it('counts from findings array when stats has no blockers', () => {
const output = {
stats: { blockers: 0 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 });
});
it('uses stats blockers over findings array when stats has blockers', () => {
const output = {
stats: { blockers: 5 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }],
};
// stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero
expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 });
});
it('counts findings length as total when stats has zero total', () => {
const output = {
findings: [{ severity: 'warning' }, { severity: 'info' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 });
});
});
describe('runGate', () => {
let tmp: string;
let logPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = path.join(tmp, 'gate.log');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it('passes mechanical gate on exit 0', () => {
const result = runGate('echo hello', tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.exit_code).toBe(0);
expect(result.type).toBe('mechanical');
expect(result.output).toContain('hello');
});
it('fails mechanical gate on non-zero exit', () => {
const result = runGate('exit 1', tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(1);
});
it('ci-pipeline fails closed without a CI provider (no placeholder pass)', () => {
const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.status).toBe('capability_failure');
expect(result.capability_code).toBe('MACP_NO_CI_PIPELINE');
expect(result.type).toBe('ci-pipeline');
expect(result.output).not.toBe('CI pipeline gate placeholder');
});
it('empty command is a typed capability failure, never a pass', () => {
const result = runGate({ command: '' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.status).toBe('capability_failure');
expect(result.capability_code).toBe('MACP_NO_COMMAND');
});
it('ai-review gate parses JSON output', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.blockers).toBe(0);
expect(result.findings).toBe(1);
});
it('ai-review gate fails on blockers', () => {
const json = JSON.stringify({ stats: { blockers: 2 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.blockers).toBe(2);
});
it('ai-review gate with fail_on=any fails on any findings', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate(
{ command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' },
tmp,
logPath,
30,
);
expect(result.passed).toBe(false);
expect(result.fail_on).toBe('any');
});
it('ai-review gate fails on invalid JSON output', () => {
const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.parse_error).toBeDefined();
});
it('writes to log file', () => {
runGate('echo logged', tmp, logPath, 30);
const log = fs.readFileSync(logPath, 'utf-8');
expect(log).toContain('COMMAND: echo logged');
expect(log).toContain('logged');
expect(log).toContain('EXIT:');
});
});
describe('runGates', () => {
let tmp: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = path.join(tmp, 'gates.log');
eventsPath = path.join(tmp, 'events.ndjson');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it('runs multiple gates and returns results', () => {
const { allPassed, gateResults } = runGates(
['echo one', 'echo two'],
tmp,
logPath,
30,
eventsPath,
'task-1',
);
expect(allPassed).toBe(true);
expect(gateResults).toHaveLength(2);
});
it('reports failure when any gate fails', () => {
const { allPassed, gateResults } = runGates(
['echo ok', 'exit 1'],
tmp,
logPath,
30,
eventsPath,
'task-2',
);
expect(allPassed).toBe(false);
expect(gateResults[0]!.passed).toBe(true);
expect(gateResults[1]!.passed).toBe(false);
});
it('emits events for each gate', () => {
runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3');
const events = fs
.readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
expect(events).toHaveLength(2); // started + passed
expect(events[0].event_type).toBe('rail.check.started');
expect(events[1].event_type).toBe('rail.check.passed');
});
it('does not silently skip gates with empty command — they become capability failures', () => {
const { gateResults, allPassed, state } = runGates(
[{ command: '', type: 'mechanical' }, 'echo real'],
tmp,
logPath,
30,
eventsPath,
'task-4',
);
expect(gateResults).toHaveLength(2);
expect(gateResults[0]!.status).toBe('capability_failure');
expect(gateResults[1]!.status).toBe('passed');
expect(allPassed).toBe(false);
expect(state).toBe('capability_failure');
});
it('does not skip ci-pipeline even with empty command — typed capability failure', () => {
const { gateResults, allPassed, state } = runGates(
[{ command: '', type: 'ci-pipeline' }],
tmp,
logPath,
30,
eventsPath,
'task-5',
);
expect(gateResults).toHaveLength(1);
expect(gateResults[0]!.passed).toBe(false);
expect(gateResults[0]!.status).toBe('capability_failure');
expect(allPassed).toBe(false);
expect(state).toBe('capability_failure');
});
it('emits failed event with correct message', () => {
runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6');
const events = fs
.readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
const failEvent = events.find(
(e: Record<string, unknown>) => e.event_type === 'rail.check.failed',
);
expect(failEvent).toBeDefined();
expect(failEvent.message).toContain('Gate failed (');
});
});
/**
* RI-N2 / SDLC-D-035 fail-closed controls for the MACP gate runner.
*
* Invariant under test: `passed: true` occurs ONLY when a gate really executed
* and really exited green (`status === 'passed'`). Absent capabilities,
* manual sign-offs, and simulated runs are typed distinctly and can never
* make the aggregate `passed`.
*/
describe('gate-runner fail-closed (RI-N2)', () => {
let tmpDir: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmpDir = makeTmpDir();
logPath = path.join(tmpDir, 'gate.log');
eventsPath = path.join(tmpDir, 'events.ndjson');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function run(gates: unknown[], options?: { simulate?: boolean }) {
return runGates(gates, tmpDir, logPath, 10, eventsPath, 'spec-task', options);
}
// ─── positive controls ───────────────────────────────────────────────────
it('a really-executed green command gate still passes', () => {
const result = run([{ command: 'exit 0', type: 'mechanical' }]);
expect(result.gateResults[0]!.status).toBe('passed');
expect(result.gateResults[0]!.passed).toBe(true);
expect(result.allPassed).toBe(true);
expect(result.state).toBe('passed');
});
it('explicit simulate completes and types every result simulated', () => {
const result = run([{ command: 'exit 0', type: 'mechanical' }, 'echo hello'], {
simulate: true,
});
expect(result.gateResults).toHaveLength(2);
for (const gate of result.gateResults) {
expect(gate.status).toBe('simulated');
expect(gate.passed).toBe(false);
}
expect(result.state).toBe('simulated');
});
it('a really-executed red command gate fails with typed status failed', () => {
const result = run([{ command: 'exit 3', type: 'mechanical' }]);
expect(result.gateResults[0]!.status).toBe('failed');
expect(result.gateResults[0]!.passed).toBe(false);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('failed');
});
// ─── negative controls — each asserts typed status AND aggregate not passed ──
it('an empty-command gate is a capability_failure, not skipped and not passed', () => {
const result = run([{ command: '', type: 'mechanical' }]);
// runGates must not silently skip it — it produces a typed result
expect(result.gateResults).toHaveLength(1);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('capability_failure');
expect(gate.capability_code).toBe('MACP_NO_COMMAND');
expect(gate.passed).toBe(false);
// aggregate is not passed
expect(result.allPassed).toBe(false);
expect(result.state).toBe('capability_failure');
expect(result.state).not.toBe('passed');
});
it('a commandless ai-review gate is a typed MACP_NO_REVIEWER capability_failure', () => {
const result = run([{ command: '', type: 'ai-review' }]);
expect(result.gateResults[0]!.status).toBe('capability_failure');
expect(result.gateResults[0]!.capability_code).toBe('MACP_NO_REVIEWER');
expect(result.allPassed).toBe(false);
expect(result.state).not.toBe('passed');
});
it('a ci-pipeline gate without a provider implementation is a capability_failure, never a placeholder pass', () => {
const result = run([{ command: '', type: 'ci-pipeline' }]);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('capability_failure');
expect(gate.capability_code).toBe('MACP_NO_CI_PIPELINE');
expect(gate.passed).toBe(false);
// the old false-success placeholder must be gone
expect(gate.output).not.toBe('CI pipeline gate placeholder');
expect(result.allPassed).toBe(false);
expect(result.state).not.toBe('passed');
});
it('a ci-pipeline gate fails closed even alongside an otherwise green run', () => {
const result = run(['exit 0', { type: 'ci-pipeline', command: 'fake-ci' }]);
expect(result.gateResults[1]!.status).toBe('capability_failure');
expect(result.gateResults[0]!.status).toBe('passed');
expect(result.allPassed).toBe(false);
expect(result.state).toBe('capability_failure');
});
it('a manual gate with no automation enters typed waiting — neither pass nor fail', () => {
const result = run([{ type: 'manual' }]);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('waiting');
expect(gate.passed).toBe(false);
expect(gate.exit_code).toBe(0);
// aggregate is not passed while any gate is waiting
expect(result.allPassed).toBe(false);
expect(result.state).toBe('waiting');
expect(result.state).not.toBe('passed');
});
it('a simulated result can never make the aggregate passed', () => {
const result = run(['exit 0', 'exit 0'], { simulate: true });
expect(result.gateResults.every((g) => g.status === 'simulated')).toBe(true);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('simulated');
expect(result.state).not.toBe('passed');
});
it('waiting dominates an otherwise green aggregate', () => {
const result = run(['exit 0', { type: 'manual' }]);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('waiting');
});
});
describe('runGate fail-closed (RI-N2)', () => {
let tmpDir: string;
let logPath: string;
beforeEach(() => {
tmpDir = makeTmpDir();
logPath = path.join(tmpDir, 'gate.log');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('simulate: true returns a typed simulated result without executing', () => {
const result = runGate('this-command-does-not-exist-xyz', tmpDir, logPath, 10, {
simulate: true,
});
expect(result.status).toBe('simulated');
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(0);
});
it('normal mode executes for real and types a green gate passed', () => {
const result = runGate('echo ok', tmpDir, logPath, 10);
expect(result.status).toBe('passed');
expect(result.passed).toBe(true);
expect(result.output).toContain('ok');
});
it('a bare string gate normalizes to mechanical and executes', () => {
const result = runGate('exit 7', tmpDir, logPath, 10);
expect(result.type).toBe('mechanical');
expect(result.status).toBe('failed');
expect(result.passed).toBe(false);
});
});
+148 -25
View File
@@ -4,7 +4,20 @@ import { dirname } from 'node:path';
import { emitEvent } from './event-emitter.js';
import { nowISO } from './event-emitter.js';
import type { GateResult } from './types.js';
import type { GateResult, GateStatus, RunGatesResult } from './types.js';
/** Typed reason stamped on every simulated gate result. */
export const SIMULATED_GATE_REASON =
'simulated execution (explicit simulate opt-in): gate was not evaluated by a real implementation';
/** Options for gate execution (RI-N2 fail-closed / explicit simulation). */
export interface RunGateOptions {
/**
* Explicit caller opt-in to simulation. Simulated gates are NOT executed;
* every result is typed `simulated` and never satisfies anything.
*/
simulate?: boolean;
}
export interface NormalizedGate {
command: string;
@@ -103,36 +116,91 @@ export function countAIFindings(parsedOutput: unknown): { blockers: number; tota
return { blockers, total };
}
function simulatedResult(gateEntry: NormalizedGate): GateResult {
return {
command: gateEntry.command,
exit_code: 0,
type: gateEntry.type,
output: SIMULATED_GATE_REASON,
timed_out: false,
passed: false,
status: 'simulated',
reason: SIMULATED_GATE_REASON,
};
}
function capabilityFailureResult(
gateEntry: NormalizedGate,
code: GateResult['capability_code'],
reason: string,
): GateResult {
return {
command: gateEntry.command,
exit_code: 1,
type: gateEntry.type,
output: '',
timed_out: false,
passed: false,
status: 'capability_failure',
capability_code: code,
reason,
};
}
function waitingResult(gateEntry: NormalizedGate, reason: string): GateResult {
return {
command: gateEntry.command,
exit_code: 0,
type: gateEntry.type,
output: '',
timed_out: false,
passed: false,
status: 'waiting',
capability_code: 'MACP_AUTHORITY_REQUIRED',
reason,
};
}
export function runGate(
gate: unknown,
cwd: string,
logPath: string,
timeoutSec: number,
options: RunGateOptions = {},
): GateResult {
const gateEntry = normalizeGate(gate);
const gateType = gateEntry.type;
const command = gateEntry.command;
// Explicit simulation only: never executes, typed simulated, never satisfying.
if (options.simulate) {
return simulatedResult(gateEntry);
}
// Fail closed: no CI provider implementation exists in @mosaicstack/macp,
// so a ci-pipeline gate is an absent capability — never a placeholder pass.
if (gateType === 'ci-pipeline') {
return {
command,
exit_code: 0,
type: gateType,
output: 'CI pipeline gate placeholder',
timed_out: false,
passed: true,
};
return capabilityFailureResult(
gateEntry,
'MACP_NO_CI_PIPELINE',
`ci-pipeline gate '${gateEntry.command || gateType}' has no CI provider implementation wired — refusing placeholder pass`,
);
}
if (!command) {
return {
command: '',
exit_code: 0,
type: gateType,
output: '',
timed_out: false,
passed: true,
};
// A manual gate with no automation waits for human sign-off: not pass, not fail.
if (gateType === 'manual') {
return waitingResult(
gateEntry,
`manual gate has no automation — waiting for human sign-off (type: ${gateType})`,
);
}
// Any other commandless gate is an absent capability — never a vacuous pass.
return capabilityFailureResult(
gateEntry,
gateType === 'ai-review' ? 'MACP_NO_REVIEWER' : 'MACP_NO_COMMAND',
`gate of type '${gateType}' has no command to execute — refusing empty-command pass`,
);
}
const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec);
@@ -143,10 +211,12 @@ export function runGate(
output,
timed_out: timedOut,
passed: false,
status: 'failed',
};
if (gateType !== 'ai-review') {
result.passed = exitCode === 0;
result.status = result.passed ? 'passed' : 'failed';
return result;
}
@@ -170,6 +240,7 @@ export function runGate(
} else {
result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined;
}
result.status = result.passed ? 'passed' : 'failed';
result.fail_on = failOn;
result.blockers = blockers;
@@ -191,16 +262,19 @@ export function runGates(
timeoutSec: number,
eventsPath: string,
taskId: string,
): { allPassed: boolean; gateResults: GateResult[] } {
let allPassed = true;
options: RunGateOptions = {},
): RunGatesResult {
const gateResults: GateResult[] = [];
let hasCapabilityFailure = false;
let hasSimulated = false;
let hasFailed = false;
let hasWaiting = false;
for (const gate of gates) {
const gateEntry = normalizeGate(gate);
const gateCmd = gateEntry.command;
if (!gateCmd && gateEntry.type !== 'ci-pipeline') continue;
const label = gateCmd || gateEntry.type;
// NOTE: no silent skip — every gate produces a typed result (RI-N2).
emitEvent(
eventsPath,
'rail.check.started',
@@ -209,10 +283,10 @@ export function runGates(
'quality-gate',
`Running gate: ${label}`,
);
const result = runGate(gate, cwd, logPath, timeoutSec);
const result = runGate(gate, cwd, logPath, timeoutSec, options);
gateResults.push(result);
if (result.passed) {
if (result.status === 'passed') {
emitEvent(
eventsPath,
'rail.check.passed',
@@ -224,7 +298,46 @@ export function runGates(
continue;
}
allPassed = false;
if (result.status === 'waiting') {
hasWaiting = true;
emitEvent(
eventsPath,
'rail.check.waiting',
taskId,
'gated',
'quality-gate',
`Gate waiting: ${label}${result.reason ?? 'manual gate awaits sign-off'}`,
);
continue;
}
if (result.status === 'simulated') {
hasSimulated = true;
emitEvent(
eventsPath,
'rail.check.simulated',
taskId,
'gated',
'quality-gate',
`Gate simulated (non-satisfying): ${label}`,
);
continue;
}
if (result.status === 'capability_failure') {
hasCapabilityFailure = true;
emitEvent(
eventsPath,
'rail.check.failed',
taskId,
'gated',
'quality-gate',
`Gate capability failure (${result.capability_code ?? 'MACP_NO_PROVIDER'}): ${label}${result.reason ?? 'required capability is absent'}`,
);
continue;
}
hasFailed = true;
let message: string;
if (result.timed_out) {
message = `Gate timed out after ${timeoutSec}s: ${label}`;
@@ -236,5 +349,15 @@ export function runGates(
emitEvent(eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', message);
}
return { allPassed, gateResults };
const state: GateStatus = hasCapabilityFailure
? 'capability_failure'
: hasSimulated
? 'simulated'
: hasFailed
? 'failed'
: hasWaiting
? 'waiting'
: 'passed';
return { allPassed: state === 'passed', gateResults, state };
}
+16 -2
View File
@@ -6,11 +6,13 @@ export type {
DependsOnPolicy,
GateType,
GateFailOn,
GateStatus,
GateEntry,
Task,
EventType,
MACPEvent,
GateResult,
RunGatesResult,
TaskResult,
ProviderMeta,
ProviderRegistry,
@@ -18,6 +20,11 @@ export type {
export { CredentialError } from './types.js';
// Typed fail-closed capability errors (RI-N2, SDLC-D-035)
export { MACP_ERROR_CODES, MACPCapabilityError } from './errors.js';
export type { MacpErrorCode } from './errors.js';
// Credential resolver
export {
DEFAULT_CREDENTIALS_DIR,
@@ -35,9 +42,16 @@ export {
export type { ResolveCredentialsOptions } from './credential-resolver.js';
// Gate runner
export { normalizeGate, runShell, countAIFindings, runGate, runGates } from './gate-runner.js';
export {
normalizeGate,
runShell,
countAIFindings,
runGate,
runGates,
SIMULATED_GATE_REASON,
} from './gate-runner.js';
export type { NormalizedGate } from './gate-runner.js';
export type { NormalizedGate, RunGateOptions } from './gate-runner.js';
// Risk-floor (agent reflection loop — diff review classifier)
export { evaluateRiskFloor, DEFAULT_RISK_THRESHOLD } from './risk-floor.js';
+39 -2
View File
@@ -1,3 +1,5 @@
import type { MacpErrorCode } from './errors.js';
/** Task status values. */
export type TaskStatus = 'pending' | 'running' | 'gated' | 'completed' | 'failed' | 'escalated';
@@ -17,7 +19,17 @@ export type DispatchMode = 'yolo' | 'acp' | 'exec';
export type DependsOnPolicy = 'all' | 'any' | 'all_terminal';
/** Quality gate type. */
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline';
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline' | 'manual';
/**
* Typed execution state of a gate closed set (RI-N2, SDLC-D-035).
*
* Only `passed` means "really executed and green". `simulated` is produced
* exclusively under an explicit simulate opt-in and never satisfies anything.
* `capability_failure` means a required executor/provider/command was absent.
* `waiting` means a manual gate awaits human sign-off (neither pass nor fail).
*/
export type GateStatus = 'passed' | 'failed' | 'simulated' | 'waiting' | 'capability_failure';
/** Gate fail_on mode. */
export type GateFailOn = 'blocker' | 'any';
@@ -67,7 +79,9 @@ export type EventType =
| 'task.retry.scheduled'
| 'rail.check.started'
| 'rail.check.passed'
| 'rail.check.failed';
| 'rail.check.failed'
| 'rail.check.waiting'
| 'rail.check.simulated';
/** Structured event record. */
export interface MACPEvent {
@@ -88,7 +102,14 @@ export interface GateResult {
type: string;
output: string;
timed_out: boolean;
/** Back-compat boolean view — true ONLY when `status === 'passed'`. */
passed: boolean;
/** Typed discriminator — the authoritative gate outcome (RI-N2). */
status: GateStatus;
/** Typed capability error code, set when `status === 'capability_failure'`. */
capability_code?: MacpErrorCode;
/** Why a non-executed state (simulated/waiting/capability_failure) was reached. */
reason?: string;
fail_on?: string;
blockers?: number;
findings?: number;
@@ -96,6 +117,22 @@ export interface GateResult {
parse_error?: string;
}
/**
* Aggregate outcome of `runGates` (RI-N2).
*
* `state` is the typed aggregate: it is `passed` only when every gate really
* executed green. A `simulated` result makes the aggregate `simulated` (never
* `passed`); a `waiting` manual gate keeps the aggregate `waiting`; a missing
* capability makes it `capability_failure`. `allPassed` is exactly
* `state === 'passed'`, so a simulated or waiting result can never satisfy a
* dependency, acceptance criterion, gate, merge, or release check.
*/
export interface RunGatesResult {
allPassed: boolean;
gateResults: GateResult[];
state: GateStatus;
}
/** Result from a completed task. */
export interface TaskResult {
task_id: string;
+1 -1
View File
@@ -102,7 +102,7 @@ mosaic yolo pi # Launch Pi in yolo mode
The launcher:
1. Verifies `~/.config/mosaic` exists
2. Resolves identity: standalone launches auto-run `mosaic init` when `SOUL.md` is missing; exact roster-owned fleet launches validate name/class, atomically seed only missing `SOUL.md`/`USER.md` from generic `defaults/`, securely consume `USER.md`, and never prompt
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
3. Injects `AGENTS.md` into the runtime
4. Forwards all arguments to the runtime CLI
@@ -39,3 +39,20 @@ packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires rea
# recorded judgement. These lines ARE that judgement, signed.)
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
# --- tools/fleet: precondition is unsatisfiable in the CI image (#1271) ---
# Signed by fred (sb-it-1-dt, 2026-08-16) at origin/next 476db12.
# This suite asserts the launcher's behaviour when `mosaic` and `pi` are MISSING.
# It shims fakes into $FAKE_BIN, but the constructed PANE_PATH always ends in the
# real system path, so on a host that installs those binaries the missing-binary
# cases cannot be measured at all. The suite's own guard (line 103) says so and
# fails rather than reporting a pass it cannot back. That guard is correct.
# The error was wiring the suite into CI: #1017 (c56483eb) enumerated it and
# dropped this exclusion, and the CI image provides `pi` in the system path, so
# it has failed on every pipeline since. Measured 2026-08-16 across pipelines
# 2444 (#1256), 2438 (#1240) and 2441 (#1017-quality): exactly one FAIL line in
# each full log, identical, this assertion; control `zzz-not-present-zzz` -> 0.
# Burn-down and the full measurement are tracked in #1271; unwired by PR #1270.
# Because test:framework-shell is one && chain and this sat at position 44 of 48,
# the four suites after it had not run at all since the merge.
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | precondition unsatisfiable in the CI image: asserts missing-binary behaviour, but PANE_PATH always ends in the system path and the image provides `pi` there; guard at line 103 fails by design rather than passing unmeasured. Burn down by controlling the tail of PANE_PATH inside the test. NOT by removing `pi` from the image: the CI image installs @earendil-works/[email protected] deliberately (measured in pipeline 2444's test-step log), and other suites depend on that pin. Burn-down tracked in #1271
+2 -3
View File
@@ -24,9 +24,8 @@
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "pnpm run test:vitest && pnpm run test:framework-shell",
"test:vitest": "pnpm run build && vitest run --passWithNoTests",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -15,7 +15,6 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FileConfigAdapter } from '../config/file-adapter.js';
import { seedFleetIdentityDefaults } from './fleet-first-start-identity.js';
import { composeContract } from './launch.js';
/**
@@ -320,7 +319,6 @@ describe('composeContract — overlay composer', () => {
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-self';
expect(seedFleetIdentityDefaults(installedHome)).toEqual(['SOUL.md', 'USER.md']);
const composed = composeContract('pi', installedHome);
expect(composed).toContain(sourceTools);
@@ -334,49 +332,6 @@ describe('composeContract — overlay composer', () => {
}
});
it('refuses a fleet USER.md replacement symlink at the point of composition', () => {
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: exact-user-seat',
' runtime: pi',
' class: worker',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-user-seat';
process.env['MOSAIC_AGENT_CLASS'] = 'worker';
writeFileSync(join(fixture.home, 'defaults', 'SOUL.md'), '# Generic soul\n');
writeFileSync(join(fixture.home, 'defaults', 'USER.md'), '# Generic user\n');
expect(seedFleetIdentityDefaults(fixture.home)).toEqual(['SOUL.md']);
const userPath = join(fixture.home, 'USER.md');
const external = join(fixture.root, 'attacker-user.md');
writeFileSync(external, 'UNSAFE-REPLACEMENT-USER-CONTENT\n');
rmSync(userPath);
symlinkSync(external, userPath);
expect(() => composeContract('pi', fixture.home)).toThrow(
`fleet identity installed is unavailable or unsafe: ${userPath}`,
);
expect(readFileSync(external, 'utf8')).toBe('UNSAFE-REPLACEMENT-USER-CONTENT\n');
});
it('preserves tolerant standalone composition when optional USER.md is unreadable', () => {
const userPath = join(fixture.home, 'USER.md');
rmSync(userPath);
mkdirSync(userPath);
const out = composeContract('pi', fixture.home);
expect(out).toContain(AGENTS);
expect(out).not.toContain('# User Profile');
});
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'never injects installed TOOLS.md through a target symlink for %s',
(runtime) => {
@@ -1,182 +0,0 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
lstatSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
linkIdentityContractNoClobber,
seedFleetIdentityDefaults,
} from './fleet-first-start-identity.js';
const roots: string[] = [];
const DEFAULT_SOUL = '# Generic soul\n';
const DEFAULT_USER = '# Generic user\n';
function writeFixture(path: string, content: string | Buffer, mode: number = 0o600): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, content, { mode });
chmodSync(path, mode);
}
function createMosaicHome(): string {
const root = mkdtempSync(join(tmpdir(), 'mosaic-identity-seed-'));
roots.push(root);
const mosaicHome = join(root, 'home', '.config', 'mosaic');
writeFixture(join(mosaicHome, 'defaults', 'SOUL.md'), DEFAULT_SOUL);
writeFixture(join(mosaicHome, 'defaults', 'USER.md'), DEFAULT_USER);
return mosaicHome;
}
function temporarySeeds(mosaicHome: string): string[] {
return readdirSync(mosaicHome).filter((entry) => entry.includes('.fleet-seed-'));
}
afterEach((): void => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe('linkIdentityContractNoClobber', () => {
it('returns false and preserves a destination that already exists', () => {
const mosaicHome = createMosaicHome();
const source = join(mosaicHome, 'source.tmp');
const destination = join(mosaicHome, 'destination.md');
writeFixture(source, 'candidate\n');
writeFixture(destination, 'operator\n');
expect(linkIdentityContractNoClobber(source, destination)).toBe(false);
expect(readFileSync(destination, 'utf8')).toBe('operator\n');
});
it('does not misclassify an unexpected link failure as a concurrent winner', () => {
const mosaicHome = createMosaicHome();
const missingSource = join(mosaicHome, 'missing.tmp');
expect(() =>
linkIdentityContractNoClobber(missingSource, join(mosaicHome, 'destination.md')),
).toThrow();
});
});
describe('seedFleetIdentityDefaults', () => {
it('publishes complete owner-private default snapshots', () => {
const mosaicHome = createMosaicHome();
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual(['SOUL.md', 'USER.md']);
for (const [entry, expected] of [
['SOUL.md', DEFAULT_SOUL],
['USER.md', DEFAULT_USER],
] as const) {
const path = join(mosaicHome, entry);
expect(readFileSync(path, 'utf8')).toBe(expected);
expect(statSync(path).mode & 0o777).toBe(0o600);
}
expect(temporarySeeds(mosaicHome)).toEqual([]);
});
it('preserves an existing regular contract byte-for-byte and mode-for-mode', () => {
const mosaicHome = createMosaicHome();
const customSoul = '# Operator-owned soul\n';
writeFixture(join(mosaicHome, 'SOUL.md'), customSoul, 0o640);
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual(['USER.md']);
expect(readFileSync(join(mosaicHome, 'SOUL.md'), 'utf8')).toBe(customSoul);
expect(statSync(join(mosaicHome, 'SOUL.md')).mode & 0o777).toBe(0o640);
});
it('is idempotent after both installed contracts exist', () => {
const mosaicHome = createMosaicHome();
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual(['SOUL.md', 'USER.md']);
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual([]);
expect(temporarySeeds(mosaicHome)).toEqual([]);
});
it('validates every required source before publishing any destination', () => {
const mosaicHome = createMosaicHome();
const missing = join(mosaicHome, 'defaults', 'USER.md');
rmSync(missing);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity default is unavailable or unsafe: ${missing}`,
);
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false);
});
it('fails closed when the configured Mosaic home is not a directory', () => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-identity-invalid-home-'));
roots.push(root);
const mosaicHome = join(root, 'mosaic-home');
writeFixture(mosaicHome, 'not a directory\n');
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity installed is unavailable or unsafe: ${join(mosaicHome, 'SOUL.md')}`,
);
});
it('refuses a symlinked default instead of following it', () => {
const mosaicHome = createMosaicHome();
const source = join(mosaicHome, 'defaults', 'SOUL.md');
rmSync(source);
symlinkSync(join(mosaicHome, 'defaults', 'USER.md'), source);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity default is unavailable or unsafe: ${source}`,
);
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(false);
});
it('refuses an existing symlinked destination without replacing it', () => {
const mosaicHome = createMosaicHome();
const destination = join(mosaicHome, 'SOUL.md');
symlinkSync(join(mosaicHome, 'defaults', 'SOUL.md'), destination);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity installed is unavailable or unsafe: ${destination}`,
);
expect(lstatSync(destination).isSymbolicLink()).toBe(true);
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false);
});
it.each(['SOUL.md', 'USER.md'] as const)(
'rejects a dangling %s destination before publishing its counterpart',
(entry) => {
const mosaicHome = createMosaicHome();
const destination = join(mosaicHome, entry);
const counterpart = join(mosaicHome, entry === 'SOUL.md' ? 'USER.md' : 'SOUL.md');
symlinkSync(join(mosaicHome, 'missing-identity-target'), destination);
expect(existsSync(destination)).toBe(false);
expect(lstatSync(destination).isSymbolicLink()).toBe(true);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity installed is unavailable or unsafe: ${destination}`,
);
expect(lstatSync(destination).isSymbolicLink()).toBe(true);
expect(existsSync(counterpart)).toBe(false);
},
);
it('rejects an oversized source before publishing a partial identity', () => {
const mosaicHome = createMosaicHome();
const source = join(mosaicHome, 'defaults', 'USER.md');
writeFixture(source, Buffer.alloc(256 * 1024 + 1, 0x61));
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity default is unavailable or unsafe: ${source}`,
);
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false);
});
});
@@ -1,120 +0,0 @@
import { randomBytes } from 'node:crypto';
import { linkSync, lstatSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { readRegularFileSecure } from '../fleet/secure-file.js';
const MAX_IDENTITY_CONTRACT_BYTES = 256 * 1024;
export const FLEET_IDENTITY_DEFAULTS = ['SOUL.md', 'USER.md'] as const;
function isFilesystemError(error: unknown, code: string): boolean {
return error instanceof Error && 'code' in error && error.code === code;
}
/** @internal Publish a complete temporary file without replacing any path. */
export function linkIdentityContractNoClobber(source: string, destination: string): boolean {
try {
linkSync(source, destination);
return true;
} catch (error: unknown) {
if (isFilesystemError(error, 'EEXIST')) return false;
throw error;
}
}
function unsafeIdentityError(kind: 'default' | 'installed', path: string, error: unknown): Error {
const reason = error instanceof Error ? error.message : String(error);
return new Error(`fleet identity ${kind} is unavailable or unsafe: ${path} (${reason})`);
}
function readIdentityContract(
mosaicHome: string,
path: string,
kind: 'default' | 'installed',
): Buffer {
try {
return readRegularFileSecure(path, {
root: mosaicHome,
maxBytes: MAX_IDENTITY_CONTRACT_BYTES,
}).content;
} catch (error: unknown) {
throw unsafeIdentityError(kind, path, error);
}
}
function installedEntryExists(path: string): boolean {
try {
lstatSync(path);
return true;
} catch (error: unknown) {
if (isFilesystemError(error, 'ENOENT')) return false;
throw unsafeIdentityError('installed', path, error);
}
}
/** Secure fleet point-of-use read for a top-level identity contract. */
export function readInstalledIdentityContractAtPointOfUse(
mosaicHome: string,
entry: (typeof FLEET_IDENTITY_DEFAULTS)[number],
): Buffer {
const configuredPath = join(mosaicHome, entry);
try {
// Preserve the launcher's established support for a symlinked Mosaic home,
// while pinning this read to the resolved directory. O_NOFOLLOW still
// rejects replacement of the identity file itself (or any child ancestor).
const canonicalHome = realpathSync(mosaicHome);
return readRegularFileSecure(join(canonicalHome, entry), {
root: canonicalHome,
maxBytes: MAX_IDENTITY_CONTRACT_BYTES,
}).content;
} catch (error: unknown) {
throw unsafeIdentityError('installed', configuredPath, error);
}
}
/**
* Seed the generic identity base required by unattended fleet launches.
*
* Exact seat identity remains roster-owned and is injected later by the
* runtime composer. Each destination appears atomically through a hard link to
* a complete owner-private temporary file; a concurrent first seat may win the
* link without allowing either process to overwrite operator content.
*/
export function seedFleetIdentityDefaults(mosaicHome: string): string[] {
const snapshots = new Map<(typeof FLEET_IDENTITY_DEFAULTS)[number], Buffer>();
for (const entry of FLEET_IDENTITY_DEFAULTS) {
const destination = join(mosaicHome, entry);
if (installedEntryExists(destination)) {
readIdentityContract(mosaicHome, destination, 'installed');
continue;
}
const source = join(mosaicHome, 'defaults', entry);
snapshots.set(entry, readIdentityContract(mosaicHome, source, 'default'));
}
const seeded: string[] = [];
for (const [entry, content] of snapshots) {
const destination = join(mosaicHome, entry);
const temporary = join(
mosaicHome,
`.${entry}.fleet-seed-${process.pid.toString()}-${randomBytes(6).toString('hex')}`,
);
let temporaryCreated = false;
try {
writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
temporaryCreated = true;
if (linkIdentityContractNoClobber(temporary, destination)) {
seeded.push(entry);
} else {
readIdentityContract(mosaicHome, destination, 'installed');
}
} finally {
if (temporaryCreated) rmSync(temporary, { force: true });
}
}
for (const entry of FLEET_IDENTITY_DEFAULTS) {
readIdentityContract(mosaicHome, join(mosaicHome, entry), 'installed');
}
return seeded;
}
@@ -1,393 +0,0 @@
import { spawn, spawnSync, type SpawnSyncReturns } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
const CLI_PATH = fileURLToPath(new URL('../../dist/cli.js', import.meta.url));
const DEFAULT_SOUL_PATH = fileURLToPath(
new URL('../../framework/defaults/SOUL.md', import.meta.url),
);
const DEFAULT_USER_PATH = fileURLToPath(
new URL('../../framework/defaults/USER.md', import.meta.url),
);
interface GreenfieldFixture {
readonly root: string;
readonly home: string;
readonly mosaicHome: string;
readonly binDir: string;
readonly capturePath: string;
}
interface AsyncLaunchResult {
readonly status: number | null;
readonly signal: NodeJS.Signals | null;
readonly stdout: string;
readonly stderr: string;
}
const fixtures: string[] = [];
function writeFixture(path: string, content: string, mode: number = 0o600): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, content, { encoding: 'utf8', mode });
chmodSync(path, mode);
}
function createGreenfieldFixture(): GreenfieldFixture {
const root = mkdtempSync(join(tmpdir(), 'mosaic-first-start-'));
fixtures.push(root);
const home = join(root, 'home');
const mosaicHome = join(home, '.config', 'mosaic');
const binDir = join(root, 'bin');
const capturePath = join(root, 'runtime-boundary.json');
mkdirSync(binDir, { recursive: true, mode: 0o700 });
writeFixture(join(mosaicHome, 'AGENTS.md'), '# Agent dispatcher\n');
writeFixture(join(mosaicHome, 'runtime', 'pi', 'RUNTIME.md'), '# Pi runtime\n');
writeFixture(join(mosaicHome, 'defaults', 'SOUL.md'), readFileSync(DEFAULT_SOUL_PATH, 'utf8'));
writeFixture(join(mosaicHome, 'defaults', 'USER.md'), readFileSync(DEFAULT_USER_PATH, 'utf8'));
writeFixture(
join(mosaicHome, 'fleet', 'roster.yaml'),
`version: 1
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: ~
runtimes:
pi:
reset_command: /new
agents:
- name: unattended-seat
runtime: pi
class: worker
`,
);
writeFixture(
join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'),
'#!/usr/bin/env bash\nexit 0\n',
0o755,
);
writeFixture(
join(mosaicHome, 'tools', 'lease-broker', 'launch-runtime.py'),
`#!/usr/bin/env python3
import json
import os
import pathlib
import sys
pathlib.Path(os.environ["MOSAIC_TEST_RUNTIME_CAPTURE"]).write_text(
json.dumps({"argv": sys.argv[1:]}), encoding="utf-8"
)
`,
0o755,
);
// checkRuntime() must find Pi, while the fake broker boundary prevents this
// executable from running or making a provider call.
writeFixture(join(binDir, 'pi'), '#!/usr/bin/env bash\nexit 97\n', 0o755);
return { root, home, mosaicHome, binDir, capturePath };
}
function launchEnvironment(
fixture: GreenfieldFixture,
capturePath: string,
fleet: boolean = true,
agentName: string = 'unattended-seat',
agentClass: string = 'worker',
): NodeJS.ProcessEnv {
return {
HOME: fixture.home,
MOSAIC_HOME: fixture.mosaicHome,
...(fleet
? {
MOSAIC_AGENT_NAME: agentName,
MOSAIC_AGENT_CLASS: agentClass,
}
: {}),
MOSAIC_TEST_RUNTIME_CAPTURE: capturePath,
PATH: `${fixture.binDir}:/usr/bin:/bin`,
};
}
function launchSync(
fixture: GreenfieldFixture,
options: {
readonly capturePath?: string;
readonly fleet?: boolean;
readonly agentName?: string;
readonly agentClass?: string;
} = {},
): SpawnSyncReturns<string> {
const capturePath = options.capturePath ?? fixture.capturePath;
return spawnSync(process.execPath, [CLI_PATH, 'yolo', 'pi'], {
cwd: fixture.root,
encoding: 'utf8',
input: '',
timeout: 10_000,
env: launchEnvironment(
fixture,
capturePath,
options.fleet ?? true,
options.agentName ?? 'unattended-seat',
options.agentClass ?? 'worker',
),
});
}
function launchAsync(fixture: GreenfieldFixture, capturePath: string): Promise<AsyncLaunchResult> {
return new Promise<AsyncLaunchResult>((resolve, reject): void => {
const child = spawn(process.execPath, [CLI_PATH, 'yolo', 'pi'], {
cwd: fixture.root,
env: launchEnvironment(fixture, capturePath),
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string): void => {
stdout += chunk;
});
child.stderr.on('data', (chunk: string): void => {
stderr += chunk;
});
child.on('error', reject);
child.on('close', (status: number | null, signal: NodeJS.Signals | null): void => {
resolve({ status, signal, stdout, stderr });
});
child.stdin.end();
});
}
function outputOf(result: { readonly stdout: string; readonly stderr: string }): string {
return `${result.stdout}${result.stderr}`;
}
function assertPrivateDefaultSeeds(fixture: GreenfieldFixture): void {
const soul = join(fixture.mosaicHome, 'SOUL.md');
const user = join(fixture.mosaicHome, 'USER.md');
expect(readFileSync(soul, 'utf8')).toBe(readFileSync(DEFAULT_SOUL_PATH, 'utf8'));
expect(readFileSync(user, 'utf8')).toBe(readFileSync(DEFAULT_USER_PATH, 'utf8'));
expect(statSync(soul).mode & 0o777).toBe(0o600);
expect(statSync(user).mode & 0o777).toBe(0o600);
}
function capturedArguments(path: string): string[] {
const capture = JSON.parse(readFileSync(path, 'utf8')) as { argv: string[] };
return capture.argv;
}
afterEach((): void => {
for (const root of fixtures.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
describe('fleet unattended first start (#1264)', () => {
it('reaches the runtime boundary without a TTY or identity wizard on a clean install', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture);
const output = outputOf(result);
expect(result.error, output).toBeUndefined();
expect(result.status, output).toBe(0);
expect(output).toContain('Initialized unattended fleet identity defaults: SOUL.md, USER.md');
expect(output).not.toContain('Running setup wizard');
expect(output).not.toContain('What would you like to do?');
expect(existsSync(fixture.capturePath), output).toBe(true);
assertPrivateDefaultSeeds(fixture);
const argv = capturedArguments(fixture.capturePath);
expect(argv).toContain('--runtime');
expect(argv.join('\n')).toContain('Agent/session: `unattended-seat`');
expect(argv.join('\n')).toContain('Role/class: `worker`');
});
it('preserves existing operator identity bytes without requiring defaults', () => {
const fixture = createGreenfieldFixture();
const customSoul = '# Operator soul\nNever replace this.\n';
const customUser = '# Operator user\nNever replace this either.\n';
writeFixture(join(fixture.mosaicHome, 'SOUL.md'), customSoul, 0o640);
writeFixture(join(fixture.mosaicHome, 'USER.md'), customUser, 0o600);
rmSync(join(fixture.mosaicHome, 'defaults'), { recursive: true, force: true });
const first = launchSync(fixture);
const secondCapture = join(fixture.root, 'runtime-boundary-second.json');
const second = launchSync(fixture, { capturePath: secondCapture });
expect(first.status, outputOf(first)).toBe(0);
expect(second.status, outputOf(second)).toBe(0);
expect(readFileSync(join(fixture.mosaicHome, 'SOUL.md'), 'utf8')).toBe(customSoul);
expect(readFileSync(join(fixture.mosaicHome, 'USER.md'), 'utf8')).toBe(customUser);
expect(statSync(join(fixture.mosaicHome, 'SOUL.md')).mode & 0o777).toBe(0o640);
expect(existsSync(fixture.capturePath)).toBe(true);
expect(existsSync(secondCapture)).toBe(true);
});
it('seeds only the missing identity contract and leaves a custom SOUL byte-exact', () => {
const fixture = createGreenfieldFixture();
const customSoul = '# Exact custom soul bytes\n';
writeFixture(join(fixture.mosaicHome, 'SOUL.md'), customSoul, 0o640);
const result = launchSync(fixture);
expect(result.status, outputOf(result)).toBe(0);
expect(outputOf(result)).toContain('Initialized unattended fleet identity defaults: USER.md');
expect(readFileSync(join(fixture.mosaicHome, 'SOUL.md'), 'utf8')).toBe(customSoul);
expect(statSync(join(fixture.mosaicHome, 'SOUL.md')).mode & 0o777).toBe(0o640);
expect(readFileSync(join(fixture.mosaicHome, 'USER.md'), 'utf8')).toBe(
readFileSync(DEFAULT_USER_PATH, 'utf8'),
);
});
it('fails closed without a wizard or partial seed when a required default is missing', () => {
const fixture = createGreenfieldFixture();
const missingDefault = join(fixture.mosaicHome, 'defaults', 'USER.md');
rmSync(missingDefault);
const result = launchSync(fixture);
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('unattended fleet identity initialization failed');
expect(output).toContain(missingDefault);
expect(output).not.toContain('Running setup wizard');
expect(output).not.toContain('What would you like to do?');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
});
it('rejects a symlinked identity default without following it or prompting', () => {
const fixture = createGreenfieldFixture();
const soulDefault = join(fixture.mosaicHome, 'defaults', 'SOUL.md');
rmSync(soulDefault);
symlinkSync(DEFAULT_SOUL_PATH, soulDefault);
const result = launchSync(fixture);
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain(`fleet identity default is unavailable or unsafe: ${soulDefault}`);
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
});
it('refuses an unknown ambient fleet name before seeding or prompting', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentName: 'not-in-the-roster' });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('canonical fleet identity is unavailable');
expect(output).toContain('Agent "not-in-the-roster" is not in the fleet roster');
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
});
it('refuses a mismatched ambient fleet class before seeding or prompting', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentClass: 'reviewer' });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('Refusing split identity authority');
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
});
it.each(['', ' ', '\t'])(
'refuses explicit blank ambient fleet class %j before seeding',
(agentClass: string) => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentClass });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('Refusing split identity authority');
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
},
);
it.each([' unattended-seat', 'unattended-seat ', ''])(
'refuses non-exact ambient fleet name %j before seeding',
(agentName: string) => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentName });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain(
'MOSAIC_AGENT_NAME must be a non-empty exact roster name with no surrounding whitespace',
);
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
},
);
it('keeps the interactive wizard path for a standalone launch', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { fleet: false });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('[mosaic] SOUL.md not found. Running setup wizard...');
expect(output).toContain('What would you like to do?');
expect(output).toContain('[mosaic] Setup failed. Run: mosaic wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
});
it('allows concurrent no-TTY seats to initialize the same defaults without clobber or residue', async () => {
const fixture = createGreenfieldFixture();
const captures = Array.from({ length: 4 }, (_, index) =>
join(fixture.root, `runtime-boundary-${index.toString()}.json`),
);
const results = await Promise.all(
captures.map(
async (capturePath): Promise<AsyncLaunchResult> => launchAsync(fixture, capturePath),
),
);
for (const result of results) {
expect(result.status, outputOf(result)).toBe(0);
expect(result.signal, outputOf(result)).toBeNull();
expect(outputOf(result)).not.toContain('Running setup wizard');
}
assertPrivateDefaultSeeds(fixture);
expect(captures.every((capturePath) => existsSync(capturePath))).toBe(true);
expect(
readdirSync(fixture.mosaicHome).filter((entry) => entry.includes('.fleet-seed-')),
).toEqual([]);
});
});
+10 -62
View File
@@ -30,10 +30,6 @@ import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
import {
readInstalledIdentityContractAtPointOfUse,
seedFleetIdentityDefaults,
} from './fleet-first-start-identity.js';
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -234,55 +230,8 @@ function checkRuntime(cmd: string): void {
}
}
function assertAmbientFleetClassMatches(canonicalName: string, canonicalClass: string): void {
const configuredClass = process.env['MOSAIC_AGENT_CLASS'];
if (configuredClass === undefined) return;
const ambientClass = canonicalizeRoleClass(configuredClass).canonicalClass;
if (ambientClass !== canonicalClass) {
throw new Error(
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalName}" resolves to "${canonicalClass}". Refusing split identity authority.`,
);
}
}
function checkSoul(): void {
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
const fleetAgentName = process.env['MOSAIC_AGENT_NAME'];
if (fleetAgentName !== undefined) {
try {
if (fleetAgentName.length === 0 || fleetAgentName !== fleetAgentName.trim()) {
throw new Error(
'MOSAIC_AGENT_NAME must be a non-empty exact roster name with no surrounding whitespace',
);
}
const fleetIdentity = resolveFleetIdentity(MOSAIC_HOME, fleetAgentName);
if (!fleetIdentity.ok || !fleetIdentity.identity) {
throw new Error(
`canonical fleet identity is unavailable: ${fleetIdentity.error ?? 'exact roster member was not resolved'}`,
);
}
assertAmbientFleetClassMatches(
fleetIdentity.identity.member.name,
fleetIdentity.identity.member.className,
);
const seeded = seedFleetIdentityDefaults(MOSAIC_HOME);
if (seeded.length > 0) {
console.log(
`[mosaic] Initialized unattended fleet identity defaults: ${seeded.join(', ')}. Exact seat identity remains roster-owned.`,
);
}
return;
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : String(error);
console.error(`[mosaic] ERROR: unattended fleet identity initialization failed: ${reason}`);
console.error(
'[mosaic] Repair the named fleet roster, launch identity, installed contract, or shipped default, then retry.',
);
process.exit(1);
}
}
if (!existsSync(soulPath)) {
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
@@ -583,27 +532,26 @@ For required push/merge/issue-close/release actions, execute without routine con
parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8'));
// USER.md (+ USER.local.md operator overlay, appended directly under the
// profile its base owns). Fleet first start is Linux/systemd-owned and uses
// the no-follow reader at point of use. Standalone launches retain the
// portable tolerant path used on macOS and other supported hosts.
const fleetAgentName = process.env['MOSAIC_AGENT_NAME'];
const user =
fleetAgentName === undefined
? readOptional(join(mosaicHome, 'USER.md'))
: readInstalledIdentityContractAtPointOfUse(mosaicHome, 'USER.md').toString('utf8');
// profile its base owns).
const user = readOptional(join(mosaicHome, 'USER.md'));
if (user) parts.push('\n\n# User Profile\n\n' + user);
const userLocal = readOptional(join(mosaicHome, 'USER.local.md'));
if (userLocal.trim()) {
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
}
const fleetIdentity = resolveFleetIdentity(mosaicHome, fleetAgentName);
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
if (!fleetIdentity.ok) {
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
}
const canonicalMember = fleetIdentity.identity?.member;
if (canonicalMember) {
assertAmbientFleetClassMatches(canonicalMember.name, canonicalMember.className);
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass;
if (ambientClass !== canonicalMember.className) {
throw new Error(
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
);
}
}
// TOOLS.md