fix(#1264): bootstrap fleet identity without a TTY
ci/woodpecker/pr/ci Pipeline failed

This commit is contained in:
goals
2026-08-16 17:37:32 -05:00
parent 476db12b92
commit 43fa047787
21 changed files with 1255 additions and 11 deletions
+9 -9
View File
@@ -9,19 +9,19 @@ This book is the canonical home for installation, configuration, deployment, rou
- [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries. - [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries.
- [Documentation sitemap](../SITEMAP.md) — resolvable current navigation and authority-gated migration summary. - [Documentation sitemap](../SITEMAP.md) — resolvable current navigation and authority-gated migration summary.
- [Product requirements](../PRD.md) — normative requirements, currently marked draft. - [Product requirements](../PRD.md) — normative requirements, currently marked draft.
- [Operations index](operations/README.md) — current local procedures and explicitly held operational outlines. - [Operations index](operations/README.md) — current local procedures, unattended fleet first-start handling, and explicitly held operational outlines.
- [Security index](security/README.md) — current SSO provider configuration. - [Security index](security/README.md) — current SSO provider configuration.
## Chapter map ## Chapter map
| Chapter | Scope | Status | | Chapter | Scope | Status |
| ------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------- | | ------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. | | `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. |
| `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. | | `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. |
| `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. | | `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. |
| [`operations/`](operations/README.md) | Health, observability, routine operation, and maintenance. | Local upgrade/recovery is current; connector lease operations are held. | | [`operations/`](operations/README.md) | Health, observability, routine operation, and maintenance. | Local upgrade/recovery and fleet first start are current; connector lease operations are held. |
| [`security/`](security/README.md) | Authentication, authorization, SSO, secrets, and security controls. | SSO provider guide is current; other pages are planned. | | [`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. | | `recovery/` | Incident response, backup, rollback, and recovery. | Scaffold only. |
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice. Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
+1
View File
@@ -5,6 +5,7 @@
## Current procedures ## Current procedures
- [Upgrade safety and recovery](upgrade-safety-and-recovery.md) — installed-CLI and local-PGlite upgrade, rollback, and framework-configuration recovery. - [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 ## Held procedures
@@ -0,0 +1,64 @@
# 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 missing destination has no safe regular default source;
- a source or existing destination is a symlink, directory, unavailable, or over the bounded size;
- the fleet communications helper/roster cannot be validated.
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 exec vitest run \
src/commands/launch-first-start.spec.ts
```
It runs the real 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, standalone wizard preservation,
and concurrent first start.
Do not use this fixture as proof that a real provider credential is present or that a package has
been deployed. Those require separate environment-specific evidence.
## Related
- [User workflow](../../USER-GUIDE/workflows/fleet-unattended-first-start.md)
- [Developer architecture](../../DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md)
- [Verification report](../../reports/qa/2026-08-16-1264-unattended-first-start.md)
+1
View File
@@ -26,6 +26,7 @@ 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. - [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. - [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. Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
@@ -11,6 +11,7 @@ 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. - [`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. - [`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. - [`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. - [`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. 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.
@@ -0,0 +1,71 @@
# 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 nonblank `MOSAIC_AGENT_NAME` selects the fleet path.
2. `resolveFleetIdentity()` must resolve that exact member through the existing roster/helper
boundary before any identity seed.
3. Safe bounded snapshots are read from only the missing contracts under `defaults/`.
4. Each snapshot is written to a random owner-private temporary file in `MOSAIC_HOME`.
5. `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.
6. Temporary files are removed, and both installed contracts are re-opened through the no-symlink
secure-file reader before launch continues.
7. `composeContract()` independently re-resolves the roster and injects exact member identity and
communications data.
A standalone launch with no `MOSAIC_AGENT_NAME` retains the interactive wizard.
## 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 ambient name 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`; 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. 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.
Real Pi authentication and provider task execution remain environment tests, not claims of this
fixture.
## Non-goals
- Runtime installation or pane-PATH resolution (#1256/#1258).
- The held `~/.mosaic` launch-composition layer in PR #1213.
- Personalizing the operator's standalone identity without a wizard.
- Changing fleet systemd or shell launcher code.
+23
View File
@@ -146,6 +146,29 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards 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. 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) ## Exact Cross-Harness Fleet Communications Contract (#766)
+4
View File
@@ -38,11 +38,13 @@ 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. - [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. - [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. - [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 documentation
- [Administrator operations](ADMIN-GUIDE/operations/README.md) — current local procedures and explicitly held outlines. - [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. - [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. - [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. - [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. - [SSO providers](ADMIN-GUIDE/security/sso-providers.md) — Authentik, WorkOS, and Keycloak configuration and discovery.
@@ -56,6 +58,7 @@ 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. - [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. - [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. - [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. - [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. - [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. - [Architecture RFCs](DEVELOPER-GUIDE/architecture/rfcs/README.md) — draft proposals without operational authority.
@@ -75,6 +78,7 @@ 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 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. - [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. - [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. - [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 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. - [Documentation catalog-audit plan](plans/2026-08-10-docs-catalog-audit.md) — evidence method and migration acceptance criteria.
+3 -1
View File
@@ -11,6 +11,7 @@ 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. - [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. - [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. - [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 ## Chapter map
@@ -18,7 +19,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. | | `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. | | `concepts/` | User-facing terminology, product concepts, and mental models. | Scaffold only. |
| `workflows/` | Task-oriented procedures for using Mosaic Stack. | Discord conversation workflow is current. | | `workflows/` | Task-oriented procedures for using Mosaic Stack. | Discord and fleet first-start workflows are current. |
| `product/` | Current product surfaces and visible behavior. | Web dashboard reference is current. | | `product/` | Current product surfaces and visible behavior. | Web dashboard reference is current. |
| `troubleshooting/` | User-visible failures, diagnostics, and fixes. | Scaffold only. | | `troubleshooting/` | User-visible failures, diagnostics, and fixes. | Scaffold only. |
@@ -27,6 +28,7 @@ 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. - [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. - [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. - [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. Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
@@ -0,0 +1,59 @@
# 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 and 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 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, a required default is
missing or unsafe, or an existing identity contract is not a safe regular file. Repair the named
path and retry the same exact roster member; do not copy another seat's personalized identity.
## Separate prerequisites
This behavior clears the Mosaic identity-wizard wall only. A clean host still needs:
- the declared runtime installed on the pane PATH;
- the fleet transport and generated unit assets; and
- runtime/provider authentication appropriate to that seat.
Those checks are separate so a successful identity bootstrap is not reported as a fully authenticated
agent session.
## Related
- [Administrator runbook](../../ADMIN-GUIDE/operations/fleet-unattended-first-start.md)
- [Developer architecture](../../DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md)
- [Verification report](../../reports/qa/2026-08-16-1264-unattended-first-start.md)
+3
View File
@@ -12,11 +12,13 @@ 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. - [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. - [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. - [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 ## Code-review evidence
- [Issue #756 independent code review](code-review/756-code-review.md) — historical exact-scope review of the official Discord plugin workstream. - [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. - [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 ## Security evidence
@@ -26,6 +28,7 @@ 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. - [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. - [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 ## Native Kanban/SOT evidence
@@ -0,0 +1,59 @@
# Issue #1264 Independent Code and Security Review
> Scope: uncommitted delivery delta for `fix/1264-fleet-unattended-first-start` against
> `origin/next@476db12b92971634b67fd2057b7577ee5894e449` | Reviewer: Codex CLI via Mosaic review tools
## Initial code review
Command:
```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 reviewed, `0` blockers, `1` should-fix.
Finding: `checkSoul()` trimmed `MOSAIC_AGENT_NAME` for pre-seed roster resolution while later
composition used the original value. A padded exact name could therefore seed identity files and
then fail composition.
Remediation:
- treat any present blank or surrounding-whitespace value as an invalid fleet launch;
- reject it before roster lookup or identity writes; and
- add three real-CLI no-side-effect regressions for leading padding, trailing padding, and empty
values.
## Code re-review
Command:
```bash
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted \
-o /tmp/1264-codex-code-rereview.json
```
Result: `approve`, confidence `0.86`, `15` files reviewed, no findings. The review sandbox could run
package typecheck but could not run Vitest because its checkout was read-only and Vite attempted to
create a timestamped config artifact (`EROFS`). This is not scored as test evidence; the executor's
writable worktree independently passed the focused and full suites recorded in the QA report.
## Security review
Final command:
```bash
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted \
-o /tmp/1264-codex-security-rereview.json
```
Result: risk `none`, confidence `0.91`, `20` files reviewed, `0` critical/high/medium/low findings.
The review specifically confirmed roster validation before seeding, bounded no-symlink reads,
no-clobber publication, unsafe/padded identity refusal, and fail-closed behavior before runtime.
## Independent PR review gate
Automated review is complete. The PR still requires a formal reviewer who is neither the implementation
seat nor Fred, per the assignment. That review and CI status are recorded in the QA report when
available.
@@ -0,0 +1,27 @@
# #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 verification.
- [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] Independent automated code/security review completed on the final uncommitted delta.
- [x] Padded-name finding remediated and automated re-review approved with no findings.
- [ ] Formal PR review by a reviewer other than goals/Fred completed on the exact pushed head.
@@ -0,0 +1,185 @@
# #1264 Unattended Fleet First-Start Verification
> Status: **IN PROGRESS** | 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 investigation was read from jarvis-brain commit
`6c0b6fc70ae6a179a1b7ff9dedfc54e9adccd19a`, report
`docs/reports/2026-08-16_sbx-canary-greenfield-e2e.md`. It measured:
```text
systemd -> start-agent-session.sh -> mosaic yolo pi (PID 3726)
-> child mosaic wizard (PID 3762)
```
The canary pane remains preserved and was not accessed. Product behavior is independently tested here
with temporary roots and fake runtime executables; no canary or installed-host inference is scored as
local PASS evidence.
## Controls
- Worktree base: `origin/next@476db12b92971634b67fd2057b7577ee5894e449`.
- `DATABASE_URL` remains unset.
- No real credential, token, provider, VM, installed Mosaic tree, unit, timer, PATH profile, or live
tmux session is read or mutated.
- Tiny's concurrent runtime-preflight and `start-agent-session.sh` PATH work are 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 | Real built-CLI subprocess with piped stdin | Focused GREEN, CLI test 1 |
| Missing top-level identity files are initialized from shipped defaults | Exact-byte and `0600` assertions | Focused GREEN, CLI tests 1/11 |
| Exact seat identity remains roster-owned | Captured argv plus unknown/padded/blank-name refusals | Focused GREEN, CLI tests 1/69 |
| Existing operator identity is never overwritten | Custom bytes/mode with defaults removed | Focused GREEN, CLI tests 2/3 |
| Concurrent/repeated first start is safe | Four parallel CLIs plus repeated launch | Focused GREEN, CLI tests 2/11 |
| Missing/unsafe defaults fail without prompting | Missing, symlink, oversized, and installed-link tests | Focused GREEN, unit/CLI tests |
| Standalone launch retains wizard | Same real CLI without fleet identity | Focused GREEN, CLI test 10 |
## Command evidence
### Worktree helper refusal and sanctioned fallback
Command:
```bash
~/bin/mosaic-worktree.sh new fix/1264-fleet-unattended-first-start --from origin/next
```
Exit: `1`. Stderr was retained; the helper refused because the derived worktree path was under
`/var/home/jason.woltje`, while `/src` does not exist on this host. Fred explicitly authorized the
plain-git fallback and path used for this task.
Command:
```bash
git -C /var/home/jason.woltje/src/stack worktree add \
/var/home/jason.woltje/agent-work/1264-unattended-first-start \
-b fix/1264-fleet-unattended-first-start origin/next
```
Exit: `0`; HEAD `476db12b92971634b67fd2057b7577ee5894e449`.
### RED
Production source remained unchanged after adding the reproducer. The built CLI represented
`origin/next@476db12` behavior.
Command:
```bash
env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic exec vitest run \
src/commands/launch-first-start.spec.ts
```
Exit: `1`.
```text
Test Files 1 failed (1)
Tests 1 failed (1)
[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 fixture used piped stdin (not a TTY), a temporary `HOME`/`MOSAIC_HOME`, shipped default bytes,
a canonical one-seat roster, a fake `pi`, and a fake lease-runtime boundary. The wizard rendered and
the runtime-boundary capture was never created. Complete combined stdout/stderr was retained at
`/tmp/1264-red.out` during execution.
### GREEN and baseline
After the production change, the original one-test command exited `0` with `1/1` passing. The final
focused command was:
```bash
env -u DATABASE_URL 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 and `119/119` tests passed. The 11 production-kind CLI tests cover no-TTY
launch, captured exact roster name/class, byte-equal `0600` seeds, no-clobber/idempotence, partial
seed, missing/symlink defaults, unknown/padded/blank ambient members, standalone interactive control,
and four concurrent first starts with no temporary residue. Nine direct filesystem tests cover
successful/no-clobber hard-link publication, unexpected link errors, source prevalidation, existing
operator contracts, idempotence, symlink sources/destinations, and oversized input.
Full package Vitest:
```text
Test Files 88 passed (88)
Tests 1568 passed (1568)
Exit 0
```
New helper coverage:
```text
Statements 100% | Branches 93.33% | Functions 100% | Lines 100%
9/9 tests passed; coverage command exit 0
```
Baseline commands:
```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
pnpm --filter @mosaicstack/mosaic build exit 0
bash framework/tools/fleet/test-start-agent-session.sh
exit 0; retained expected fixture LD_PRELOAD warning
bash framework/tools/quality/scripts/test-install-migration.sh
21 passed, 0 failed, exit 0
bash framework/tools/_scripts/test-mosaic-init-rce.sh
PASS, exit 0
git diff --check exit 0
```
The aggregate `test:framework-shell` command exited `1` at `invariant_r_unittest.py`: `5/6` tests
passed and the remaining test refused the operator-global Pi drift from measured `0.84.1` to
installed `0.84.2`. This is retained as an environment/version-coupling failure, not scored as a
#1264 code failure and not retried. The aggregate stopped there; later aggregate stages are
**UNTESTED** except for the three targeted shell suites listed above.
Root `pnpm test` is **UNTESTED** because it can execute the prohibited local PostgreSQL-dependent
gateway isolation path. No PostgreSQL service, connection, migration, or initialization was used.
CI is **UNTESTED — pending PR**.
## Explicitly untested
- Canary VM remediation or restart: **UNTESTED and prohibited for this task**.
- Real Pi authentication/provider prompt and task execution: **UNTESTED**.
- PR #1213 composition layer: **UNTESTED and not required**.
- Deployment/published npm package behavior: **UNTESTED until CI/release; deployment is not this PR's scope**.
## Review and residual risks
Initial independent Codex code review returned `request-changes` with one should-fix: a padded
`MOSAIC_AGENT_NAME` could be trimmed for pre-seed validation and later rejected in composition,
leaving seeds behind. The implementation now rejects blank or padded values before roster lookup or
writes; three no-side-effect regression cases pass. Codex re-review approved the remediated delta
with no findings (`confidence=0.86`). Its read-only sandbox could not execute Vitest because Vite
needed a temporary config artifact; executor-owned focused/full results above are the test evidence.
Final Codex security review found no confirmed vulnerabilities (`risk=none`, confidence `0.91`).
Formal PR review by a reviewer other than goals/Fred and CI remain pending.
Real Pi authentication/provider prompt and task execution remain unmeasured. The local gate proves
that Mosaic crosses its identity boundary and reaches the fake lease-runtime boundary; it does not
claim provider readiness or deployment.
@@ -0,0 +1,114 @@
# #1264 — Unattended fleet first start
## Tracking
- Issue: `mosaicstack/stack#1264`
- Branch: `fix/1264-fleet-unattended-first-start`
- Base: `origin/next@476db12b92971634b67fd2057b7577ee5894e449`
- Worktree: `/var/home/jason.woltje/agent-work/1264-unattended-first-start`
- Coordinator: Fred; reviewer must be neither Fred nor this implementation seat.
- `docs/TASKS.md` is orchestrator-owned and is not modified by this worker.
## 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 an exact seat identity from the canonical roster and
preserving the interactive wizard for standalone launches.
## Intake and boundaries
- Read Daphne's source report at jarvis-brain commit
`6c0b6fc70ae6a179a1b7ff9dedfc54e9adccd19a` before implementation.
- Read Tiny's concurrent `PREFLIGHT-STATE.md`; do not edit
`start-agent-session.sh`, its PATH builder, runtime preflight, or #1258's Node candidate.
- Do not touch the canary VM or this host's `~/.config/mosaic`.
- Do not depend on held PR #1213 or introduce the proposed `~/.mosaic` composition layer.
- No real credentials/provider calls. Tests use temporary roots and fake executables only.
- Report exact commands, exit codes, and retained stderr in
`docs/reports/qa/2026-08-16-1264-unattended-first-start.md`.
## Requirements and design assumptions
- PRD IDs: `FCM-REQ-12`, `AC-FCM-10`; `FCM-REQ-11` is reserved by concurrent #1256.
- `ASSUMPTION:` generated `MOSAIC_AGENT_NAME` distinguishes fleet launches; the existing runtime
composer still validates the exact member against the canonical roster.
- Prefer the shipped `defaults/SOUL.md` and `defaults/USER.md` over threading wizard flags through
every launcher. Seed only missing top-level files, never overwrite existing operator content.
- Generic defaults are a base behavior contract, not the seat identity. The roster-resolved injected
block supplies exact agent/session name and class.
- Fleet missing/unsafe defaults must fail closed without attempting an interactive wizard.
- Standalone missing identity retains today's wizard behavior.
## Plan
1. Add a no-TTY, systemd-equivalent failing reproducer before production changes; record RED.
2. Add narrow first-start bootstrap logic at the existing `checkSoul()` seam only.
3. Prove generic default bytes, private modes, exact roster identity, no clobber, idempotence/race,
invalid-default refusal, and standalone wizard preservation.
4. Run focused, package, shell/framework, typecheck, lint, format, build, and greenfield fixture gates.
5. Update user/developer/admin documentation, sitemap, QA report, and documentation checklist.
6. Obtain independent code review, remediate, commit with explicit `goals` identity, queue-guard,
push, open PR to `next`, and request a reviewer other than Fred/goals.
7. After branch is pushed and worktree is clean, remove this worktree as Fred explicitly required.
## Budget
- No user-specified token cap.
- Working estimate: 25K tokens for source/test/docs/review/PR lifecycle.
- Reduce scope before expanding launcher surfaces; stop and report if the fix requires #1213 or the
contested pane-PATH function.
## Progress
- [x] Issue #1264 identified and read.
- [x] Daphne's report and Tiny's state read.
- [x] Fred authorized plain-git worktree placement after the mandated helper failed on this host.
- [x] PRD amended before coding.
- [x] RED test captured: focused Vitest `1 failed`, command exit `1`; real built CLI entered the
identity wizard under piped stdin and never reached the fake runtime boundary.
- [x] Implementation and canonical documentation complete.
- [x] Applicable local baseline and situational gates complete; aggregate shell has one scoped
environment refusal and root DB-backed test remains prohibited/unrun.
- [x] Independent automated review complete: one padded-name finding fixed; clean code/security
re-review. Formal non-goals/non-Fred PR reviewer pending.
- [ ] PR lifecycle complete.
- [ ] Worktree removed.
## Test evidence
### RED — 2026-08-16
```bash
env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic exec vitest run \
src/commands/launch-first-start.spec.ts
```
Exit `1`; `1` file failed, `1` test failed. The child emitted
`[mosaic] SOUL.md not found. Running setup wizard...`, rendered
`What would you like to do?`, then emitted `[mosaic] Setup failed. Run: mosaic wizard`.
The assertion expected runtime exit `0` and received `1`; the fake runtime-boundary capture was not
created. Full output is retained at `/tmp/1264-red.out` for this work session.
### GREEN — current delta
- Original no-TTY subprocess test: `1/1` passed, command exit `0`.
- Final focused set: `6/6` files, `119/119` tests passed.
- Full Mosaic Vitest: `88/88` files, `1568/1568` tests passed.
- New helper coverage: 100% statements/functions/lines, 93.33% branches.
- Root preflight/format/diff checks passed; typecheck 45/45, lint 25/25, build 25/25.
- Targeted start-agent-session, install migration (21/21), and init-RCE shell tests passed.
- Aggregate framework shell stopped at the known environment refusal: global Pi is 0.84.2 while
Invariant R is measured for 0.84.1. It was not retried or scored as a #1264 failure.
- Root `pnpm test` remains **UNTESTED** because it can execute prohibited PostgreSQL-dependent tests.
- CI remains **UNTESTED** until the PR is pushed.
## Risks / blockers
- The shipped defaults are intentionally generic; exact fleet identity must remain visibly
roster-derived to avoid making every seat indistinguishable.
- First-start writes are a concurrent boundary when several systemd seats launch together; creation
must be no-clobber and idempotent.
- The test intentionally drives the real built CLI rather than exporting private launch helpers; this
keeps the systemd/no-TTY execution boundary under test.
- Real Pi authentication and provider task execution remain an environment-level residual and are
explicitly untested in this local fixture.
+4
View File
@@ -7,6 +7,10 @@
- [DOCS-IA-001 — information architecture](DOCS-IA-001.md) — completed structure-design and documentation-contract record. - [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. - [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. 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 ## Related
+1 -1
View File
@@ -102,7 +102,7 @@ mosaic yolo pi # Launch Pi in yolo mode
The launcher: The launcher:
1. Verifies `~/.config/mosaic` exists 1. Verifies `~/.config/mosaic` exists
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing) 2. Resolves identity: standalone launches auto-run `mosaic init` when `SOUL.md` is missing; exact roster-owned fleet launches atomically seed only missing `SOUL.md`/`USER.md` from generic `defaults/` and never prompt
3. Injects `AGENTS.md` into the runtime 3. Injects `AGENTS.md` into the runtime
4. Forwards all arguments to the runtime CLI 4. Forwards all arguments to the runtime CLI
@@ -0,0 +1,153 @@
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('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('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);
});
});
@@ -0,0 +1,82 @@
import { randomBytes } from 'node:crypto';
import { existsSync, linkSync, 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 isAlreadyExistsError(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'EEXIST';
}
/** @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 (isAlreadyExistsError(error)) return false;
throw error;
}
}
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) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`fleet identity ${kind} is unavailable or unsafe: ${path} (${reason})`);
}
}
/**
* 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 (existsSync(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);
} finally {
if (temporaryCreated) rmSync(temporary, { force: true });
}
}
for (const entry of FLEET_IDENTITY_DEFAULTS) {
readIdentityContract(mosaicHome, join(mosaicHome, entry), 'installed');
}
return seeded;
}
@@ -0,0 +1,359 @@
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',
): NodeJS.ProcessEnv {
return {
HOME: fixture.home,
MOSAIC_HOME: fixture.mosaicHome,
...(fleet
? {
MOSAIC_AGENT_NAME: agentName,
MOSAIC_AGENT_CLASS: 'worker',
}
: {}),
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;
} = {},
): 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',
),
});
}
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.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([]);
});
});
+32
View File
@@ -30,6 +30,7 @@ import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js'; import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js'; import { canonicalizeRoleClass } from './fleet-personas.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js'; import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
import { seedFleetIdentityDefaults } from './fleet-first-start-identity.js';
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js'; import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -232,6 +233,37 @@ function checkRuntime(cmd: string): void {
function checkSoul(): void { function checkSoul(): void {
const soulPath = join(MOSAIC_HOME, 'SOUL.md'); 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'}`,
);
}
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 shipped identity defaults under ${join(MOSAIC_HOME, 'defaults')} and retry this exact roster member.`,
);
process.exit(1);
}
}
if (!existsSync(soulPath)) { if (!existsSync(soulPath)) {
console.log('[mosaic] SOUL.md not found. Running setup wizard...'); console.log('[mosaic] SOUL.md not found. Running setup wizard...');