From 43fa0477877e0d0f110da8d11c3033b40ddeb191 Mon Sep 17 00:00:00 2001 From: goals Date: Sun, 16 Aug 2026 17:37:32 -0500 Subject: [PATCH 1/3] fix(#1264): bootstrap fleet identity without a TTY --- docs/ADMIN-GUIDE/README.md | 18 +- docs/ADMIN-GUIDE/operations/README.md | 1 + .../fleet-unattended-first-start.md | 64 ++++ docs/DEVELOPER-GUIDE/README.md | 1 + docs/DEVELOPER-GUIDE/architecture/README.md | 1 + .../fleet-first-start-identity.md | 71 ++++ docs/PRD.md | 23 ++ docs/SITEMAP.md | 4 + docs/USER-GUIDE/README.md | 4 +- .../workflows/fleet-unattended-first-start.md | 59 +++ docs/reports/README.md | 3 + docs/reports/code-review/1264-code-review.md | 59 +++ .../1264-documentation-checklist.md | 27 ++ .../2026-08-16-1264-unattended-first-start.md | 185 +++++++++ .../1264-unattended-first-start.md | 114 ++++++ docs/scratchpads/README.md | 4 + packages/mosaic/framework/defaults/README.md | 2 +- .../fleet-first-start-identity.spec.ts | 153 ++++++++ .../commands/fleet-first-start-identity.ts | 82 ++++ .../src/commands/launch-first-start.spec.ts | 359 ++++++++++++++++++ packages/mosaic/src/commands/launch.ts | 32 ++ 21 files changed, 1255 insertions(+), 11 deletions(-) create mode 100644 docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md create mode 100644 docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md create mode 100644 docs/USER-GUIDE/workflows/fleet-unattended-first-start.md create mode 100644 docs/reports/code-review/1264-code-review.md create mode 100644 docs/reports/documentation/1264-documentation-checklist.md create mode 100644 docs/reports/qa/2026-08-16-1264-unattended-first-start.md create mode 100644 docs/scratchpads/1264-unattended-first-start.md create mode 100644 packages/mosaic/src/commands/fleet-first-start-identity.spec.ts create mode 100644 packages/mosaic/src/commands/fleet-first-start-identity.ts create mode 100644 packages/mosaic/src/commands/launch-first-start.spec.ts 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...'); -- 2.54.0 From 9dc90be7e13b1cd609f6df97d43d890ef5392ca0 Mon Sep 17 00:00:00 2001 From: goals Date: Sun, 16 Aug 2026 18:33:39 -0500 Subject: [PATCH 2/3] fix(#1264): harden unattended identity bootstrap --- .../fleet-unattended-first-start.md | 21 +- .../fleet-first-start-identity.md | 37 ++- .../workflows/fleet-unattended-first-start.md | 10 +- docs/reports/code-review/1264-code-review.md | 83 +++--- .../1264-documentation-checklist.md | 11 +- .../2026-08-16-1264-unattended-first-start.md | 256 ++++++++++-------- .../1264-unattended-first-start.md | 151 +++++------ packages/mosaic/framework/defaults/README.md | 2 +- packages/mosaic/package.json | 3 +- .../src/commands/compose-contract.spec.ts | 19 ++ .../fleet-first-start-identity.spec.ts | 29 ++ .../commands/fleet-first-start-identity.ts | 56 +++- .../src/commands/launch-first-start.spec.ts | 19 +- packages/mosaic/src/commands/launch.ts | 39 ++- 14 files changed, 448 insertions(+), 288 deletions(-) diff --git a/docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md b/docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md index 463b16f6..1754fc59 100644 --- a/docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md +++ b/docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md @@ -27,9 +27,11 @@ The fleet path never falls back to an interactive wizard. It exits nonzero befor when: - `MOSAIC_AGENT_NAME` is not an exact roster member; +- an ambient `MOSAIC_AGENT_CLASS` disagrees with that member's canonical class; - 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. +- a source or existing destination is a symlink (including dangling), directory, unavailable, or over the bounded size; +- the fleet communications helper/roster cannot be validated; or +- `USER.md` cannot be securely re-read at the point where its content is composed. Diagnostics begin with: @@ -45,14 +47,17 @@ not delete or replace an existing personalized `SOUL.md`/`USER.md` merely to cle The source gate is: ```bash -pnpm --filter @mosaicstack/mosaic exec vitest run \ - src/commands/launch-first-start.spec.ts +pnpm --filter @mosaicstack/mosaic... build && \ + 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. +The build leg is load-bearing: `dist/` is ignored, so a direct Vitest invocation could otherwise run +absent or stale CLI output. The gate runs the exact-source built CLI in subprocesses with piped stdin, +temporary homes, a canonical fixture roster, fake runtime/broker executables, and no provider call. It +covers no-TTY launch, exact identity, +private modes, no-clobber, missing/symlink defaults, unknown members, class mismatch, +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. diff --git a/docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md b/docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md index 1cc2f3d1..2d940134 100644 --- a/docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md +++ b/docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md @@ -14,17 +14,20 @@ fleet pane with no TTY, that child blocked or failed before the runtime 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. +1. A present, nonblank, whitespace-exact `MOSAIC_AGENT_NAME` selects the fleet path. 2. `resolveFleetIdentity()` must resolve that exact member through the existing roster/helper - boundary 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 + boundary, and any ambient `MOSAIC_AGENT_CLASS` must canonicalize to the roster class, before any + identity seed. +3. `lstatSync()` preflights every destination directory entry without following links, so a dangling + link is rejected before its counterpart can be published. +4. Safe bounded snapshots are read from only the missing contracts under `defaults/`. +5. Each snapshot is written to a random owner-private temporary file in `MOSAIC_HOME`. +6. `linkSync()` publishes the complete file without overwriting an existing path. `EEXIST` means a concurrent seat or operator won; the existing path is preserved and revalidated. -6. Temporary files are removed, and both installed contracts are re-opened through the no-symlink +7. 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. +8. `composeContract()` independently re-resolves the roster, securely reads `USER.md` through a + descriptor at the point of use, and injects exact member identity and communications data. A standalone launch with no `MOSAIC_AGENT_NAME` retains the interactive wizard. @@ -39,13 +42,14 @@ not the source of a fleet seat's identity. The canonical roster controls: - 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. +An unknown/padded ambient name or mismatched ambient class fails before any file is seeded. This +avoids replacing the interactive wall with a fleet of indistinguishable or ambiently invented +identities. ## Concurrency and filesystem properties -- Sources and final destinations are bounded regular files beneath `MOSAIC_HOME`; symlinks are not - followed. +- Sources and final destinations are bounded regular files beneath `MOSAIC_HOME`; target and dangling + symlinks are not followed. - New files have mode `0600`. - Hard-link publication is same-filesystem, atomic, and no-clobber. - A temporary path is removed only when this process successfully created it. @@ -56,9 +60,12 @@ with a fleet of indistinguishable or ambiently invented identities. ## 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. +no-TTY subprocess, not a direct wizard test. The package `test:vitest` gate builds Mosaic before +Vitest, while the clean-checkout command builds its workspace dependencies first, so ignored +`dist/cli.js` cannot be absent or stale. A fake lease launcher records whether execution reached the +runtime boundary and captures the composed prompt. +Positive and negative cases prove the check can both proceed and refuse. Composition coverage also +replaces a previously validated `USER.md` with an external symlink and proves point-of-use refusal. Real Pi authentication and provider task execution remain environment tests, not claims of this fixture. diff --git a/docs/USER-GUIDE/workflows/fleet-unattended-first-start.md b/docs/USER-GUIDE/workflows/fleet-unattended-first-start.md index 82cfd063..8425971e 100644 --- a/docs/USER-GUIDE/workflows/fleet-unattended-first-start.md +++ b/docs/USER-GUIDE/workflows/fleet-unattended-first-start.md @@ -11,7 +11,8 @@ stop at the interactive identity wizard. 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; +1. validates the exact roster member, its canonical class, and the installed fleet communications + helper; 2. reads the shipped generic contracts from `~/.config/mosaic/defaults/SOUL.md` and `defaults/USER.md`; 3. creates only the missing top-level `SOUL.md` and `USER.md` as owner-private files; @@ -37,9 +38,10 @@ A roster-owned seat may be started without attaching to its pane: 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. +Mosaic refuses before runtime execution if the requested member is absent, its ambient class +conflicts with the roster, a required default is missing or unsafe, or an existing identity contract +is not a safe regular file. Repair the named component and retry the same exact roster member; do not +copy another seat's personalized identity. ## Separate prerequisites diff --git a/docs/reports/code-review/1264-code-review.md b/docs/reports/code-review/1264-code-review.md index f9c8c2c8..76f74be1 100644 --- a/docs/reports/code-review/1264-code-review.md +++ b/docs/reports/code-review/1264-code-review.md @@ -1,59 +1,78 @@ -# Issue #1264 Independent Code and Security Review +# Issue #1264 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 +> Branch: `fix/1264-fleet-unattended-first-start` | Base: +> `origin/next@476db12b92971634b67fd2057b7577ee5894e449` -## Initial code review +## Initial automated review -Command: +Codex reviewed the pre-PR uncommitted delta with: ```bash ~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted \ -o /tmp/1264-codex-code-review.json ``` -Result: `request-changes`, confidence `0.93`, `20` files reviewed, `0` blockers, `1` should-fix. +Result: `request-changes`, confidence `0.93`, 20 files, one should-fix. `checkSoul()` trimmed +`MOSAIC_AGENT_NAME` for pre-seed resolution while composition used the original value, so a padded +name could seed files before later refusal. -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 rejected blank/leading/trailing-whitespace values before roster lookup or writes and +added three built-CLI no-side-effect regressions. Automated re-review approved that delta with no +findings (confidence `0.86`). Initial security review reported risk `none` (confidence `0.91`). -Remediation: +## Formal exact-head review -- 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. +Daphne reviewed PR #1268 at exact head `43fa0477877e0d0f110da8d11c3033b40ddeb191` and filed Gitea +review ID 168 as `REQUEST_CHANGES`. The review was source/PR-only; the canary remained untouched. -## Code re-review +Blocking groups: -Command: +1. class mismatch was validated after first-start mutation; +2. secure `USER.md` validation was discarded before ordinary path-following composition; +3. `existsSync()` treated a dangling destination symlink as missing, allowing counterpart partial + publication; and +4. the built-CLI/evidence chain allowed stale ignored `dist/`, cited an unshipped canary object, and + carried conflicting test totals/pane wording. + +The diagnostic's defaults-only repair advice was also inaccurate for roster/class/destination +failures. + +## Formal-review remediation + +All four blocking groups received regressions before production changes. The RED run produced four +failures while 1,568 existing tests passed. Remediation then: + +- validates canonical name and class before seeding; +- preflights destination directory entries with `lstatSync()` so target and dangling symlinks fail + before publication; +- securely reads `USER.md` through an `O_NOFOLLOW` descriptor at composition time; +- adds a Mosaic build before package Vitest and a dependency build in the clean-checkout command; +- replaces defaults-only advice with neutral named-component repair guidance; and +- reconciles shipping canary provenance, pane chronology, commands, and totals. + +Remediation code review: ```bash ~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted \ - -o /tmp/1264-codex-code-rereview.json + -o /tmp/1264-remediation-code-review.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. +Result: `approve`, confidence `0.88`, 6 files, no findings. Summary: the fail-closed destination +checks, class-validation order, secure composition, and build-before-Vitest path are coherent. -## Security review - -Final command: +Remediation security review: ```bash ~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted \ - -o /tmp/1264-codex-security-rereview.json + -o /tmp/1264-remediation-security-review.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. +Result: risk `none`, confidence `0.93`, 9 files, no critical/high/medium/low findings. The sandbox +could not run Vitest because Vite attempted to create a temporary config artifact on its read-only +mount (`EROFS`); executor-owned focused and full results are recorded in the QA report. -## Independent PR review gate +## Remaining 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. +Daphne must re-review the next exact pushed head. This report cannot record that future verdict +without changing the reviewed head, so the authoritative terminal verdict belongs to PR #1268's +Gitea review record. Fred and goals are excluded as reviewers. diff --git a/docs/reports/documentation/1264-documentation-checklist.md b/docs/reports/documentation/1264-documentation-checklist.md index e03e4cf3..4cc3c0ba 100644 --- a/docs/reports/documentation/1264-documentation-checklist.md +++ b/docs/reports/documentation/1264-documentation-checklist.md @@ -4,7 +4,8 @@ - [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] Administrator operations page documents source/destination ownership, failure handling, and an + exact-source build-before-Vitest verification gate. - [x] Developer architecture page documents control flow, identity authority, concurrency, and non-goals. - [x] `docs/SITEMAP.md` and book indexes updated. - [x] QA evidence is under `docs/reports/qa/`; working notes are under `docs/scratchpads/`. @@ -22,6 +23,8 @@ ## 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. +- [x] Initial padded-name finding remediated and automated re-review approved. +- [x] Daphne formal review ID 168 completed on exact first head `43fa0477` and requested changes. +- [x] Four formal-review groups reproduced red and remediated; automated remediation code/security + reviews are clean. +- [ ] Daphne exact-remediation-head re-review completed after push (Fred/goals excluded). 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 index 30b2f425..15486656 100644 --- a/docs/reports/qa/2026-08-16-1264-unattended-first-start.md +++ b/docs/reports/qa/2026-08-16-1264-unattended-first-start.md @@ -1,185 +1,201 @@ # #1264 Unattended Fleet First-Start Verification -> Status: **IN PROGRESS** | Executor: goals | Date: 2026-08-16 | Target: isolated local fixtures only +> Status: **IN PROGRESS — review remediation complete locally; push/re-review pending** | Executor: +> goals | Date: 2026-08-16 | Target: isolated local fixtures only ## Objective Verify that a named fleet seat launched through a systemd-equivalent, no-TTY environment on a clean host reaches its runtime boundary without an interactive Mosaic identity wizard. Preserve standalone -wizard behavior and canonical roster ownership of exact seat identity. +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: +Daphne's canary Run-7 report is reachable from jarvis-brain `origin/main` at +`8bf94afeb8c7d5df96cdd4a4508e75a1d2999710`, +`docs/reports/2026-08-16_sbx-canary-greenfield-e2e.md`. The earlier local object +`6c0b6fc70ae6a179a1b7ff9dedfc54e9adccd19a` is not reachable from an origin ref and is not used as +shipping provenance. Run 7 measured: ```text systemd -> start-agent-session.sh -> mosaic yolo pi (PID 3726) -> child mosaic wizard (PID 3762) ``` -The 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. +The pane was preserved when Run 7 was captured. Formal review ID 168 records that an authorized +rollback occurred later. This task never accessed or altered the canary VM, pane, snapshot, or +rollback state. Product behavior is independently tested here with temporary roots and fake runtime +executables. ## Controls -- 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. +- Original base: `origin/next@476db12b92971634b67fd2057b7577ee5894e449`. +- PR: #1268, first pushed head `43fa0477877e0d0f110da8d11c3033b40ddeb191`. +- `DATABASE_URL` remains unset for local tests. +- No runtime/provider credential or token value, VM, installed Mosaic tree, unit, timer, PATH profile, + or live tmux session is read or mutated. Standard Gitea/Woodpecker wrappers authenticate metadata + reads/writes without exposing credential values. +- Tiny's runtime-preflight and `start-agent-session.sh` PATH work remain out of scope. - Held PR #1213 is not a dependency. ## Requirements-to-evidence map -| Acceptance criterion | Method | Evidence | -| ---------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------ | -| No-TTY fleet first start avoids wizard and reaches runtime | 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 | +| Acceptance criterion | Method | Evidence | +| ---------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------- | +| No-TTY fleet first start avoids wizard and reaches runtime | Exact-source built CLI with piped stdin | CLI GREEN | +| Missing top-level identity files are initialized from shipped defaults | Exact-byte and `0600` assertions | CLI + filesystem GREEN | +| Exact seat identity remains roster-owned | Captured argv; name/class mismatch no-side-effect refusals | CLI GREEN | +| Existing operator identity is never overwritten | Custom bytes/mode with defaults removed | CLI + filesystem GREEN | +| Concurrent/repeated first start is safe | Four parallel CLIs plus repeated launch | CLI GREEN | +| Missing/unsafe defaults and destinations fail before partial mutation | Missing, target/dangling symlink, oversized, invalid-root cases | Filesystem/CLI GREEN | +| Validated `USER.md` cannot be replaced by an external symlink | Seed, replace, compose at point of use | Composition GREEN | +| Standalone launch retains wizard | Same built CLI without fleet identity | CLI GREEN | +| Built-CLI evidence cannot use stale ignored `dist/` | Build-with-dependencies gate before Vitest | Package script + command gate | -## Command evidence +## Initial RED -### 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: +Production source remained unchanged after adding the first reproducer. The CLI was built from +`origin/next@476db12` before the test. ```bash env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic exec vitest run \ src/commands/launch-first-start.spec.ts ``` -Exit: `1`. +Exit `1`; one file and one test failed. Output included: ```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. +The fake runtime-boundary capture was not created. Complete stdout/stderr was retained at +`/tmp/1264-red.out` during that work session. -### GREEN and baseline +## Formal-review remediation RED -After the production change, the original one-test command exited `0` with `1/1` passing. The final -focused command was: +Daphne's exact-head review ID 168 requested changes at `43fa0477`. Before changing production code, +new regressions were run against an exact-source build. Four tests failed while the existing 1,568 +passed: + +1. valid roster name plus mismatched ambient class seeded both files before refusal; +2. dangling `SOUL.md` allowed `USER.md` to be published before refusal; +3. dangling `USER.md` allowed `SOUL.md` to be published before refusal; and +4. replacing a securely validated `USER.md` with an external symlink was followed by composition. + +This establishes that all four reviewer findings were observable on the pushed implementation. + +## Final GREEN + +The production-kind command builds Mosaic and all workspace dependencies before invoking Vitest, +because `dist/` is ignored and may otherwise be absent or stale: ```bash -env -u DATABASE_URL 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 +env -u DATABASE_URL sh -c ' + pnpm --filter @mosaicstack/mosaic... build && + pnpm --filter @mosaicstack/mosaic exec vitest run \ + src/commands/fleet-first-start-identity.spec.ts \ + src/commands/launch-first-start.spec.ts \ + src/commands/launch.spec.ts \ + src/commands/compose-contract.spec.ts \ + src/config/file-adapter.test.ts \ + src/cli-smoke.spec.ts +' ``` -Exit: `0`; `6/6` files 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. +Exit `0`: `6/6` files, `124/124` tests. -Full package Vitest: +- 12 real-CLI/no-TTY tests cover exact roster name/class, byte-equal `0600` seeds, no-clobber, + partial seed, missing/symlink defaults, unknown/padded/blank name, mismatched class, standalone + wizard preservation, and four concurrent starts. +- 12 direct filesystem tests cover complete publication, existing operators, idempotence, source + prevalidation, target and dangling destination links, invalid roots, oversized input, and + unexpected link errors. +- Composition coverage deterministically replaces validated `USER.md` with an external symlink and + requires refusal at point of use. + +Full package gate (which rebuilds Mosaic itself after the clean-checkout dependency build): + +```bash +env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic run test:vitest +``` + +Exit `0`: `88/88` files, `1,573/1,573` tests. + +Focused helper + point-of-use coverage: ```text -Test Files 88 passed (88) -Tests 1568 passed (1568) -Exit 0 +2 files, 52/52 tests +Statements 97.97% | Branches 88% | Functions 100% | Lines 97.97% +Exit 0 ``` -New helper coverage: +Final repository gates after remediation: ```text -Statements 100% | Branches 93.33% | Functions 100% | Lines 100% -9/9 tests passed; coverage command exit 0 +pnpm preflight exit 0 +pnpm typecheck 45/45 tasks, exit 0 +pnpm lint 25/25 tasks, exit 0 +pnpm build 25/25 tasks, exit 0 +pnpm format:check exit 0 +git diff --check exit 0 ``` -Baseline commands: +Pre-PR targeted shell runs on the unchanged shell surfaces also passed: ```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 +bash framework/tools/fleet/test-start-agent-session.sh exit 0 locally +bash framework/tools/quality/scripts/test-install-migration.sh 21 passed, 0 failed +bash framework/tools/_scripts/test-mosaic-init-rce.sh PASS ``` -The aggregate `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. +The aggregate local `test:framework-shell` run stopped at `invariant_r_unittest.py`: installed +operator-global Pi is `0.84.2`, while the invariant is measured for `0.84.1`. Later aggregate stages +remain unmeasured except the targeted suites above. Root `pnpm test` remains locally **UNTESTED** +because this checkout prohibits the PostgreSQL-dependent gateway isolation path. -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**. +## Review and security evidence + +- Initial Codex review found padded-name mutation-before-refusal; it was fixed with three + no-side-effect regressions. +- Codex review of the formal-review remediation: `approve`, confidence `0.88`, 6 files, no findings. +- Codex security review of the remediation: risk `none`, confidence `0.93`, 9 files, no findings. + Its sandbox could not execute Vitest because Vite attempted a write on a read-only mount; the + executor-owned results above are the test evidence. +- Daphne formal review ID 168 at exact head `43fa0477`: `REQUEST_CHANGES`, four blocking groups. All + four have red-first regressions and local green remediation. Re-review of the next pushed exact + head is necessarily pending until that head exists. + +## CI evidence and external blocker + +Pipeline 2445 ran against exact first head `43fa0477`: + +- install, sanitization, upgrade guard, typecheck, lint, and format passed; +- Mosaic Vitest passed `88/88`, `1,568/1,568`; and +- the test step emitted exactly one `FAIL:` line: + +```text +FAIL: host provides 'pi' in the system path; missing-binary cases are not measurable here +``` + +That line comes from the inherited `test-start-agent-session.sh` CI-fit guard, not #1264. Fred filed +the correction as PR #1270. Its pipeline 2448 is terminal green and proves the four formerly masked +suites execute, but #1270 is not merged, so `next` still carries the failing chain. A new #1268 +pipeline is pending the remediation push. Terminal-green #1268 CI is not claimed. + +PR #1268's envelope was read back as `user.login=mos-dt-0`; its commit is explicitly authored and +committed by `goals `. No goals Gitea login exists on this host, and no +other principal was borrowed. The cross-wrapper principal defect is tracked in #1272. ## Explicitly untested -- Canary VM remediation or restart: **UNTESTED and prohibited for this task**. +- Canary VM remediation/restart: **UNTESTED and prohibited**. - Real Pi authentication/provider prompt and task execution: **UNTESTED**. - PR #1213 composition layer: **UNTESTED and not required**. -- Deployment/published npm package behavior: **UNTESTED until CI/release; deployment is not this PR's scope**. +- Deployment/published npm behavior: **UNTESTED until merge/release**. +- Local PostgreSQL execution/migration: **UNTESTED and prohibited**. -## 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. +The local gate proves Mosaic crosses its identity boundary and reaches a fake lease-runtime boundary; +it does not claim provider readiness, deployment, or a currently running canary seat. diff --git a/docs/scratchpads/1264-unattended-first-start.md b/docs/scratchpads/1264-unattended-first-start.md index 91d2d0ab..bb077e17 100644 --- a/docs/scratchpads/1264-unattended-first-start.md +++ b/docs/scratchpads/1264-unattended-first-start.md @@ -3,112 +3,95 @@ ## Tracking - Issue: `mosaicstack/stack#1264` +- PR: `mosaicstack/stack#1268` - Branch: `fix/1264-fleet-unattended-first-start` - Base: `origin/next@476db12b92971634b67fd2057b7577ee5894e449` -- Worktree: `/var/home/jason.woltje/agent-work/1264-unattended-first-start` +- First pushed head: `43fa0477877e0d0f110da8d11c3033b40ddeb191` +- Current remediation worktree: `/var/home/jason.woltje/agent-work/1264-review-remediation` - Coordinator: Fred; reviewer must be neither Fred nor this implementation seat. - `docs/TASKS.md` is orchestrator-owned and is not modified by this worker. +The original `/var/home/jason.woltje/agent-work/1264-unattended-first-start` worktree was removed +without force after its pushed head and clean state were verified. The Fred-authorized plain-Git +worktree exception was reused for exact-head review remediation because `/src` remains unavailable. + ## Objective A roster-owned fleet seat launched from systemd on a clean host must cross Mosaic's first-run identity -gate without a human or TTY, while retaining an exact seat identity from the canonical roster and -preserving the interactive wizard for standalone launches. +gate without a human or TTY, while retaining exact name/class from the canonical roster and +preserving the standalone interactive wizard. ## 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`. +- Shipping canary provenance is jarvis-brain `origin/main` commit + `8bf94afeb8c7d5df96cdd4a4508e75a1d2999710`. The earlier local `6c0b6fc...` object is not used. +- The Run-7 pane was preserved when evidence was captured; formal review records a later authorized + rollback. This task never accessed or altered the canary. +- Tiny's concurrent runtime-preflight, `start-agent-session.sh`, and #1258 PATH seam remain untouched. +- Held PR #1213 is not a dependency. +- No runtime/provider credential values or provider calls, installed-host changes, PostgreSQL, unit, + timer, or profile mutation. Tests use temporary roots and fake executables only; Gitea/Woodpecker + metadata operations use standard wrappers without exposing credentials. -## Requirements and design assumptions +## Requirements and design -- 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. +- PRD IDs: `FCM-REQ-12`, `AC-FCM-10`; `FCM-REQ-11` is reserved by #1256. +- A present fleet name must be nonblank, whitespace-exact, and resolve through the canonical roster. +- Any ambient class must canonicalize to the roster class before mutation. +- Preflight all destination directory entries with no-follow existence semantics so dangling links + fail before counterpart publication. +- Seed only missing top-level files from bounded regular defaults with owner-private, atomic, + no-clobber hard links. +- Generic defaults are behavior, not identity or authority. +- Securely consume `USER.md` through a descriptor at composition time. +- Standalone missing identity retains the wizard. ## 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. +- [x] Issue, canary report, Tiny collision state, and PRD read/amended. +- [x] Initial production-kind RED captured with a real built CLI and no TTY. +- [x] Implementation, tests, user/admin/developer docs, QA, and indexes delivered. +- [x] Initial automated review finding (padded name before write) remediated. +- [x] Commit `43fa0477` pushed; PR #1268 opened against `next`; original worktree removed cleanly. +- [x] Daphne formal review ID 168 completed on exact first head: `REQUEST_CHANGES` with four groups. +- [x] All four groups reproduced red before remediation and now pass locally. +- [x] Remediation Codex review approved; remediation security review risk `none`. +- [ ] Commit/push remediation with explicit goals author/committer; verify remote object/content. +- [ ] Daphne exact-new-head re-review. +- [ ] Terminal #1268 CI. Pipeline 2445's only `FAIL:` was the inherited Pi-PATH CI-fit guard; PR + #1270's pipeline 2448 is green, but #1270 is not merged. +- [ ] Remove the clean remediation worktree after push. ## Test evidence -### RED — 2026-08-16 +### Initial RED -```bash -env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic exec vitest run \ - src/commands/launch-first-start.spec.ts -``` +The built `origin/next` CLI entered `mosaic wizard`, rendered `What would you like to do?`, exited 1, +and never created the fake runtime-boundary capture. -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. +### Formal-review RED -### GREEN — current delta +Against exact first-head production code, four new tests failed while 1,568 existing tests passed: +class mismatch mutated before refusal; each dangling destination left its counterpart; and a +replacement `USER.md` symlink was consumed by composition. -- 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. +### Final local GREEN -## Risks / blockers +- Exact-source focused gate: `6/6` files, `124/124` tests. +- Full exact-source Mosaic Vitest: `88/88` files, `1,573/1,573` tests. +- Helper + point-of-use coverage: `52/52`; 97.97% statements/lines, 88% branches, 100% functions. +- Root preflight passed; typecheck `45/45`, lint `25/25`, build `25/25`. +- Initial targeted shell gates passed: start-agent-session, install migration `21/21`, init-RCE. +- Local aggregate framework shell stops at operator-global Pi `0.84.2` versus measured `0.84.1`. +- Local root `pnpm test` remains unrun because the checkout prohibits its PostgreSQL-dependent path. -- The 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. +The full evidence and command boundaries are in +`docs/reports/qa/2026-08-16-1264-unattended-first-start.md`. + +## Review / delivery notes + +- Codex remediation review: approve, confidence `0.88`, no findings. +- Codex remediation security review: risk `none`, confidence `0.93`, no findings. +- PR envelope reads `mos-dt-0`; the commit reads goals/goals. No goals Gitea principal exists on this + host, so no other principal will be borrowed. Tracked in #1272. +- PR #1270 is pushed, not merged. Do not represent `next` or #1268 CI as green until measured. diff --git a/packages/mosaic/framework/defaults/README.md b/packages/mosaic/framework/defaults/README.md index 611f266b..10de5130 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. 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 +2. Resolves identity: standalone launches auto-run `mosaic init` when `SOUL.md` is missing; exact roster-owned fleet launches validate name/class, atomically seed only missing `SOUL.md`/`USER.md` from generic `defaults/`, securely consume `USER.md`, and never prompt 3. Injects `AGENTS.md` into the runtime 4. Forwards all arguments to the runtime CLI diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 98cf494f..24ff9ff9 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -24,7 +24,8 @@ "build": "tsc", "lint": "eslint src", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", + "test": "pnpm run test:vitest && pnpm run test:framework-shell", + "test:vitest": "pnpm run build && vitest run --passWithNoTests", "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh" }, "dependencies": { diff --git a/packages/mosaic/src/commands/compose-contract.spec.ts b/packages/mosaic/src/commands/compose-contract.spec.ts index dacd4874..c8514f39 100644 --- a/packages/mosaic/src/commands/compose-contract.spec.ts +++ b/packages/mosaic/src/commands/compose-contract.spec.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { FileConfigAdapter } from '../config/file-adapter.js'; +import { seedFleetIdentityDefaults } from './fleet-first-start-identity.js'; import { composeContract } from './launch.js'; /** @@ -319,6 +320,7 @@ describe('composeContract — overlay composer', () => { ].join('\n'), ); process.env['MOSAIC_AGENT_NAME'] = 'exact-self'; + expect(seedFleetIdentityDefaults(installedHome)).toEqual(['SOUL.md', 'USER.md']); const composed = composeContract('pi', installedHome); expect(composed).toContain(sourceTools); @@ -332,6 +334,23 @@ describe('composeContract — overlay composer', () => { } }); + it('refuses a USER.md replacement symlink at the point of composition', () => { + writeFileSync(join(fixture.home, 'defaults', 'SOUL.md'), '# Generic soul\n'); + writeFileSync(join(fixture.home, 'defaults', 'USER.md'), '# Generic user\n'); + expect(seedFleetIdentityDefaults(fixture.home)).toEqual(['SOUL.md']); + + const userPath = join(fixture.home, 'USER.md'); + const external = join(fixture.root, 'attacker-user.md'); + writeFileSync(external, 'UNSAFE-REPLACEMENT-USER-CONTENT\n'); + rmSync(userPath); + symlinkSync(external, userPath); + + expect(() => composeContract('pi', fixture.home)).toThrow( + `fleet identity installed is unavailable or unsafe: ${userPath}`, + ); + expect(readFileSync(external, 'utf8')).toBe('UNSAFE-REPLACEMENT-USER-CONTENT\n'); + }); + it.each(['claude', 'codex', 'opencode', 'pi'] as const)( 'never injects installed TOOLS.md through a target symlink for %s', (runtime) => { diff --git a/packages/mosaic/src/commands/fleet-first-start-identity.spec.ts b/packages/mosaic/src/commands/fleet-first-start-identity.spec.ts index 7447f638..5ca29113 100644 --- a/packages/mosaic/src/commands/fleet-first-start-identity.spec.ts +++ b/packages/mosaic/src/commands/fleet-first-start-identity.spec.ts @@ -115,6 +115,17 @@ describe('seedFleetIdentityDefaults', () => { expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false); }); + it('fails closed when the configured Mosaic home is not a directory', () => { + const root = mkdtempSync(join(tmpdir(), 'mosaic-identity-invalid-home-')); + roots.push(root); + const mosaicHome = join(root, 'mosaic-home'); + writeFixture(mosaicHome, 'not a directory\n'); + + expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow( + `fleet identity installed is unavailable or unsafe: ${join(mosaicHome, 'SOUL.md')}`, + ); + }); + it('refuses a symlinked default instead of following it', () => { const mosaicHome = createMosaicHome(); const source = join(mosaicHome, 'defaults', 'SOUL.md'); @@ -139,6 +150,24 @@ describe('seedFleetIdentityDefaults', () => { expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false); }); + it.each(['SOUL.md', 'USER.md'] as const)( + 'rejects a dangling %s destination before publishing its counterpart', + (entry) => { + const mosaicHome = createMosaicHome(); + const destination = join(mosaicHome, entry); + const counterpart = join(mosaicHome, entry === 'SOUL.md' ? 'USER.md' : 'SOUL.md'); + symlinkSync(join(mosaicHome, 'missing-identity-target'), destination); + + expect(existsSync(destination)).toBe(false); + expect(lstatSync(destination).isSymbolicLink()).toBe(true); + expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow( + `fleet identity installed is unavailable or unsafe: ${destination}`, + ); + expect(lstatSync(destination).isSymbolicLink()).toBe(true); + expect(existsSync(counterpart)).toBe(false); + }, + ); + it('rejects an oversized source before publishing a partial identity', () => { const mosaicHome = createMosaicHome(); const source = join(mosaicHome, 'defaults', 'USER.md'); diff --git a/packages/mosaic/src/commands/fleet-first-start-identity.ts b/packages/mosaic/src/commands/fleet-first-start-identity.ts index cc340b67..b9160db5 100644 --- a/packages/mosaic/src/commands/fleet-first-start-identity.ts +++ b/packages/mosaic/src/commands/fleet-first-start-identity.ts @@ -1,13 +1,13 @@ import { randomBytes } from 'node:crypto'; -import { existsSync, linkSync, rmSync, writeFileSync } from 'node:fs'; +import { linkSync, lstatSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { readRegularFileSecure } from '../fleet/secure-file.js'; const MAX_IDENTITY_CONTRACT_BYTES = 256 * 1024; export const FLEET_IDENTITY_DEFAULTS = ['SOUL.md', 'USER.md'] as const; -function isAlreadyExistsError(error: unknown): boolean { - return error instanceof Error && 'code' in error && error.code === 'EEXIST'; +function isFilesystemError(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code; } /** @internal Publish a complete temporary file without replacing any path. */ @@ -16,11 +16,16 @@ export function linkIdentityContractNoClobber(source: string, destination: strin linkSync(source, destination); return true; } catch (error: unknown) { - if (isAlreadyExistsError(error)) return false; + if (isFilesystemError(error, 'EEXIST')) return false; throw error; } } +function unsafeIdentityError(kind: 'default' | 'installed', path: string, error: unknown): Error { + const reason = error instanceof Error ? error.message : String(error); + return new Error(`fleet identity ${kind} is unavailable or unsafe: ${path} (${reason})`); +} + function readIdentityContract( mosaicHome: string, path: string, @@ -32,8 +37,39 @@ function readIdentityContract( 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})`); + throw unsafeIdentityError(kind, path, error); + } +} + +function installedEntryExists(path: string): boolean { + try { + lstatSync(path); + return true; + } catch (error: unknown) { + if (isFilesystemError(error, 'ENOENT')) return false; + throw unsafeIdentityError('installed', path, error); + } +} + +/** Secure point-of-use read for a top-level identity contract. */ +export function readOptionalInstalledIdentityContract( + mosaicHome: string, + entry: (typeof FLEET_IDENTITY_DEFAULTS)[number], + required: boolean = false, +): Buffer | undefined { + const configuredPath = join(mosaicHome, entry); + try { + // Preserve the launcher's established support for a symlinked Mosaic home, + // while pinning this read to the resolved directory. O_NOFOLLOW still + // rejects replacement of the identity file itself (or any child ancestor). + const canonicalHome = realpathSync(mosaicHome); + return readRegularFileSecure(join(canonicalHome, entry), { + root: canonicalHome, + maxBytes: MAX_IDENTITY_CONTRACT_BYTES, + }).content; + } catch (error: unknown) { + if (!required && isFilesystemError(error, 'ENOENT')) return undefined; + throw unsafeIdentityError('installed', configuredPath, error); } } @@ -50,7 +86,7 @@ export function seedFleetIdentityDefaults(mosaicHome: string): string[] { for (const entry of FLEET_IDENTITY_DEFAULTS) { const destination = join(mosaicHome, entry); - if (existsSync(destination)) { + if (installedEntryExists(destination)) { readIdentityContract(mosaicHome, destination, 'installed'); continue; } @@ -69,7 +105,11 @@ export function seedFleetIdentityDefaults(mosaicHome: string): string[] { try { writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 }); temporaryCreated = true; - if (linkIdentityContractNoClobber(temporary, destination)) seeded.push(entry); + if (linkIdentityContractNoClobber(temporary, destination)) { + seeded.push(entry); + } else { + readIdentityContract(mosaicHome, destination, 'installed'); + } } finally { if (temporaryCreated) rmSync(temporary, { force: true }); } diff --git a/packages/mosaic/src/commands/launch-first-start.spec.ts b/packages/mosaic/src/commands/launch-first-start.spec.ts index 48597de1..6a141b7b 100644 --- a/packages/mosaic/src/commands/launch-first-start.spec.ts +++ b/packages/mosaic/src/commands/launch-first-start.spec.ts @@ -108,6 +108,7 @@ function launchEnvironment( capturePath: string, fleet: boolean = true, agentName: string = 'unattended-seat', + agentClass: string = 'worker', ): NodeJS.ProcessEnv { return { HOME: fixture.home, @@ -115,7 +116,7 @@ function launchEnvironment( ...(fleet ? { MOSAIC_AGENT_NAME: agentName, - MOSAIC_AGENT_CLASS: 'worker', + MOSAIC_AGENT_CLASS: agentClass, } : {}), MOSAIC_TEST_RUNTIME_CAPTURE: capturePath, @@ -129,6 +130,7 @@ function launchSync( readonly capturePath?: string; readonly fleet?: boolean; readonly agentName?: string; + readonly agentClass?: string; } = {}, ): SpawnSyncReturns { const capturePath = options.capturePath ?? fixture.capturePath; @@ -142,6 +144,7 @@ function launchSync( capturePath, options.fleet ?? true, options.agentName ?? 'unattended-seat', + options.agentClass ?? 'worker', ), }); } @@ -300,6 +303,20 @@ describe('fleet unattended first start (#1264)', () => { expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false); }); + it('refuses a mismatched ambient fleet class before seeding or prompting', () => { + const fixture = createGreenfieldFixture(); + + const result = launchSync(fixture, { agentClass: 'reviewer' }); + const output = outputOf(result); + + expect(result.status, output).toBe(1); + expect(output).toContain('Refusing split identity authority'); + expect(output).not.toContain('Running setup wizard'); + expect(existsSync(fixture.capturePath)).toBe(false); + expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false); + expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false); + }); + it.each([' unattended-seat', 'unattended-seat ', ''])( 'refuses non-exact ambient fleet name %j before seeding', (agentName: string) => { diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index d0ca4208..6c1275a2 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -30,7 +30,10 @@ 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 { + readOptionalInstalledIdentityContract, + 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'); @@ -231,6 +234,18 @@ function checkRuntime(cmd: string): void { } } +function assertAmbientFleetClassMatches(canonicalName: string, canonicalClass: string): void { + const configuredClass = process.env['MOSAIC_AGENT_CLASS']; + if (!configuredClass?.trim()) return; + + const ambientClass = canonicalizeRoleClass(configuredClass).canonicalClass; + if (ambientClass !== canonicalClass) { + throw new Error( + `Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalName}" resolves to "${canonicalClass}". Refusing split identity authority.`, + ); + } +} + function checkSoul(): void { const soulPath = join(MOSAIC_HOME, 'SOUL.md'); const fleetAgentName = process.env['MOSAIC_AGENT_NAME']; @@ -247,6 +262,10 @@ function checkSoul(): void { `canonical fleet identity is unavailable: ${fleetIdentity.error ?? 'exact roster member was not resolved'}`, ); } + assertAmbientFleetClassMatches( + fleetIdentity.identity.member.name, + fleetIdentity.identity.member.className, + ); const seeded = seedFleetIdentityDefaults(MOSAIC_HOME); if (seeded.length > 0) { console.log( @@ -258,7 +277,7 @@ function checkSoul(): void { 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.`, + '[mosaic] Repair the named fleet roster, launch identity, installed contract, or shipped default, then retry.', ); process.exit(1); } @@ -565,7 +584,12 @@ For required push/merge/issue-close/release actions, execute without routine con // USER.md (+ USER.local.md operator overlay, appended directly under the // profile its base owns). - const user = readOptional(join(mosaicHome, 'USER.md')); + const user = + readOptionalInstalledIdentityContract( + mosaicHome, + 'USER.md', + process.env['MOSAIC_AGENT_NAME'] !== undefined, + )?.toString('utf8') ?? ''; if (user) parts.push('\n\n# User Profile\n\n' + user); const userLocal = readOptional(join(mosaicHome, 'USER.local.md')); if (userLocal.trim()) { @@ -577,13 +601,8 @@ For required push/merge/issue-close/release actions, execute without routine con throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`); } const canonicalMember = fleetIdentity.identity?.member; - if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) { - const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass; - if (ambientClass !== canonicalMember.className) { - throw new Error( - `Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`, - ); - } + if (canonicalMember) { + assertAmbientFleetClassMatches(canonicalMember.name, canonicalMember.className); } // TOOLS.md -- 2.54.0 From 3af594590a40898c1804a1741a1ae928c8c19fae Mon Sep 17 00:00:00 2001 From: goals Date: Sun, 16 Aug 2026 19:01:26 -0500 Subject: [PATCH 3/3] fix(#1264): preserve portable standalone launch --- .../fleet-unattended-first-start.md | 7 +-- .../fleet-first-start-identity.md | 23 ++++---- .../workflows/fleet-unattended-first-start.md | 8 +-- docs/reports/code-review/1264-code-review.md | 19 +++++++ .../1264-documentation-checklist.md | 8 +-- .../2026-08-16-1264-unattended-first-start.md | 53 +++++++++++++------ .../1264-unattended-first-start.md | 39 ++++++++------ .../src/commands/compose-contract.spec.ts | 28 +++++++++- .../commands/fleet-first-start-identity.ts | 8 ++- .../src/commands/launch-first-start.spec.ts | 17 ++++++ packages/mosaic/src/commands/launch.ts | 19 +++---- 11 files changed, 161 insertions(+), 68 deletions(-) diff --git a/docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md b/docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md index 1754fc59..d802c82a 100644 --- a/docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md +++ b/docs/ADMIN-GUIDE/operations/fleet-unattended-first-start.md @@ -27,7 +27,8 @@ The fleet path never falls back to an interactive wizard. It exits nonzero befor when: - `MOSAIC_AGENT_NAME` is not an exact roster member; -- an ambient `MOSAIC_AGENT_CLASS` disagrees with that member's canonical class; +- a defined ambient `MOSAIC_AGENT_CLASS` is blank/whitespace or disagrees with that member's + canonical class; - a missing destination has no safe regular default source; - a source or existing destination is a symlink (including dangling), directory, unavailable, or over the bounded size; - the fleet communications helper/roster cannot be validated; or @@ -56,8 +57,8 @@ The build leg is load-bearing: `dist/` is ignored, so a direct Vitest invocation absent or stale CLI output. The gate runs the exact-source built CLI in subprocesses with piped stdin, temporary homes, a canonical fixture roster, fake runtime/broker executables, and no provider call. It covers no-TTY launch, exact identity, -private modes, no-clobber, missing/symlink defaults, unknown members, class mismatch, -standalone wizard preservation, and concurrent first start. +private modes, no-clobber, missing/symlink defaults, unknown members, blank/mismatched class, +portable standalone composition/wizard preservation, and concurrent first start. Do not use this fixture as proof that a real provider credential is present or that a package has been deployed. Those require separate environment-specific evidence. diff --git a/docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md b/docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md index 2d940134..44cd9199 100644 --- a/docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md +++ b/docs/DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md @@ -16,8 +16,8 @@ The fix remains at `checkSoul()` and does not add flags to `yolo`, fleet command 1. A present, nonblank, whitespace-exact `MOSAIC_AGENT_NAME` selects the fleet path. 2. `resolveFleetIdentity()` must resolve that exact member through the existing roster/helper - boundary, and any ambient `MOSAIC_AGENT_CLASS` must canonicalize to the roster class, before any - identity seed. + boundary, and any defined `MOSAIC_AGENT_CLASS` (including blank/whitespace) must canonicalize to + the roster class, before any identity seed. Only undefined means absent. 3. `lstatSync()` preflights every destination directory entry without following links, so a dangling link is rejected before its counterpart can be published. 4. Safe bounded snapshots are read from only the missing contracts under `defaults/`. @@ -26,10 +26,12 @@ The fix remains at `checkSoul()` and does not add flags to `yolo`, fleet command concurrent seat or operator won; the existing path is preserved and revalidated. 7. Temporary files are removed, and both installed contracts are re-opened through the no-symlink secure-file reader before launch continues. -8. `composeContract()` independently re-resolves the roster, securely reads `USER.md` through a - descriptor at the point of use, and injects exact member identity and communications data. +8. `composeContract()` independently re-resolves the roster, securely reads fleet `USER.md` through + a Linux descriptor at the point of use, and injects exact member identity and communications data. -A standalone launch with no `MOSAIC_AGENT_NAME` retains the interactive wizard. +A standalone launch with no `MOSAIC_AGENT_NAME` retains the portable tolerant USER read and the +interactive wizard. Fleet-only no-follow enforcement must not make supported standalone macOS +composition depend on Linux `/proc` descriptor traversal. ## Identity and authority @@ -42,9 +44,9 @@ not the source of a fleet seat's identity. The canonical roster controls: - tmux socket and helper target; and - communications generation. -An unknown/padded ambient name or mismatched ambient class fails before any file is seeded. This -avoids replacing the interactive wall with a fleet of indistinguishable or ambiently invented -identities. +An unknown/padded ambient name, mismatched class, or explicitly blank/whitespace class fails before +any file is seeded. This avoids replacing the interactive wall with a fleet of indistinguishable or +ambiently invented identities. ## Concurrency and filesystem properties @@ -64,8 +66,9 @@ no-TTY subprocess, not a direct wizard test. The package `test:vitest` gate buil Vitest, while the clean-checkout command builds its workspace dependencies first, so ignored `dist/cli.js` cannot be absent or stale. A fake lease launcher records whether execution reached the runtime boundary and captures the composed prompt. -Positive and negative cases prove the check can both proceed and refuse. Composition coverage also -replaces a previously validated `USER.md` with an external symlink and proves point-of-use refusal. +Positive and negative cases prove the check can both proceed and refuse. Fleet composition coverage +replaces a previously validated `USER.md` with an external symlink and proves point-of-use refusal; +a standalone unreadable-optional-USER case proves the portable tolerant branch remains separate. Real Pi authentication and provider task execution remain environment tests, not claims of this fixture. diff --git a/docs/USER-GUIDE/workflows/fleet-unattended-first-start.md b/docs/USER-GUIDE/workflows/fleet-unattended-first-start.md index 8425971e..c178a796 100644 --- a/docs/USER-GUIDE/workflows/fleet-unattended-first-start.md +++ b/docs/USER-GUIDE/workflows/fleet-unattended-first-start.md @@ -25,8 +25,8 @@ base. The canonical roster row supplies each seat's exact name, class, peers, so ## Operator behavior -A normal standalone launch without a fleet identity still uses the interactive wizard when -`SOUL.md` is absent: +A normal standalone launch without a fleet identity retains its portable configuration path and still +uses the interactive wizard when `SOUL.md` is absent: ```bash mosaic pi @@ -38,8 +38,8 @@ A roster-owned seat may be started without attaching to its pane: mosaic fleet start ``` -Mosaic refuses before runtime execution if the requested member is absent, its ambient class -conflicts with the roster, a required default is missing or unsafe, or an existing identity contract +Mosaic refuses before runtime execution if the requested member is absent, its explicitly supplied +ambient class is blank or conflicts with the roster, a required default is missing or unsafe, or an existing identity contract is not a safe regular file. Repair the named component and retry the same exact roster member; do not copy another seat's personalized identity. diff --git a/docs/reports/code-review/1264-code-review.md b/docs/reports/code-review/1264-code-review.md index 76f74be1..fe1756b8 100644 --- a/docs/reports/code-review/1264-code-review.md +++ b/docs/reports/code-review/1264-code-review.md @@ -71,6 +71,25 @@ Result: risk `none`, confidence `0.93`, 9 files, no critical/high/medium/low fin could not run Vitest because Vite attempted to create a temporary config artifact on its read-only mount (`EROFS`); executor-owned focused and full results are recorded in the QA report. +## Second exact-head review + +Daphne reviewed exact head `9dc90be7e13b1cd609f6df97d43d890ef5392ca0` and filed Gitea review +ID 169 as `REQUEST_CHANGES`. Review 169 confirmed all review-168 closures, then found: + +1. the new point-of-use reader was Linux-only but had been applied to every standalone USER read, + breaking supported non-fleet macOS composition; and +2. explicit blank/whitespace `MOSAIC_AGENT_CLASS` was treated as absent and could seed before + runtime, while only undefined should mean absent. + +Red-first remediation preserves legacy `readOptional()` for standalone composition, keeps descriptor +no-follow consumption fleet-only, moves the replacement-symlink case under a valid fleet identity, +and rejects defined blank/whitespace classes before seeding. Three blank-class CLI cases and one +tolerant standalone composition case failed before the source change and pass after it. + +Review-169 remediation code review approved at confidence `0.90` (4 files, no findings). Security +review reported risk `none` at confidence `0.90` (4 files, no findings). The review sandbox retained +its known Vite `EROFS` limitation; executor-owned tests are in the QA report. + ## Remaining review gate Daphne must re-review the next exact pushed head. This report cannot record that future verdict diff --git a/docs/reports/documentation/1264-documentation-checklist.md b/docs/reports/documentation/1264-documentation-checklist.md index 4cc3c0ba..c65d5cc0 100644 --- a/docs/reports/documentation/1264-documentation-checklist.md +++ b/docs/reports/documentation/1264-documentation-checklist.md @@ -25,6 +25,8 @@ - [x] Initial padded-name finding remediated and automated re-review approved. - [x] Daphne formal review ID 168 completed on exact first head `43fa0477` and requested changes. -- [x] Four formal-review groups reproduced red and remediated; automated remediation code/security - reviews are clean. -- [ ] Daphne exact-remediation-head re-review completed after push (Fred/goals excluded). +- [x] Four review-168 groups reproduced red and remediated; automated reviews are clean. +- [x] Daphne review ID 169 completed on exact head `9dc90be7` and confirmed review-168 closures. +- [x] Review-169 standalone-portability and blank-class blockers reproduced red and remediated; + automated reviews are clean. +- [ ] Daphne exact-second-remediation-head re-review completed after push (Fred/goals excluded). 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 index 15486656..d208d14e 100644 --- a/docs/reports/qa/2026-08-16-1264-unattended-first-start.md +++ b/docs/reports/qa/2026-08-16-1264-unattended-first-start.md @@ -1,7 +1,7 @@ # #1264 Unattended Fleet First-Start Verification -> Status: **IN PROGRESS — review remediation complete locally; push/re-review pending** | Executor: -> goals | Date: 2026-08-16 | Target: isolated local fixtures only +> Status: **IN PROGRESS — review-169 remediation complete locally; push/re-review pending** | +> Executor: goals | Date: 2026-08-16 | Target: isolated local fixtures only ## Objective @@ -44,7 +44,7 @@ executables. | ---------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------- | | No-TTY fleet first start avoids wizard and reaches runtime | Exact-source built CLI with piped stdin | CLI GREEN | | Missing top-level identity files are initialized from shipped defaults | Exact-byte and `0600` assertions | CLI + filesystem GREEN | -| Exact seat identity remains roster-owned | Captured argv; name/class mismatch no-side-effect refusals | CLI GREEN | +| Exact seat identity remains roster-owned | Captured argv; mismatched/blank class no-side-effect refusals | CLI GREEN | | Existing operator identity is never overwritten | Custom bytes/mode with defaults removed | CLI + filesystem GREEN | | Concurrent/repeated first start is safe | Four parallel CLIs plus repeated launch | CLI GREEN | | Missing/unsafe defaults and destinations fail before partial mutation | Missing, target/dangling symlink, oversized, invalid-root cases | Filesystem/CLI GREEN | @@ -85,7 +85,18 @@ passed: 3. dangling `USER.md` allowed `SOUL.md` to be published before refusal; and 4. replacing a securely validated `USER.md` with an external symlink was followed by composition. -This establishes that all four reviewer findings were observable on the pushed implementation. +This establishes that all four review-168 findings were observable on the pushed implementation. + +Daphne's review ID 169 then found two more exact-head failures at `9dc90be7`. Before production +changes, four new assertions failed: + +1. standalone composition routed an unreadable optional `USER.md` through the Linux-only descriptor + reader instead of the legacy portable tolerant path; and +2. explicit `MOSAIC_AGENT_CLASS` values `""`, `" "`, and tab were treated as absent, seeded both + identity files, and reached runtime. + +The replacement-symlink case was also moved under a valid roster identity so it tests the fleet-only +security boundary rather than standalone behavior. ## Final GREEN @@ -105,16 +116,17 @@ env -u DATABASE_URL sh -c ' ' ``` -Exit `0`: `6/6` files, `124/124` tests. +Exit `0`: `6/6` files, `128/128` tests. -- 12 real-CLI/no-TTY tests cover exact roster name/class, byte-equal `0600` seeds, no-clobber, - partial seed, missing/symlink defaults, unknown/padded/blank name, mismatched class, standalone - wizard preservation, and four concurrent starts. +- 15 real-CLI/no-TTY tests cover exact roster name/class, byte-equal `0600` seeds, no-clobber, + partial seed, missing/symlink defaults, unknown/padded/blank name, mismatched or explicitly blank + class, standalone wizard preservation, and four concurrent starts. - 12 direct filesystem tests cover complete publication, existing operators, idempotence, source prevalidation, target and dangling destination links, invalid roots, oversized input, and unexpected link errors. -- Composition coverage deterministically replaces validated `USER.md` with an external symlink and - requires refusal at point of use. +- Composition coverage deterministically replaces a valid fleet seat's validated `USER.md` with an + external symlink and requires refusal at point of use. A separate standalone case proves tolerant + optional composition remains outside the Linux-only fleet reader. Full package gate (which rebuilds Mosaic itself after the clean-checkout dependency build): @@ -122,13 +134,13 @@ Full package gate (which rebuilds Mosaic itself after the clean-checkout depende env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic run test:vitest ``` -Exit `0`: `88/88` files, `1,573/1,573` tests. +Exit `0`: `88/88` files, `1,577/1,577` tests. Focused helper + point-of-use coverage: ```text -2 files, 52/52 tests -Statements 97.97% | Branches 88% | Functions 100% | Lines 97.97% +2 files, 53/53 tests +Statements 97.84% | Branches 91.66% | Functions 100% | Lines 97.84% Exit 0 ``` @@ -164,9 +176,14 @@ because this checkout prohibits the PostgreSQL-dependent gateway isolation path. - Codex security review of the remediation: risk `none`, confidence `0.93`, 9 files, no findings. Its sandbox could not execute Vitest because Vite attempted a write on a read-only mount; the executor-owned results above are the test evidence. -- Daphne formal review ID 168 at exact head `43fa0477`: `REQUEST_CHANGES`, four blocking groups. All - four have red-first regressions and local green remediation. Re-review of the next pushed exact - head is necessarily pending until that head exists. +- Daphne formal review ID 168 at exact head `43fa0477`: `REQUEST_CHANGES`, four blocking groups; all + closed by review 169. +- Daphne formal review ID 169 at exact head `9dc90be7`: `REQUEST_CHANGES`, two blocking groups + (standalone portability and explicit blank class). Both now have red-first regressions and local + green remediation. +- Codex review of review-169 remediation: `approve`, confidence `0.90`, 4 files, no findings. +- Codex security review of review-169 remediation: risk `none`, confidence `0.90`, 4 files, no + findings. Exact-new-head Daphne re-review is pending until that head is pushed. ## CI evidence and external blocker @@ -183,7 +200,9 @@ FAIL: host provides 'pi' in the system path; missing-binary cases are not measur That line comes from the inherited `test-start-agent-session.sh` CI-fit guard, not #1264. Fred filed the correction as PR #1270. Its pipeline 2448 is terminal green and proves the four formerly masked suites execute, but #1270 is not merged, so `next` still carries the failing chain. A new #1268 -pipeline is pending the remediation push. Terminal-green #1268 CI is not claimed. +pipeline 2449 at `9dc90be7` reproduced the same single inherited `FAIL:` after Mosaic passed +`1,573/1,573`. A new pipeline is pending the review-169 remediation push. Terminal-green #1268 CI is +not claimed. PR #1268's envelope was read back as `user.login=mos-dt-0`; its commit is explicitly authored and committed by `goals `. No goals Gitea login exists on this host, and no diff --git a/docs/scratchpads/1264-unattended-first-start.md b/docs/scratchpads/1264-unattended-first-start.md index bb077e17..31d2223d 100644 --- a/docs/scratchpads/1264-unattended-first-start.md +++ b/docs/scratchpads/1264-unattended-first-start.md @@ -7,13 +7,14 @@ - Branch: `fix/1264-fleet-unattended-first-start` - Base: `origin/next@476db12b92971634b67fd2057b7577ee5894e449` - First pushed head: `43fa0477877e0d0f110da8d11c3033b40ddeb191` -- Current remediation worktree: `/var/home/jason.woltje/agent-work/1264-review-remediation` +- Current remediation worktree: `/var/home/jason.woltje/agent-work/1264-review2-remediation` - Coordinator: Fred; reviewer must be neither Fred nor this implementation seat. - `docs/TASKS.md` is orchestrator-owned and is not modified by this worker. -The original `/var/home/jason.woltje/agent-work/1264-unattended-first-start` worktree was removed -without force after its pushed head and clean state were verified. The Fred-authorized plain-Git -worktree exception was reused for exact-head review remediation because `/src` remains unavailable. +The original `/var/home/jason.woltje/agent-work/1264-unattended-first-start` and first remediation +worktrees were removed without force after each pushed head and clean state were verified. The +Fred-authorized plain-Git worktree exception was reused for exact-head review remediation because +`/src` remains unavailable. ## Objective @@ -37,14 +38,16 @@ preserving the standalone interactive wizard. - PRD IDs: `FCM-REQ-12`, `AC-FCM-10`; `FCM-REQ-11` is reserved by #1256. - A present fleet name must be nonblank, whitespace-exact, and resolve through the canonical roster. -- Any ambient class must canonicalize to the roster class before mutation. +- Any defined ambient class, including blank/whitespace, must canonicalize to the roster class before + mutation; only undefined means absent. - Preflight all destination directory entries with no-follow existence semantics so dangling links fail before counterpart publication. - Seed only missing top-level files from bounded regular defaults with owner-private, atomic, no-clobber hard links. - Generic defaults are behavior, not identity or authority. -- Securely consume `USER.md` through a descriptor at composition time. -- Standalone missing identity retains the wizard. +- Securely consume fleet `USER.md` through a Linux descriptor at composition time. +- Standalone composition retains the portable tolerant USER read and missing identity retains the + wizard. ## Progress @@ -54,9 +57,11 @@ preserving the standalone interactive wizard. - [x] Initial automated review finding (padded name before write) remediated. - [x] Commit `43fa0477` pushed; PR #1268 opened against `next`; original worktree removed cleanly. - [x] Daphne formal review ID 168 completed on exact first head: `REQUEST_CHANGES` with four groups. -- [x] All four groups reproduced red before remediation and now pass locally. -- [x] Remediation Codex review approved; remediation security review risk `none`. -- [ ] Commit/push remediation with explicit goals author/committer; verify remote object/content. +- [x] All four review-168 groups reproduced red before remediation and passed at `9dc90be7`. +- [x] Daphne review ID 169 completed on `9dc90be7`: review-168 closures confirmed; two new blockers. +- [x] Review-169 portability and blank-class blockers reproduced red and now pass locally. +- [x] Review-169 Codex review approved; security review risk `none`. +- [ ] Commit/push second remediation with explicit goals author/committer; verify remote object/content. - [ ] Daphne exact-new-head re-review. - [ ] Terminal #1268 CI. Pipeline 2445's only `FAIL:` was the inherited Pi-PATH CI-fit guard; PR #1270's pipeline 2448 is green, but #1270 is not merged. @@ -73,13 +78,15 @@ and never created the fake runtime-boundary capture. Against exact first-head production code, four new tests failed while 1,568 existing tests passed: class mismatch mutated before refusal; each dangling destination left its counterpart; and a -replacement `USER.md` symlink was consumed by composition. +replacement `USER.md` symlink was consumed by composition. Review-169 RED then proved standalone +composition hit the Linux-only reader and three explicit blank/whitespace class cases seeded and +launched. ### Final local GREEN -- Exact-source focused gate: `6/6` files, `124/124` tests. -- Full exact-source Mosaic Vitest: `88/88` files, `1,573/1,573` tests. -- Helper + point-of-use coverage: `52/52`; 97.97% statements/lines, 88% branches, 100% functions. +- Exact-source focused gate: `6/6` files, `128/128` tests. +- Full exact-source Mosaic Vitest: `88/88` files, `1,577/1,577` tests. +- Helper + point-of-use coverage: `53/53`; 97.84% statements/lines, 91.66% branches, 100% functions. - Root preflight passed; typecheck `45/45`, lint `25/25`, build `25/25`. - Initial targeted shell gates passed: start-agent-session, install migration `21/21`, init-RCE. - Local aggregate framework shell stops at operator-global Pi `0.84.2` versus measured `0.84.1`. @@ -90,8 +97,8 @@ The full evidence and command boundaries are in ## Review / delivery notes -- Codex remediation review: approve, confidence `0.88`, no findings. -- Codex remediation security review: risk `none`, confidence `0.93`, no findings. +- Review-168 remediation Codex review: approve `0.88`; security risk `none` `0.93`. +- Review-169 remediation Codex review: approve `0.90`; security risk `none` `0.90`. - PR envelope reads `mos-dt-0`; the commit reads goals/goals. No goals Gitea principal exists on this host, so no other principal will be borrowed. Tracked in #1272. - PR #1270 is pushed, not merged. Do not represent `next` or #1268 CI as green until measured. diff --git a/packages/mosaic/src/commands/compose-contract.spec.ts b/packages/mosaic/src/commands/compose-contract.spec.ts index c8514f39..07d93c6b 100644 --- a/packages/mosaic/src/commands/compose-contract.spec.ts +++ b/packages/mosaic/src/commands/compose-contract.spec.ts @@ -334,7 +334,22 @@ describe('composeContract — overlay composer', () => { } }); - it('refuses a USER.md replacement symlink at the point of composition', () => { + it('refuses a fleet USER.md replacement symlink at the point of composition', () => { + mkdirSync(join(fixture.home, 'fleet'), { recursive: true }); + writeFileSync( + join(fixture.home, 'fleet', 'roster.yaml'), + [ + 'version: 1', + 'transport: tmux', + 'agents:', + ' - name: exact-user-seat', + ' runtime: pi', + ' class: worker', + '', + ].join('\n'), + ); + process.env['MOSAIC_AGENT_NAME'] = 'exact-user-seat'; + process.env['MOSAIC_AGENT_CLASS'] = 'worker'; writeFileSync(join(fixture.home, 'defaults', 'SOUL.md'), '# Generic soul\n'); writeFileSync(join(fixture.home, 'defaults', 'USER.md'), '# Generic user\n'); expect(seedFleetIdentityDefaults(fixture.home)).toEqual(['SOUL.md']); @@ -351,6 +366,17 @@ describe('composeContract — overlay composer', () => { expect(readFileSync(external, 'utf8')).toBe('UNSAFE-REPLACEMENT-USER-CONTENT\n'); }); + it('preserves tolerant standalone composition when optional USER.md is unreadable', () => { + const userPath = join(fixture.home, 'USER.md'); + rmSync(userPath); + mkdirSync(userPath); + + const out = composeContract('pi', fixture.home); + + expect(out).toContain(AGENTS); + expect(out).not.toContain('# User Profile'); + }); + it.each(['claude', 'codex', 'opencode', 'pi'] as const)( 'never injects installed TOOLS.md through a target symlink for %s', (runtime) => { diff --git a/packages/mosaic/src/commands/fleet-first-start-identity.ts b/packages/mosaic/src/commands/fleet-first-start-identity.ts index b9160db5..a9040936 100644 --- a/packages/mosaic/src/commands/fleet-first-start-identity.ts +++ b/packages/mosaic/src/commands/fleet-first-start-identity.ts @@ -51,12 +51,11 @@ function installedEntryExists(path: string): boolean { } } -/** Secure point-of-use read for a top-level identity contract. */ -export function readOptionalInstalledIdentityContract( +/** Secure fleet point-of-use read for a top-level identity contract. */ +export function readInstalledIdentityContractAtPointOfUse( mosaicHome: string, entry: (typeof FLEET_IDENTITY_DEFAULTS)[number], - required: boolean = false, -): Buffer | undefined { +): Buffer { const configuredPath = join(mosaicHome, entry); try { // Preserve the launcher's established support for a symlinked Mosaic home, @@ -68,7 +67,6 @@ export function readOptionalInstalledIdentityContract( maxBytes: MAX_IDENTITY_CONTRACT_BYTES, }).content; } catch (error: unknown) { - if (!required && isFilesystemError(error, 'ENOENT')) return undefined; throw unsafeIdentityError('installed', configuredPath, error); } } diff --git a/packages/mosaic/src/commands/launch-first-start.spec.ts b/packages/mosaic/src/commands/launch-first-start.spec.ts index 6a141b7b..755479b6 100644 --- a/packages/mosaic/src/commands/launch-first-start.spec.ts +++ b/packages/mosaic/src/commands/launch-first-start.spec.ts @@ -317,6 +317,23 @@ describe('fleet unattended first start (#1264)', () => { expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false); }); + it.each(['', ' ', '\t'])( + 'refuses explicit blank ambient fleet class %j before seeding', + (agentClass: string) => { + const fixture = createGreenfieldFixture(); + + const result = launchSync(fixture, { agentClass }); + const output = outputOf(result); + + expect(result.status, output).toBe(1); + expect(output).toContain('Refusing split identity authority'); + expect(output).not.toContain('Running setup wizard'); + expect(existsSync(fixture.capturePath)).toBe(false); + expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false); + expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false); + }, + ); + it.each([' unattended-seat', 'unattended-seat ', ''])( 'refuses non-exact ambient fleet name %j before seeding', (agentName: string) => { diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 6c1275a2..d9f24224 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -31,7 +31,7 @@ import { readPersonaContractBlock } from '../fleet/persona-contract.js'; import { canonicalizeRoleClass } from './fleet-personas.js'; import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js'; import { - readOptionalInstalledIdentityContract, + readInstalledIdentityContractAtPointOfUse, seedFleetIdentityDefaults, } from './fleet-first-start-identity.js'; import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js'; @@ -236,7 +236,7 @@ function checkRuntime(cmd: string): void { function assertAmbientFleetClassMatches(canonicalName: string, canonicalClass: string): void { const configuredClass = process.env['MOSAIC_AGENT_CLASS']; - if (!configuredClass?.trim()) return; + if (configuredClass === undefined) return; const ambientClass = canonicalizeRoleClass(configuredClass).canonicalClass; if (ambientClass !== canonicalClass) { @@ -583,20 +583,21 @@ For required push/merge/issue-close/release actions, execute without routine con parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8')); // USER.md (+ USER.local.md operator overlay, appended directly under the - // profile its base owns). + // profile its base owns). Fleet first start is Linux/systemd-owned and uses + // the no-follow reader at point of use. Standalone launches retain the + // portable tolerant path used on macOS and other supported hosts. + const fleetAgentName = process.env['MOSAIC_AGENT_NAME']; const user = - readOptionalInstalledIdentityContract( - mosaicHome, - 'USER.md', - process.env['MOSAIC_AGENT_NAME'] !== undefined, - )?.toString('utf8') ?? ''; + fleetAgentName === undefined + ? readOptional(join(mosaicHome, 'USER.md')) + : readInstalledIdentityContractAtPointOfUse(mosaicHome, 'USER.md').toString('utf8'); if (user) parts.push('\n\n# User Profile\n\n' + user); const userLocal = readOptional(join(mosaicHome, 'USER.local.md')); if (userLocal.trim()) { parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal); } - const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']); + const fleetIdentity = resolveFleetIdentity(mosaicHome, fleetAgentName); if (!fleetIdentity.ok) { throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`); } -- 2.54.0