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