diff --git a/docs/ADMIN-GUIDE/README.md b/docs/ADMIN-GUIDE/README.md index 2aef404b..7c8bdb73 100644 --- a/docs/ADMIN-GUIDE/README.md +++ b/docs/ADMIN-GUIDE/README.md @@ -9,19 +9,19 @@ This book is the canonical home for installation, configuration, deployment, rou - [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries. - [Documentation sitemap](../SITEMAP.md) — resolvable current navigation and authority-gated migration summary. - [Product requirements](../PRD.md) — normative requirements, currently marked draft. -- [Operations index](operations/README.md) — current local procedures 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. ## 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 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. | +| Chapter | Scope | Status | +| ------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. | +| `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. | +| `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. | +| [`operations/`](operations/README.md) | Health, observability, routine operation, and maintenance. | Local upgrade/recovery and fleet first start are current; connector lease operations are held. | +| [`security/`](security/README.md) | Authentication, authorization, SSO, secrets, and security controls. | SSO provider guide is current; other pages are planned. | +| `recovery/` | Incident response, backup, rollback, and recovery. | Scaffold only. | Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice. diff --git a/docs/ADMIN-GUIDE/operations/README.md b/docs/ADMIN-GUIDE/operations/README.md index c6ad5ce6..5218117b 100644 --- a/docs/ADMIN-GUIDE/operations/README.md +++ b/docs/ADMIN-GUIDE/operations/README.md @@ -5,6 +5,7 @@ ## 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 diff --git a/docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md b/docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md new file mode 100644 index 00000000..463b16f6 --- /dev/null +++ b/docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md @@ -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) diff --git a/docs/DEVELOPER-GUIDE/README.md b/docs/DEVELOPER-GUIDE/README.md index 39699767..92e32ed9 100644 --- a/docs/DEVELOPER-GUIDE/README.md +++ b/docs/DEVELOPER-GUIDE/README.md @@ -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. - [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. diff --git a/docs/DEVELOPER-GUIDE/architecture/README.md b/docs/DEVELOPER-GUIDE/architecture/README.md index a7d4ac9f..f015acaf 100644 --- a/docs/DEVELOPER-GUIDE/architecture/README.md +++ b/docs/DEVELOPER-GUIDE/architecture/README.md @@ -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. - [`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. diff --git a/docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md b/docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md new file mode 100644 index 00000000..1cc2f3d1 --- /dev/null +++ b/docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md @@ -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. diff --git a/docs/PRD.md b/docs/PRD.md index 77ccd609..33c4dfc2 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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 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) diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 71a4699c..806c9b00 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -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. - [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. @@ -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. - [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. @@ -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 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. diff --git a/docs/USER-GUIDE/README.md b/docs/USER-GUIDE/README.md index 269ee134..6102a91e 100644 --- a/docs/USER-GUIDE/README.md +++ b/docs/USER-GUIDE/README.md @@ -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. - [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 @@ -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. | | `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. | | `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. - [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. diff --git a/docs/USER-GUIDE/workflows/fleet-unattended-first-start.md b/docs/USER-GUIDE/workflows/fleet-unattended-first-start.md new file mode 100644 index 00000000..82cfd063 --- /dev/null +++ b/docs/USER-GUIDE/workflows/fleet-unattended-first-start.md @@ -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 +``` + +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) diff --git a/docs/reports/README.md b/docs/reports/README.md index 22c048c6..1d9470ec 100644 --- a/docs/reports/README.md +++ b/docs/reports/README.md @@ -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. - [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 @@ -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. - [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 diff --git a/docs/reports/code-review/1264-code-review.md b/docs/reports/code-review/1264-code-review.md new file mode 100644 index 00000000..f9c8c2c8 --- /dev/null +++ b/docs/reports/code-review/1264-code-review.md @@ -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. diff --git a/docs/reports/documentation/1264-documentation-checklist.md b/docs/reports/documentation/1264-documentation-checklist.md new file mode 100644 index 00000000..e03e4cf3 --- /dev/null +++ b/docs/reports/documentation/1264-documentation-checklist.md @@ -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. diff --git a/docs/reports/qa/2026-08-16-1264-unattended-first-start.md b/docs/reports/qa/2026-08-16-1264-unattended-first-start.md new file mode 100644 index 00000000..30b2f425 --- /dev/null +++ b/docs/reports/qa/2026-08-16-1264-unattended-first-start.md @@ -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/6–9 | +| 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. diff --git a/docs/scratchpads/1264-unattended-first-start.md b/docs/scratchpads/1264-unattended-first-start.md new file mode 100644 index 00000000..91d2d0ab --- /dev/null +++ b/docs/scratchpads/1264-unattended-first-start.md @@ -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. diff --git a/docs/scratchpads/README.md b/docs/scratchpads/README.md index 67586383..56ff4a30 100644 --- a/docs/scratchpads/README.md +++ b/docs/scratchpads/README.md @@ -7,6 +7,10 @@ - [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 diff --git a/packages/mosaic/framework/defaults/README.md b/packages/mosaic/framework/defaults/README.md index 8222ffb3..611f266b 100644 --- a/packages/mosaic/framework/defaults/README.md +++ b/packages/mosaic/framework/defaults/README.md @@ -102,7 +102,7 @@ mosaic yolo pi # Launch Pi in yolo mode The launcher: 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 4. Forwards all arguments to the runtime CLI diff --git a/packages/mosaic/src/commands/fleet-first-start-identity.spec.ts b/packages/mosaic/src/commands/fleet-first-start-identity.spec.ts new file mode 100644 index 00000000..7447f638 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-first-start-identity.spec.ts @@ -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); + }); +}); diff --git a/packages/mosaic/src/commands/fleet-first-start-identity.ts b/packages/mosaic/src/commands/fleet-first-start-identity.ts new file mode 100644 index 00000000..cc340b67 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-first-start-identity.ts @@ -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; +} diff --git a/packages/mosaic/src/commands/launch-first-start.spec.ts b/packages/mosaic/src/commands/launch-first-start.spec.ts new file mode 100644 index 00000000..48597de1 --- /dev/null +++ b/packages/mosaic/src/commands/launch-first-start.spec.ts @@ -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 { + 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 { + return new Promise((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 => 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([]); + }); +}); diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index c4d8f3d2..d0ca4208 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -30,6 +30,7 @@ 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 { 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'); @@ -232,6 +233,37 @@ function checkRuntime(cmd: string): void { 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'}`, + ); + } + 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)) { console.log('[mosaic] SOUL.md not found. Running setup wizard...');