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