Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3934e03fa6 | ||
|
|
fd26532757 | ||
|
|
c395ecae84 | ||
|
|
c5a5f9d362 |
@@ -0,0 +1,49 @@
|
||||
# C1 detector gate. The fixture itself is intentionally RED; CI is green only
|
||||
# when its exact phase verdicts/reasons match the versioned expected-RED manifest.
|
||||
when:
|
||||
- event: [pull_request, manual]
|
||||
- event: push
|
||||
branch: [next, main]
|
||||
|
||||
steps:
|
||||
greenfield-git-present:
|
||||
image: node:22-bookworm-slim
|
||||
commands:
|
||||
- |
|
||||
set +e
|
||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||
bash tools/e2e-install-test.sh --lane next --source checkout --git present \
|
||||
> /tmp/greenfield-git-present.log 2>&1
|
||||
fixture_status=$?
|
||||
set -e
|
||||
cat /tmp/greenfield-git-present.log
|
||||
bash tools/verify-greenfield-expected-red.sh \
|
||||
next-git-present /tmp/greenfield-git-present.log "$fixture_status"
|
||||
|
||||
greenfield-main-git-present:
|
||||
image: node:22-bookworm-slim
|
||||
commands:
|
||||
- |
|
||||
set +e
|
||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||
bash tools/e2e-install-test.sh --lane main --source checkout --git present \
|
||||
> /tmp/greenfield-main-git-present.log 2>&1
|
||||
fixture_status=$?
|
||||
set -e
|
||||
cat /tmp/greenfield-main-git-present.log
|
||||
bash tools/verify-greenfield-expected-red.sh \
|
||||
main-git-present /tmp/greenfield-main-git-present.log "$fixture_status"
|
||||
|
||||
greenfield-git-absent:
|
||||
image: node:22-bookworm-slim
|
||||
commands:
|
||||
- |
|
||||
set +e
|
||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||
bash tools/e2e-install-test.sh --lane next --source checkout --git absent \
|
||||
> /tmp/greenfield-git-absent.log 2>&1
|
||||
fixture_status=$?
|
||||
set -e
|
||||
cat /tmp/greenfield-git-absent.log
|
||||
bash tools/verify-greenfield-expected-red.sh \
|
||||
next-git-absent /tmp/greenfield-git-absent.log "$fixture_status"
|
||||
@@ -7,7 +7,7 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash'
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
@@ -30,6 +30,16 @@ This installs both components:
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||
|
||||
### Install lanes
|
||||
|
||||
| Lane | Command | Use when | Source |
|
||||
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Exact `@next` CLI/gateway versions + pinned `next` framework commit; pinned-source fallback |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||
|
||||
`--next` selects the prerelease integration lane. It installs the exact CLI/gateway versions resolved from the aligned `@next` tags, and pins the framework archive to the resolved `next` commit. If the registry path fails, it builds from that pinned source. An explicit `--ref` or `MOSAIC_REF` wins and selects source mode.
|
||||
|
||||
After install, the wizard runs automatically or you can invoke it manually:
|
||||
|
||||
```bash
|
||||
@@ -38,10 +48,14 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js ≥ 20
|
||||
- npm (for global @mosaicstack/mosaic install)
|
||||
- Linux x86_64 with glibc (Debian is the greenfield CI platform; musl/Alpine, macOS, and ARM64 currently fail as unsupported)
|
||||
- Node.js ≥ 20 and npm ≥ 9
|
||||
- `bash`, `curl`, `git`, `python3`, `tar`, and standard core utilities (`awk`, `df`, `find`, `flock`, `grep`, `install`, `realpath`, `sed`, `sha256sum`, `stat`, `sync`)
|
||||
- At least 256 MiB free disk and 1,000 free inodes at the npm prefix
|
||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||
|
||||
The installer evaluates canonical phases P0–P9 and does not print `Done.` unless every committed postcondition passes. A failed phase exits non-zero, names the phase, and points to its durable journal under `${XDG_STATE_HOME:-~/.local/state}/mosaic/install/`. See [Installer state machine and recovery](docs/guides/installer-state-machine.md).
|
||||
|
||||
## Usage
|
||||
|
||||
### Launching Agent Sessions
|
||||
@@ -337,7 +351,7 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
||||
Run the installer again — it handles upgrades automatically:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash'
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
@@ -358,15 +372,17 @@ The CLI also performs a background update check on every invocation (cached for
|
||||
### Installer Flags
|
||||
|
||||
```bash
|
||||
bash tools/install.sh --check # Version check only
|
||||
bash tools/install.sh --check # Side-effect-free P0-P8 postcondition check
|
||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
bash tools/install.sh --next # Prerelease lane: exact @next versions + pinned-source fallback
|
||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||
```
|
||||
|
||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. `--check` reports one PASS/FAIL row for each P0–P8 predicate and exits non-zero if any row fails; it does not create the npm prefix, lock, journal, manifest, or runtime files.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+33
-43
@@ -79,49 +79,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
||||
|
||||
---
|
||||
|
||||
## Per-estate durable agent working memory (#1051)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
Agent and lane continuity currently accumulates as plain local files with no repository backing. The installer must make a private, per-estate `mosaic-brain` clone at `~/.mosaic` reproducible without authorizing cross-estate access or introducing an independent credential path.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `MB-REQ-01` (R1): Ensure the target estate's existing `mosaic-brain` can be cloned to `~/.mosaic`; repository creation and live access granting remain broker-mediated.
|
||||
2. `MB-REQ-02` (R2/Q1): Derive estate and brain target from the configured target git host through the credential broker's estate registry. A second `brain_repo` authority and host-machine inference are forbidden; an unknown host fails closed with a named diagnosis.
|
||||
3. `MB-REQ-03` (R3): Seat access is granted only through `mosaic cred`; callers must never resolve or read a token independently. Live grant verification is gated on MC-CRED-01 implementation.
|
||||
4. `MB-REQ-04` (R4): The eventual live postcondition requires `~/.mosaic` to be a `main`-branch git repo with the expected remote and a seat-owned read/write round-trip. This live validation is gated on MC-CRED-01 implementation and cannot be replaced by a clone exit code.
|
||||
5. `MB-REQ-05` (R5): The out-of-estate refusal control covers both Git and API resolver axes. Axis disagreement is `indeterminate` failure, never permission; contract tests bind to the broker's four terminal classes and stable reason codes.
|
||||
6. `MB-REQ-06` (R6): The brain skeleton excludes `*.token`, `*.key`, `*.pem`, `.env`, and `credentials.json`; credentials remain broker-owned and no error path may print secret material. Arbitrary legacy content is never auto-published from a heuristic denylist: an approved content scanner must bind approval to the exact source snapshot, otherwise the item is retained and reported.
|
||||
7. `MB-REQ-07` (R7): Detect existing local lane directories and seat state files, publish approved snapshots into the durable layout without overwrite or deletion, and explicitly report every detected item that cannot be migrated. Automatic source deletion is parked until command-scoped identity propagation and the required clean audit; retained sources are always reported. Lane findings are append-only; `board/` has a named single writer; writes push immediately rather than on a timer.
|
||||
8. `MB-REQ-08` (R8): `mosaic doctor` reports missing clone, wrong remote, incomplete write-access evidence, and uncommitted local state. `--fix` repairs the first three only through the approved installer/broker path; it never hand-rolls credential resolution.
|
||||
9. `MB-REQ-09`: Retention is ownership-first and archive-only. Every retained artifact requires a named durable owner; absent or non-durable ownership leaves the gate open and blocking. Age and size never authorize deletion.
|
||||
10. `MB-REQ-10`: Brain provisioning occupies canonical installer P7 only after the applicable P5 credential postcondition commits; canonical phase numbers are unchanged.
|
||||
|
||||
### Current delivery slice
|
||||
|
||||
In scope now: estate derivation, secret exclusion, non-destructive migration, doctor reporting/repair orchestration, and red-first tests over all four credential-contract terminal classes. Live grant and live read/write round-trip evidence remain explicitly gated on the working MC-CRED-01 broker and must not be mocked or replaced by independent token lookup.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-MB-01`: Contract tests observe RED before implementation and then distinguish `ok/0`, `refused/10`, `error/20`, and `indeterminate/30`, preserving v1.5 diagnoses including refused `provider-identity-mismatch`/`credential-rejected` and indeterminate `identity-not-measured`/`provider-unavailable`. A scope-forbidden `/user` result with confirmed in-scope repository capability is never represented as a dead credential. `identity-not-found` remains reserved for a future visibility-authorized inventory operation and is not an expected `validate` result.
|
||||
2. `AC-MB-02`: Estate resolution uses the configured target git host and one registry; unknown, mismatched, and host-machine-derived inputs fail closed.
|
||||
3. `AC-MB-03`: A clean fixture contains the required layout and exact secret exclusions; filename-, content-, binary-, and size-based secret controls remain outside Git without their values appearing in output. Without an approved scanner, even benign legacy content is retained and reported rather than auto-published.
|
||||
4. `AC-MB-04`: Migration publishes approved lane-durable and seat-state snapshots into collision-safe archive/ledger paths, retains and reports every source, never overwrites an existing finding, and never deletes by age/size.
|
||||
5. `AC-MB-05`: Doctor detects all four R8 defect classes; `--fix` repairs eligible classes through the approved P7/broker seam and leaves unresolved credential-dependent states visible.
|
||||
6. `AC-MB-06`: Git-axis and API-axis refusal must both be authoritative `refused` outcomes with matching stable reason codes; any disagreement yields `indeterminate`.
|
||||
7. `AC-MB-07`: Independent code review and security review pass at the exact head, and HOMELAB Woodpecker instance `mosaic` is terminal green before integration.
|
||||
8. `AC-MB-08`: After reviewed merge to `main`, report only **believed-fixed, pending jarvis validation**; issue #1051 remains open until W-jarvis validates the installed result.
|
||||
|
||||
### Constraints and risks
|
||||
|
||||
- MC-CRED-01 contract v1.5 is the caller boundary; no independent credential/token lookup is permitted. Identity is established from governed mint-time binding and provider evidence when measurable, never a credential filename. Runtime validation does not widen a least-privilege token merely to make `/user` observable.
|
||||
- C1 owns installer phase sequencing. This slice consumes P5/P7 ordering without renumbering or duplicating the phase machine.
|
||||
- Lane content is findings, so last-writer-wins is data loss. Append-only names and explicit collision handling are mandatory.
|
||||
- A created-but-empty brain beside unbacked local doctrine fails the objective; migration is a primary acceptance gate.
|
||||
|
||||
---
|
||||
|
||||
## Compaction Refresh Trust Lifecycle (M1, #827–#830)
|
||||
|
||||
### Problem and objective
|
||||
@@ -1411,3 +1368,36 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
||||
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
||||
|
||||
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
||||
|
||||
---
|
||||
|
||||
## Greenfield install correctness — C1 (#1050)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
A from-zero install can report success while leaving the target host unusable because the installer has no transactional state machine capable of certifying its own postconditions. C1 supplies the structural spine and red-first fixture; later cards repair the individual failed postconditions.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. P2 is scoped to installer-distribution artifacts and SHALL NOT foreclose credentialed downstream acquisition. P5 owns validating any credential capability required by requested downstream work; P7 may provision credential-dependent resources only after that P5 postcondition commits.
|
||||
2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently.
|
||||
3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded.
|
||||
4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails.
|
||||
5. P4 SHALL consume a checkout-free, lane/versioned shipped-set declaration published by the installer. C1 SHALL NOT select among the currently disagreeing framework-payload, repository-root, sync-source, and W-jarvis populations; while no declaration exists, P4 reports `NOT-MEASURED / UNDECLARED` and remains blocking rather than fabricating a count. C5 owns the declaration's contents and containment/loadability fulfillment.
|
||||
6. The from-zero fixture SHALL be lane-parametric, use Debian/glibc, run the documented install command as a non-root target user with an isolated HOME, and inherit no host credentials, npm cache, home directory, or runtime configuration.
|
||||
7. The fixture SHALL select `next` with `--next` or `MOSAIC_NEXT=1` and assert the resolved lane version. Internal predicates use P3's absolute CLI path; shell discoverability is tested only at P8.
|
||||
8. Fault injection after each P2–P8 phase SHALL prove either clean rollback or a durable, honestly reported resumable partial state, with no journal incorrectly left in progress.
|
||||
9. Unsupported musl/Alpine and unavailable Docker SHALL fail loudly rather than skip as pass.
|
||||
|
||||
### C1 acceptance criteria
|
||||
|
||||
1. The pre-C1 from-zero matrix records both discriminating controls: with `git` absent, the legacy installer still exits zero while P1 fails and skill sync degrades; with `git` present, P1 passes and the observed sync store/runtime links are 101/101. The C1 installer must fail at P1 before mutation when `git` is absent.
|
||||
2. The discriminating P3 row passes: the binary exists at the expected absolute path and reports exactly the resolved `next` lane version, while P4, P5, and P8 fail.
|
||||
3. The `--check` mutation negative control proves host fingerprints are byte-identical before and after observation.
|
||||
4. Woodpecker executes and validates the expected RED fixture; C1 does not repair P4/P5/P8 or activate #869.
|
||||
|
||||
### Explicit exclusions and dependencies
|
||||
|
||||
- C2 owns P8/PATH, C3 owns P5/headless identity, C4 owns P6 activation policy, and C5 owns P4/skills.
|
||||
- Main-lane execution is a promotion precondition owned by #1037; C1 only makes the fixture lane-parametric.
|
||||
- RM-02 and #869 activation are out of scope.
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
||||
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
||||
|
||||
## Installation and upgrades
|
||||
|
||||
- [Installer state machine and recovery](guides/installer-state-machine.md) — canonical P0–P9 phases, side-effect-free checks, durable journal states, rollback/remediation, and the Debian greenfield CI gate.
|
||||
- [Upgrade safety and recovery](guides/upgrade-safety-and-recovery.md) — framework ownership, durable operator snapshots, verify net, and projection regeneration.
|
||||
|
||||
## CLI and skill management
|
||||
|
||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Installer State Machine and Recovery
|
||||
|
||||
The unified installer uses a transactional P0–P9 model. It may report success only after P9 reasserts every applicable committed postcondition. Internal phases invoke the CLI by P3's absolute path; shell discovery is checked only at P8.
|
||||
|
||||
## Canonical phases
|
||||
|
||||
| Phase | Responsibility | Failure disposition |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| P0 Resolve context | State target user, HOME, shell, privilege mode, architecture, libc, Node, and npm | Fail before mutation |
|
||||
| P1 Preflight | Validate downstream tool closure (including `git` and `python3`), writable prefix, registry lane, disk/inodes, and exclusive lock | Fail before target mutation |
|
||||
| P2 Acquire artifacts | Resolve exact registry versions and an immutable framework commit; record lane and SHA-256 | Discard temporary work |
|
||||
| P3 Install CLI | Install at the configured absolute prefix and require exact resolved version | Restore the prior prefix/npmrc snapshot |
|
||||
| P4 Install framework + skills | Sync framework and consume a checkout-free, lane/versioned shipped-skill declaration | Restore prior framework/runtime trees |
|
||||
| P5 Identity | Validate SOUL/USER content, owner, and mode; establish any credential capability requested downstream | Restore generated identity/credential binding |
|
||||
| P6 Runtime linking / activation | Evaluate activation honestly; never treat dead enforcement hooks as active readiness | Restore runtime activation files |
|
||||
| P7 Services | Provision only requested services/resources after any required P5 credential commits | Stop and restore requested services/resources |
|
||||
| P8 Shell discoverability | Require fresh login and non-login shells of the actual target shell to resolve P3's path | Restore shell profiles |
|
||||
| P9 Verify + commit | Re-run P0–P8, commit the manifest, and seal the journal | Leave an honestly reported resumable failure or restore the pre-install snapshot |
|
||||
|
||||
The phase numbers are a cross-workstream contract and must not be renumbered.
|
||||
|
||||
## Side-effect-free check
|
||||
|
||||
```bash
|
||||
bash tools/install.sh --check # stable/latest lane
|
||||
bash tools/install.sh --check --next # prerelease lane
|
||||
```
|
||||
|
||||
`--check`:
|
||||
|
||||
- emits exactly one `[P0]` through `[P8]` PASS/FAIL row;
|
||||
- exits non-zero if any predicate fails;
|
||||
- does not create the npm prefix, lock, journal, manifest, shell profile, or runtime file;
|
||||
- uses temporary npm observation storage outside the target HOME and removes it before exit.
|
||||
|
||||
P4 currently fails as `NOT-MEASURED / UNDECLARED` until the installer publishes `~/.config/mosaic/.install-shipped-skills.json`. C1 deliberately does not select among the conflicting candidate populations; C5 owns publishing and fulfilling that declaration. Once present, the P4 predicate requires the declaration's lane/version to match the resolved install and every named skill to remain contained under `skills/<name>/SKILL.md` with matching loadable frontmatter.
|
||||
|
||||
## Durable journal
|
||||
|
||||
Each mutating run creates a private transaction directory:
|
||||
|
||||
```text
|
||||
${XDG_STATE_HOME:-~/.local/state}/mosaic/install/
|
||||
active.json
|
||||
<UTC-run-id>/
|
||||
journal.ndjson
|
||||
journal.ndjson.sha256 # committed runs only
|
||||
commands.log
|
||||
snapshot/
|
||||
```
|
||||
|
||||
Before each mutation scope is touched, `journal.ndjson` records:
|
||||
|
||||
- phase and path;
|
||||
- whether prior state existed and where its snapshot lives;
|
||||
- the reversal action;
|
||||
- the captured command-output location and command status.
|
||||
|
||||
Journal, action-status, manifest, or command-log write/sync failure is fatal. An unrecorded mutation is not allowed. Successful P9 runs append a seal event, write the SHA-256 sidecar, and make the journal and sidecar read-only. Required P4/P6 action failures are persisted in the manifest so a later `--check` cannot turn a failed action into a false pass.
|
||||
|
||||
Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict descendants of canonical `HOME`; unsafe custom `MOSAIC_HOME`/`MOSAIC_PREFIX` values fail at P0. The same validation runs again immediately before recursive rollback. The OS lock is concurrency authority: if a process dies while `active.json` still says `in-progress`, a retry that acquires the free lock preserves the stale projection as `prior-active.json` and proceeds from the honestly retained partial state.
|
||||
|
||||
`active.json` is the current projection:
|
||||
|
||||
- `in-progress`: incomplete/open transaction;
|
||||
- `rolled-back`: a fault restored the snapshot;
|
||||
- `rollback-failed`: restoration failed or refused a replaced/unsafe target and requires manual recovery;
|
||||
- `failed-resumable`: named postconditions failed and the recorded partial state remains for remediation;
|
||||
- `committed`: P9 passed and the journal is sealed.
|
||||
|
||||
## Failure recovery
|
||||
|
||||
1. Read the named phase and remediation line from installer stderr.
|
||||
2. Inspect `active.json`, then the referenced `journal.ndjson` and `commands.log`. Command output needed to diagnose a failure is preserved and surfaced; it is not redirected away.
|
||||
3. For `rolled-back`, verify the target paths match their pre-install state before retrying.
|
||||
4. For `failed-resumable`, repair the named phase owner requirement, then run `install.sh --check` before retrying the installer.
|
||||
5. Do not activate the #869 enforcement hooks merely to turn P6 green. A broker-less host with those hooks is a failed P6 state.
|
||||
|
||||
## Greenfield CI gate
|
||||
|
||||
`.woodpecker/greenfield-install.yml` runs `tools/e2e-install-test.sh` from zero in Debian/glibc as a non-root uid with `env -i`. No host HOME, npm cache, credentials, or bind mount enters the target process. Checkout mode packages the complete current checkout into an archive, pins its SHA-256 through an internal fixture seam, and copies the self-contained fixture into the container; framework-installer changes in the PR are therefore exercised rather than fetched from an older remote branch.
|
||||
|
||||
The C1 fixture intentionally returns an attributable RED while C2–C5 remain open. CI itself remains green only when the fixture's final P0–P9 verdicts, required discriminator rows, and non-zero exit match the versioned contract in `tools/fixtures/greenfield-expected-red.tsv`. Any later remediation that changes an observed verdict makes CI red until the owning lane deliberately updates that manifest:
|
||||
|
||||
- `git` present: P1 and strict P3 pass; P4/P5/P6/P8 fail for their own reasons; P9 refuses success.
|
||||
- `git` absent: P1 fails before target mutation and the installer emits no `Done.`.
|
||||
|
||||
The fixture is lane-parametric:
|
||||
|
||||
```bash
|
||||
bash tools/e2e-install-test.sh --lane next --git present
|
||||
bash tools/e2e-install-test.sh --lane main --git present
|
||||
```
|
||||
|
||||
CI exercises both lane parameters as expected-RED structural checks. Delivery targets `main` under the trunk-only merge rule; `next` remains a non-merging integration lane. The linked installer issue stays open after merge and closes only after Jarvis independently validates the greenfield behavior.
|
||||
|
||||
## Source trust boundary
|
||||
|
||||
Remote source mode pins the resolved commit, records the archive SHA-256, limits compressed/expanded size and entry count, and rejects traversal, links, devices, and special files before extraction. This provides immutable run provenance and archive safety, not an independent authenticity root. Signed artifact metadata/provenance is explicitly deferred by the canonical greenfield PRD; C1 does not invent a signing system. The checkout CI seam does verify an expected digest supplied independently by the fixture.
|
||||
@@ -12,6 +12,20 @@ with no snapshot to fall back to.
|
||||
Protection is layered. Each layer is independent; a later layer catches what an
|
||||
earlier one misses.
|
||||
|
||||
## Layer 0 — Transaction journal (install-wide recovery)
|
||||
|
||||
The unified installer opens a private journal under
|
||||
`${XDG_STATE_HOME:-~/.local/state}/mosaic/install/` before the first target
|
||||
mutation. Every mutation scope records its path, prior snapshot, and reversal
|
||||
instructions before it is touched. Journal write/sync failure is fatal, and P9
|
||||
seals successful journals with a SHA-256 sidecar. See
|
||||
[Installer state machine and recovery](./installer-state-machine.md).
|
||||
|
||||
This transaction journal is distinct from the retained operator-only backup
|
||||
below. The transaction journal is required for correctness and rollback;
|
||||
Layer 2's durable backup remains a separately stated, fail-open recovery bonus
|
||||
for a manifest bug that the normal transaction did not detect.
|
||||
|
||||
## Layer 1 — Manifest-owned sync (prevention)
|
||||
|
||||
The single source of truth for ownership is
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# #1050 — Installer P0–P9 state machine and red-first fixture
|
||||
|
||||
## Objective
|
||||
|
||||
Implement C1 from the canonical greenfield-install PRD v2: a transactional P0–P9 installer spine, a side-effect-free P0–P8 `--check`, and a lane-parametric Debian/glibc non-root from-zero fixture. The acceptance milestone is an attributable RED on the pre-C1 installer while preserving P3 PASS.
|
||||
|
||||
## Authority and scope
|
||||
|
||||
- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`. Currency was re-derived after compaction: authenticated fetch resolved `origin/main` to `cb23e5fbc8a282fa967b93d7a134fa48d11b4bb1`; the PRD and charters are byte-identical to the previously read remote copies.
|
||||
- Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`).
|
||||
- Historical implementation base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. Delivery PR #1054 targets `main` under L0's trunk-only rule; `next` remains a non-merging integration lane.
|
||||
- Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion.
|
||||
- `docs/TASKS.md` is orchestrator-single-writer and is not modified by this worker.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Pre-register the canonical phase/output/side-effect-free/fault-injection checks and observe RED against the base installer.
|
||||
2. Commit the immutable red-first acceptance fixture before implementation.
|
||||
3. Add the state-machine/journal/postcondition spine without repairing P4/P5/P8 symptoms.
|
||||
4. Wire the expected-RED from-zero fixture into Woodpecker using Debian/glibc and a non-root target user.
|
||||
5. Run shell/static baselines, situational container validation, code review, security review, then deliver through a PR to `next` under the coordinator-owned merge path.
|
||||
|
||||
## Budget
|
||||
|
||||
- Working estimate: 32K reasoning/output tokens.
|
||||
- Hard external cap: none stated.
|
||||
- Adaptation: keep implementation in shell surfaces already in scope; no package dependency install unless repository gates require it.
|
||||
|
||||
## Pre-registered acceptance checks
|
||||
|
||||
| ID | Exact case | Expected pre-fix result |
|
||||
|---|---|---|
|
||||
| C1-R1 | `tools/e2e-install-test.sh --lane next` in a clean Debian 12 container as uid 1001 | non-zero; P3 PASS; P4 `NOT-MEASURED / UNDECLARED`; P5/P6/P8 FAIL with own reasons |
|
||||
| C1-R2 | `tools/install-state-machine.test.sh` phase table case | RED because base installer does not enumerate canonical P0–P9 contracts |
|
||||
| C1-R3 | side-effect-free `--check` case over a fingerprinted HOME | RED because base `--check` is version-only rather than P0–P8 predicates |
|
||||
| C1-R4 | fault injection after each P2…P8 | RED because base installer has no injectable durable journal/rollback state |
|
||||
| C1-R5 | Docker unavailable | base harness incorrectly exits 0; replacement must fail non-zero |
|
||||
| C1-R6 | lane resolution | bare checkout is forbidden; fixture must pass `--next` and assert the resolved prerelease version |
|
||||
| C1-R7 | same Debian fixture with `git` absent vs present | absent: P1 FAIL while legacy installer exits 0 and sync degrades; present: P1 PASS and observed store/runtime containment 101/101 |
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Charter, doctrine, delivery/CI/QA/docs guides read and re-anchored after compaction.
|
||||
- [x] Canonical PRD v2/v3 addenda and charters read from fetched `origin/main`; numbering reconciles with the TL spec. No numbering conflict found. INV-B/C/D are binding and implemented without renumbering.
|
||||
- [x] Target base reachability verified with `merge-base --is-ancestor`.
|
||||
- [x] Issue #1050 created and provider author read back.
|
||||
- [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it.
|
||||
- [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it.
|
||||
- [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here.
|
||||
- [x] Corrected RED transcript captured and reported, including the git-present/absent controls and strict P3 PASS.
|
||||
- [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery.
|
||||
- [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. CI compares its exact final phase map/reasons to `tools/fixtures/greenfield-expected-red.tsv`; the fixture remains red while the detector job is green only on an exact match.
|
||||
- [ ] Reviews complete. Automated review defects around Bash conditional errexit, explicit exits, P4/P6 persisted action status, dev/offline source resolution, stale locks, checkout coverage, and rollback path safety were remediated. Remaining automated objections are the charter-mandated expected RED/C5 boundary and signed provenance, which the canonical PRD explicitly defers; independent informed review is still required.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`.
|
||||
- `next` is a non-merging integration lane; PR #1054 targets `main`. The old “pending promotion to main” caution dissolved when the base moved. #1050 remains open after merge and closes only after Jarvis validates the greenfield behavior.
|
||||
- #869 must remain staged and inactive.
|
||||
- Late sequencing input MB-BRAIN-01 is accommodated without implementation or renumbering: P2 covers installer distribution only; P5 owns requested credential capability; P7 leaves an ordered seam for credential-dependent resource provisioning after P5.
|
||||
|
||||
## Verification log
|
||||
|
||||
- `bash -n` and ShellCheck pass for all changed shell surfaces; `git diff --check` passes.
|
||||
- `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, good/bad discrimination, persisted P4/P6 action failures, P2–P8 rollback, unsafe/overlapping/symlink roots, stale `active.json`, and fatal journal initialization.
|
||||
- `bash tools/install-next-lane.test.sh` passes, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, and prerelease suffix mismatch.
|
||||
- `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised. `tools/verify-greenfield-expected-red.sh` converts that expected detector result into a green CI assertion and fails on any unreviewed verdict drift.
|
||||
- Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test:installer`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full rerun is required after final edits.
|
||||
@@ -1,105 +0,0 @@
|
||||
# #1051 — per-estate mosaic-brain installer
|
||||
|
||||
Last updated: 2026-08-05
|
||||
|
||||
## Objective
|
||||
|
||||
Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusions, non-destructive migration, doctor diagnostics/fixes, and credential-contract terminal-class handling. Live broker grants and live read/write round-trips remain gated on MC-CRED-01.
|
||||
|
||||
## Sources and bindings
|
||||
|
||||
- Provider issue: HOMELAB `git.mosaicstack.dev`, `GET /api/v1/repos/mosaicstack/stack/issues/1051`, `application/json;charset=utf-8`.
|
||||
- Issue requirements: R1–R8 read directly on 2026-08-05.
|
||||
- MC-CRED caller contract: v1.5, SHA-256 `4cecba3386b37431d4a075205c6dfe43555c7673922fed61b84f43cac1a6ae92` at the 2026-08-05 re-derivation. Earlier moving bindings were v1.5 `710d22d61a93a4b9c70fc55506a023a675a110417fa7a6e72dc051c0d9fe8237`, v1.4 `27f20158561ae8292f3bfc926b5e97f398de93db6a1cf65fcc215d08811d39af`/`d12ad4595b7aef078e392988a07ab5cb00244440775c9c733dc825746d7ac67b`, and v1.3 `8cfa4853d2b0b0e8cc9e792fa8411310e16d7704c06e0af9d9a57155131d8086`.
|
||||
- Fleet doctrine: SHA-256 `026b43322e0551ef15b646a9f30d3a6aef58c662a810b732be2a03b1ecf7d36e` at intake.
|
||||
- Intake base was HOMELAB provider `next` = `4df478cdd150fdf8d52ea109f02ade5d85017acd`; `main` = `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`. On 2026-08-05 `mos-claude` ruled that L0 trunk-based gate 15 requires all three lanes to retarget to `main`; `next` remains a non-merging integration branch. Never weaken or patch `pr-merge.sh`.
|
||||
|
||||
## Scope
|
||||
|
||||
### In now
|
||||
|
||||
- R2/Q1 target-host estate derivation using one registry.
|
||||
- R5 both-axis refusal parity and disagreement failure.
|
||||
- R6 exact secret exclusions and no secret-bearing diagnostics.
|
||||
- R7 detection plus non-destructive, collision-safe migration/reporting.
|
||||
- R8 doctor checks and approved-seam fix orchestration.
|
||||
- Red-first tests over all four contract terminal classes and stable reason codes.
|
||||
|
||||
### Gated / excluded
|
||||
|
||||
- R3 live grant: waits for working MC-CRED-01.
|
||||
- R4 live seat-owned read/write round-trip: waits for working MC-CRED-01.
|
||||
- No independent token lookup, grant helper, or shared-credential fallback.
|
||||
- No phase renumbering; C1 owns the phase machine and provides the P5→P7 seam.
|
||||
- No age/size reaping or deletion.
|
||||
|
||||
## Owner authority ruling and resolver seam
|
||||
|
||||
- Binding addendum: `/home/hermes/agent-work/tl-mosaic/CHARTER-MB-BRAIN-01-ADDENDUM.md`; re-read after compaction.
|
||||
- HOMELAB durable lane-archive owner and user-namespace brain owner are the human provider account selected by local estate policy (operator ruling: `jason.woltje`) with a required GLPI queue as the standing remediation process. The brain target is therefore `<policy-owner>/mosaic-brain` on the estate host, not `<installer-source-org>/mosaic-brain`. Framework source remains operator-agnostic: the actual login and queue are local policy, not hardcoded open-source context.
|
||||
- Provider lookup is anonymous because the ruled owner is public. It requires exact allowlisted login plus a same-invocation public known-good control, private 404 control, and generated absent 404 control. It sends no Authorization header and never widens token scope.
|
||||
- Provider `active` is deliberately ignored: non-admin reads return false for demonstrably active accounts. Resolvability + exact login + public visibility are the gate.
|
||||
- Private and absent principals both return anonymous 404. The fail-closed reason is `owner-not-resolvable`, never owner-not-found.
|
||||
- Caller `owner` strings and `validated=true` are ignored. Migration consumes only an injected source-of-truth resolver result. Owner grammar is NFKC-stable, ASCII allowlisted, exact-policy matched, and mission-seat class is excluded.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Pre-register acceptance tests and observe each requirement RED for its own missing behavior.
|
||||
2. Commit the red tests before implementation.
|
||||
3. Implement a narrow brain provisioning/doctor helper that consumes broker JSON outcomes and the shared estate registry without credential resolution.
|
||||
4. Implement safe migration and exact brain skeleton/ignore policy.
|
||||
5. Integrate the helper into C1's P7 seam and `mosaic doctor` after C1 lands/rebase.
|
||||
6. Run focused, package, installer, lint, typecheck, format, and situational security tests.
|
||||
7. Run independent code and security reviews in parallel; remediate and re-review.
|
||||
8. Push after HOMELAB queue guard, open the reviewed PR to `main`, and preserve merge order C1 → MC-CRED → MB-BRAIN. Do not modify the merge guard; `next` is non-merging integration only.
|
||||
9. Re-take CI measurement at the rebased exact head; do not rework code solely because base evidence moved.
|
||||
|
||||
## Acceptance interpretation registered before results
|
||||
|
||||
- `ok/0`: complete authoritative evidence only.
|
||||
- `refused/10`: complete authoritative denial only.
|
||||
- `error/20`: local contract/control failure; never reinterpret as denial.
|
||||
- `indeterminate/30`: incomplete/disagreeing evidence; fail closed, never resolve permissively.
|
||||
- Both Git and API axes must return authoritative `refused` with the same stable reason code for R5. Any axis disagreement is `indeterminate`. A provider `/user` login mismatch is first-class `provider-identity-mismatch`; credential filenames never establish principal identity.
|
||||
- Migration publication requires the durable object to contain the approved snapshot and no overwrite. Automatic path-based source deletion is parked; every source is retained and reported.
|
||||
- Secret exclusion is tested through exact ignore rules, nested secret-shaped paths, bounded UTF-8 content controls, and an approved-scanner gate bound to the exact source snapshot. Without an approved scanner, even benign content is retained and reported rather than committed.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token ceiling was supplied. Working cap: 55K tokens for implementation/review and 3 focused remediation attempts per failure class. Reduce optional refactoring and documentation breadth before touching required acceptance scope.
|
||||
|
||||
## Risks
|
||||
|
||||
- C1 and MC-CRED branches have not merged into `main`; integration edits must wait for their exact interfaces or be confined to stable contract seams.
|
||||
- A broker runtime test before MC-CRED lands would either fail for an irrelevant reason or pressure a hand-rolled workaround; contract fixtures are allowed, live capability claims are not.
|
||||
- Migration can lose data through overwrite, cross-device move failure, or partial copy. Implementation must stage, verify resulting bytes, and retain/report source on incomplete transfer.
|
||||
- `~/.mosaic` is a git repo, while current working state may live under multiple local roots; detection must be explicit and cannot treat age/size as ownership.
|
||||
|
||||
## Progress / evidence
|
||||
|
||||
- [x] Charter receipt accepted by `tl-mosaic`.
|
||||
- [x] Issue #1051 R1–R8 read directly from provider.
|
||||
- [x] Contract re-derived through v1.3, moving v1.4, and v1.5 before R5 integration. v1.5 separates in-scope repository capability from `/user` identity measurement: 401 is `credential-rejected`/refused, 403/404 may become `identity-not-measured` only after in-scope capability succeeds, and 200 login mismatch is refused. `identity-not-found` is not reachable from `validate`.
|
||||
- [x] C1 P5→P7 seam receipt read; no brain implementation is in C1.
|
||||
- [x] RED acceptance set committed at `cf11c6c86abae073d8b02b4014cd5447ba67f12a`; author and committer read back as `be-coder-07` and branch reachability was independently verified by `tl-mosaic`.
|
||||
- [x] Moving-contract REDs observed independently for v1.4 mismatch, R8 prerequisite ordering, owner resolver seam/allowlist, tracked skeleton/no-follow behavior, runtime observation/publication, and provider owner resolution.
|
||||
- [x] Focused implementation includes secure migration, v1.5 write-differential/subject binding, production Git+API refusal parity, provider-backed durable owner resolution that ignores non-admin `active`, required GLPI standing-process policy, P7 provision orchestration, an internal installer command, and installed `mosaic doctor` wiring. Latest focused result: 97/97 (secure config 4, store 45, runtime 19, owner resolver 16, provision 5, provision command 3, installed doctor 5).
|
||||
- [x] MC-CRED added the required canonical reverse registry seam `ParsedCredentialEstateRegistry.resolveByHost()` at dependency head `6ca8758f`; current local copies are temporary until dependency integration and the 32-line permissive shim has been removed.
|
||||
- [x] Identity gotcha measured: inline `MOSAIC_GIT_IDENTITY=be-coder-07` controls credential resolution but does not override `user.name`/`user.email` inherited from the linked worktree common-dir config (`coder-mos1`). The first local P7 RED commit was immediately amended before push with command-scoped `GIT_AUTHOR_*` + `GIT_COMMITTER_*`; resulting author and committer both read back as `be-coder-07`. Every subsequent authoring command must carry both identity sets and be verified.
|
||||
- [x] R6 migration reports filename- or content-secret-shaped files without copying them; arbitrary legacy content requires an approved scanner bound to the exact source snapshot, and production currently retains/reports when no approved scanner is configured. `.gitignore` is canonical allowlisted content only: an existing noncanonical regular file fails closed and is never merged into publication. Symlinked `.gitignore`, layout directories, and nested migration destinations fail closed; a dirty checkout blocks provisioning before skeleton publication. The brain root is principal-owned mode `0700` before clone and after clone, all memory-bearing layout directories are mode `0700` even under umask `0022`, and doctor reports owner-accessible roots as hard unsafe findings.
|
||||
- [x] Provider owner lookup uses manual redirect handling, a five-second abort signal, strict JSON content type/shape, and an incrementally enforced 256 KiB response ceiling.
|
||||
- [x] Security-critical owner policy/registry reads have direct controls for principal UID ownership, file/ancestor permissions, and descriptor-safe regular-file reads.
|
||||
- [x] Automatic source deletion is parked per the shared-Git-identity governance ruling; remotely reachable snapshots still leave and report every source.
|
||||
- [x] Multi-host push-on-write uses an isolated temporary Git index populated from approved in-memory blobs rather than pathname re-reads, verifies each committed blob ID, the exact changed-path allowlist, and both author/committer trailers before push, then reconciles only approved paths into the real checkout index. Real-repository controls prove a clean checkout remains clean, a concurrent non-fast-forward fetch/rebase/push remains clean and preserves both findings, destination-path substitution cannot change committed bytes, and unrelated pre-staged secret-shaped content remains staged but never enters the published commit.
|
||||
- [x] Doctor Git observations preserve three states: `clean`, `dirty`, and `unmeasurable`; failed remote, branch, or status measurements emit hard `brain-git-state-indeterminate` findings rather than mismatch or ready. The boolean-literal guard sweep covered all MB-BRAIN production files in the 20-file PR population: its only remaining `=== false` guard is the non-nullable `isAbsolute()` predicate; no nullable boolean measurement guards remain.
|
||||
- [x] Author-run Review 10 and focused 88/88 evidence were declared void when blocker fixes changed the head; neither is an independent gate pass.
|
||||
- [ ] Installer shell P7 invocation after C1 + MC-CRED integration; production command is registered but the C1 shell has not yet called it.
|
||||
- [ ] Implementation green on merged dependency base.
|
||||
- [ ] Independent code review.
|
||||
- [ ] Independent security review.
|
||||
- [ ] HOMELAB CI terminal green at exact head.
|
||||
- [ ] Reviewed PR retargeted to `main` after C1 and MC-CRED; `next` remains non-merging integration only.
|
||||
|
||||
## Completion language
|
||||
|
||||
After reviewed merge to `main`, only: **believed-fixed, pending jarvis validation**. Issue #1051 remains open until W-jarvis validates the installed result.
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"clean:generated": "node scripts/clean-generated.mjs",
|
||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||
"test:checkout": "node --test scripts/*.test.mjs",
|
||||
"test": "pnpm test:checkout && turbo run test",
|
||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
||||
"test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh && bash tools/verify-greenfield-expected-red.test.sh",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"prepare": "node scripts/install-hooks.mjs"
|
||||
|
||||
@@ -58,6 +58,7 @@ done
|
||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||
# shellcheck source=tools/_lib/manifest.sh
|
||||
# shellcheck disable=SC1091 # Dynamic SOURCE_DIR; the path is validated by set -e.
|
||||
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
||||
|
||||
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
||||
@@ -222,12 +223,14 @@ prune_durable_snapshots() {
|
||||
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
|
||||
list="$(mktemp)"
|
||||
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
|
||||
warn "Backup pruning skipped; policy: retention cleanup is optional and a failed enumeration must preserve every existing recovery snapshot."
|
||||
rm -f "$list"; return 0
|
||||
fi
|
||||
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
|
||||
# dependency); if it is somehow unavailable, leave the backups untouched rather
|
||||
# than risk pruning in an undefined order.
|
||||
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
|
||||
warn "Backup pruning skipped; policy: ordering failure preserves all snapshots rather than risking deletion in an undefined order."
|
||||
rm -f "$list"; return 0
|
||||
fi
|
||||
while IFS= read -r d; do
|
||||
@@ -266,7 +269,11 @@ make_durable_snapshot() {
|
||||
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
|
||||
return 0
|
||||
fi
|
||||
chmod 700 "$root" 2>/dev/null || true
|
||||
if ! chmod 700 "$root"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: backup root permissions could not be made private; policy: never write operator data to an insufficiently protected location."
|
||||
return 0
|
||||
fi
|
||||
dir="$root/pre-update-$ts"
|
||||
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
|
||||
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
|
||||
@@ -281,7 +288,10 @@ make_durable_snapshot() {
|
||||
if ! enumerate_operator_files "$list"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
|
||||
rm -f "$list"; rmdir "$dir" 2>/dev/null || true
|
||||
rm -f "$list"
|
||||
if ! rmdir "$dir"; then
|
||||
warn "Durable snapshot cleanup left $dir in place; policy: preserve unexpected content rather than deleting it recursively."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r -d '' rel; do
|
||||
@@ -292,12 +302,18 @@ make_durable_snapshot() {
|
||||
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
|
||||
continue
|
||||
fi
|
||||
chmod 600 "$dst" 2>/dev/null || true
|
||||
if ! chmod 600 "$dst"; then
|
||||
rm -f "$dst"
|
||||
warn "Durable snapshot: copied '$rel' could not be made private and was removed; policy: do not retain an insecure recovery copy."
|
||||
continue
|
||||
fi
|
||||
count=$((count + 1))
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
# Tighten every dir the copy created (mkdir -p honors umask, but be explicit).
|
||||
find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true
|
||||
# Tighten every dir the copy created (mkdir -p already honored umask 077).
|
||||
if ! find "$dir" -type d -exec chmod 700 {} +; then
|
||||
warn "Durable snapshot directory permission recheck failed; policy: continue because every directory was created under umask 077, while retaining the diagnostic."
|
||||
fi
|
||||
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
|
||||
DURABLE_SNAPSHOT_DIR="$dir"
|
||||
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
|
||||
@@ -344,7 +360,9 @@ verify_operator_surface() {
|
||||
continue
|
||||
fi
|
||||
if cp "$snap" "$cur"; then
|
||||
chmod 600 "$cur" 2>/dev/null || true
|
||||
if ! chmod 600 "$cur"; then
|
||||
warn "Operator file '$rel' was restored but its mode could not be tightened to 0600; policy: preserve recovered content and require manual permission repair."
|
||||
fi
|
||||
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
|
||||
healed=$((healed + 1))
|
||||
else
|
||||
@@ -535,7 +553,7 @@ sync_framework_keep() {
|
||||
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
||||
# the "directory not empty" races we tolerate are ignored via -delete's own
|
||||
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
||||
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
|
||||
if ! find "$dst/$root" -type d -empty -delete; then
|
||||
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
||||
fi
|
||||
done < <(manifest_subtree_roots)
|
||||
@@ -581,7 +599,7 @@ run_migrations() {
|
||||
MIGRATION_REMOVED_PATHS+=("bin" "rails")
|
||||
if [[ -d "$TARGET_DIR/bin" ]]; then
|
||||
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
||||
rm -rf "$TARGET_DIR/bin"
|
||||
rm -rf "${TARGET_DIR:?}/bin"
|
||||
fi
|
||||
|
||||
# Remove old mosaic PATH entry from shell profiles
|
||||
@@ -706,13 +724,23 @@ mkdir -p "$TARGET_DIR/credentials"
|
||||
# by `mosaic init` from templates with user-supplied values.
|
||||
reconcile_framework_files
|
||||
|
||||
# Ensure tool scripts are executable
|
||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
||||
# Ensure tool scripts are executable. These are P4 postconditions, not
|
||||
# best-effort cleanup: a chmod failure leaves shipped tools unloadable.
|
||||
if ! find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} +; then
|
||||
fail "Could not mark shipped shell tools executable."
|
||||
exit 1
|
||||
fi
|
||||
if ! find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} +; then
|
||||
fail "Could not mark shipped runtime scripts executable."
|
||||
exit 1
|
||||
fi
|
||||
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
||||
# suffix — git resolves credential helpers by exact name/path, not extension —
|
||||
# so the *.sh glob above does not cover it; chmod it explicitly.
|
||||
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
|
||||
# suffix — git resolves credential helpers by exact name/path, not extension.
|
||||
if [[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] \
|
||||
&& ! chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic"; then
|
||||
fail "Could not mark git-credential-mosaic executable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ok "Framework synced to $TARGET_DIR"
|
||||
|
||||
@@ -739,49 +767,110 @@ step "Post-install tasks"
|
||||
|
||||
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
||||
|
||||
# Capture every fallible post-install command. A failure's text is surfaced and
|
||||
# also appended to the parent transaction's private command log. Failure to
|
||||
# write that log is fatal: continuing would recreate the false-clean diagnosis
|
||||
# INV-C forbids.
|
||||
record_phase_outcome() {
|
||||
local phase="$1" status="$2" reason="$3"
|
||||
[[ -n "${MOSAIC_INSTALL_PHASE_STATUS_FILE:-}" ]] || return 0
|
||||
if ! printf '%s\t%s\t%s\n' "$phase" "$status" "$reason" >> "$MOSAIC_INSTALL_PHASE_STATUS_FILE" \
|
||||
|| ! sync "$MOSAIC_INSTALL_PHASE_STATUS_FILE"; then
|
||||
fail "Could not durably record $phase action outcome for the parent transaction."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_captured() {
|
||||
local label="$1" output status=0
|
||||
shift
|
||||
output="$(mktemp "${TMPDIR:-/tmp}/mosaic-post-install.XXXXXX.log")"
|
||||
if "$@" >"$output" 2>&1; then status=0; else status=$?; fi
|
||||
if [[ -n "${MOSAIC_INSTALL_COMMAND_LOG:-}" ]]; then
|
||||
if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$MOSAIC_INSTALL_COMMAND_LOG" \
|
||||
|| ! sync "$MOSAIC_INSTALL_COMMAND_LOG"; then
|
||||
cat "$output" >&2
|
||||
rm -f "$output"
|
||||
fail "Could not durably append '$label' diagnostics to the install command log."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [[ "$status" -ne 0 ]]; then cat "$output" >&2; fi
|
||||
rm -f "$output"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
||||
link_args=()
|
||||
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
||||
# stdout is suppressed as before, but stderr is left connected: the
|
||||
# install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
|
||||
# the operator, not be swallowed silently.
|
||||
if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
|
||||
if run_captured "runtime asset linking" "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}"; then
|
||||
record_phase_outcome P6 committed "runtime asset linker exited zero"
|
||||
ok "Runtime assets linked"
|
||||
else
|
||||
warn "Runtime asset linking failed (non-fatal) — see message above for details."
|
||||
record_phase_outcome P6 failed "runtime asset linker exited non-zero"
|
||||
warn "Runtime asset linking did not commit; policy: continue only to enumerate all phase diagnostics, while P6/P9 remain blocking."
|
||||
fi
|
||||
else
|
||||
record_phase_outcome P6 failed "required runtime asset linker is missing or not executable"
|
||||
warn "Runtime asset linking was not attempted; policy: a missing required linker remains a blocking P6/P9 failure."
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
|
||||
if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then
|
||||
if run_captured "sequential-thinking setup" "$SCRIPTS/mosaic-ensure-sequential-thinking"; then
|
||||
ok "sequential-thinking MCP configured"
|
||||
elif [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
||||
record_phase_outcome P6 failed "sequential-thinking setup failed under diagnostic-continuation compatibility mode"
|
||||
warn "sequential-thinking setup did not commit; policy: the unified installer compatibility flag allows diagnostic continuation, while P6/P9 remain blocking."
|
||||
else
|
||||
if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
||||
warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)"
|
||||
else
|
||||
fail "sequential-thinking MCP setup failed (hard requirement)."
|
||||
exit 1
|
||||
fi
|
||||
fail "sequential-thinking MCP setup failed (hard requirement)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
|
||||
"$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)"
|
||||
if run_captured "excalidraw setup" "$SCRIPTS/mosaic-ensure-excalidraw"; then
|
||||
ok "excalidraw MCP configured"
|
||||
else
|
||||
warn "excalidraw setup did not commit; policy: optional integration failure is retained in the journal and does not define core install readiness."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
||||
"$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)"
|
||||
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then
|
||||
record_phase_outcome P4 failed "required skills sync explicitly skipped"
|
||||
warn "Skills sync was skipped; policy: diagnostic continuation is allowed, but P4/P9 cannot certify an incomplete requested framework install."
|
||||
elif [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
||||
if run_captured "skills sync" "$SCRIPTS/mosaic-sync-skills"; then
|
||||
record_phase_outcome P4 committed "skills sync exited zero"
|
||||
ok "Skills synced"
|
||||
else
|
||||
record_phase_outcome P4 failed "skills sync exited non-zero"
|
||||
warn "Skills sync did not commit; policy: continue to collect P4 diagnostics, but P4/P9 must not certify the install."
|
||||
fi
|
||||
else
|
||||
record_phase_outcome P4 failed "required skills sync command is missing or not executable"
|
||||
warn "Skills sync was not attempted; policy: a missing required sync command remains a blocking P4/P9 failure."
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
|
||||
"$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)"
|
||||
if run_captured "local skills migration" "$SCRIPTS/mosaic-migrate-local-skills" --apply; then
|
||||
ok "Local skills migrated"
|
||||
else
|
||||
record_phase_outcome P4 failed "local skills migration exited non-zero"
|
||||
warn "Local skill migration did not commit; policy: preserve user content and continue diagnostics, while P4/P9 remain blocking."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
|
||||
"$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details"
|
||||
if run_captured "health audit" "$SCRIPTS/mosaic-doctor"; then
|
||||
ok "Health audit passed"
|
||||
else
|
||||
warn "Health audit found unresolved state; policy: preserve its diagnostics and let P9 issue the authoritative failure."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Write version stamp AFTER everything succeeds
|
||||
# The version stamp records the successfully committed framework file sync.
|
||||
# Post-install failures are carried separately into P4/P6 and cannot be erased
|
||||
# by this stamp.
|
||||
write_framework_version
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────
|
||||
|
||||
@@ -23,7 +23,6 @@ import { registerSkillCommand } from './commands/skill.js';
|
||||
import { registerLaunchCommands } from './commands/launch.js';
|
||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
||||
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
||||
import { registerBrainProvisionCommand } from './commands/brain-provision-command.js';
|
||||
import { registerAuthCommand } from './commands/auth.js';
|
||||
import { registerFederationCommand } from './commands/federation.js';
|
||||
import { registerGatewayCommand } from './commands/gateway.js';
|
||||
@@ -86,10 +85,6 @@ registerLeaseCapabilityProbe(program);
|
||||
|
||||
registerInstallOrderingGuardCommand(program);
|
||||
|
||||
// ─── durable brain P7 provisioner (hidden; #1051) ───────────────────────
|
||||
|
||||
registerBrainProvisionCommand(program);
|
||||
|
||||
// ─── login ──────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface CommandRequest {
|
||||
readonly program: 'git' | 'mosaic';
|
||||
readonly args: readonly string[];
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
readonly status: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
interface InstalledDoctorResult {
|
||||
readonly status: 'ok' | 'warn' | 'error';
|
||||
readonly findings: readonly {
|
||||
readonly code: string;
|
||||
readonly reasonCode: string | null;
|
||||
}[];
|
||||
readonly lines: readonly string[];
|
||||
}
|
||||
|
||||
interface BrainDoctorModule {
|
||||
runInstalledBrainDoctorCheck(
|
||||
options: {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity?: string;
|
||||
readonly fix: boolean;
|
||||
},
|
||||
run: (request: CommandRequest) => CommandResult,
|
||||
): InstalledDoctorResult;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-doctor-check.js';
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadDoctor(requirement: string): Promise<BrainDoctorModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as BrainDoctorModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: installed brain doctor check is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function tempRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-doctor-'));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function installConfig(root: string): { readonly home: string; readonly mosaicHome: string } {
|
||||
const home = join(root, 'home');
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
mkdirSync(join(mosaicHome, 'cred'), { recursive: true });
|
||||
mkdirSync(join(mosaicHome, 'brain'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(mosaicHome, 'cred', 'estates.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
writeFileSync(
|
||||
join(mosaicHome, 'brain', 'owners.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
estate: 'homelab',
|
||||
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||
controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
writeFileSync(
|
||||
join(mosaicHome, '.install-manifest.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
status: 'committed',
|
||||
sourceRepo: 'https://git.example.invalid/example/stack.git',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return { home, mosaicHome };
|
||||
}
|
||||
|
||||
function validateResult(outcome: 'ok' | 'refused' | 'indeterminate', reasonCode: string): string {
|
||||
const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30;
|
||||
return JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation: 'validate',
|
||||
outcome,
|
||||
exitCode,
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity: 'seat-a',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'durable-owner/mosaic-brain',
|
||||
},
|
||||
mutation: 'none',
|
||||
reason: { code: reasonCode, message: 'non-secret' },
|
||||
evidence: {
|
||||
providerIdentity:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
login: 'seat-a',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
repositoryPermission:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
requested: 'write',
|
||||
effective: 'write',
|
||||
endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
writeDifferential:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
state: 'can-write',
|
||||
credentialBinding: 'same-resolution',
|
||||
transportPrincipal: 'seat-a',
|
||||
authenticatedReceivePack: 'advertised',
|
||||
readOnlyControl: {
|
||||
identity: 'read-control',
|
||||
providerPermission: 'read',
|
||||
receivePack: 'refused',
|
||||
},
|
||||
unauthenticatedReceivePack: 'refused',
|
||||
artifactCreated: false,
|
||||
proves: 'non-secret evidence',
|
||||
doesNotProve: 'branch update acceptance',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
audit: { journalId: 'opaque', state: 'sealed' },
|
||||
});
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('installed mosaic doctor brain checks', (): void => {
|
||||
it('derives the target from the committed install manifest and surfaces a missing clone plus refusal', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-08 installed doctor missing clone');
|
||||
const config = installConfig(tempRoot());
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ ...config, identity: 'seat-a', fix: false },
|
||||
(request): CommandResult => {
|
||||
requests.push(request);
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('warn');
|
||||
expect(result.findings.map((finding) => finding.code)).toEqual(
|
||||
expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']),
|
||||
);
|
||||
expect(result.lines.join('\n')).toMatch(/brain-clone-missing/);
|
||||
expect(result.lines.join('\n')).toMatch(/no-token-for-identity/);
|
||||
expect(requests[0]?.args).toContain('durable-owner/mosaic-brain');
|
||||
});
|
||||
|
||||
it('fails closed without an explicit identity and performs no command', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-08 explicit identity');
|
||||
const config = installConfig(tempRoot());
|
||||
let calls = 0;
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ ...config, fix: false },
|
||||
(): CommandResult => {
|
||||
calls += 1;
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'error',
|
||||
findings: [{ code: 'brain-identity-required', reasonCode: 'identity-required' }],
|
||||
});
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it('treats identity-not-measured as an error, not no-write refusal and not a repairable grant case', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-08 identity measurement axis');
|
||||
const config = installConfig(tempRoot());
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ ...config, identity: 'seat-a', fix: true },
|
||||
(request): CommandResult => {
|
||||
requests.push(request);
|
||||
return {
|
||||
status: 30,
|
||||
stdout: validateResult('indeterminate', 'identity-not-measured'),
|
||||
stderr: 'identity not measured',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'brain-write-access-indeterminate',
|
||||
reasonCode: 'identity-not-measured',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is wired into the top-level mosaic doctor path before the shell audit runs', (): void => {
|
||||
const launch = readFileSync(join(process.cwd(), 'src', 'commands', 'launch.ts'), 'utf8');
|
||||
|
||||
expect(launch).toContain('runInstalledBrainDoctorCheck');
|
||||
expect(launch).toContain('defaultInstalledBrainDoctorOptions');
|
||||
expect(launch).toContain('systemCommandRunner');
|
||||
expect(launch).toMatch(/brainCheckFailed[\s\S]*runDoctorScriptAndExit/);
|
||||
});
|
||||
|
||||
it('reports a missing or unsafe registry/manifest as configuration error rather than defaulting estate', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-02 missing mapping fail-closed');
|
||||
const root = tempRoot();
|
||||
const home = join(root, 'home');
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
mkdirSync(mosaicHome, { recursive: true });
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ home, mosaicHome, identity: 'seat-a', fix: false },
|
||||
(): CommandResult => ({ status: 0, stdout: '', stderr: '' }),
|
||||
);
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.findings[0]?.code).toMatch(/brain-(estate-registry|install-manifest)-/);
|
||||
expect(result.lines.join('\n')).not.toMatch(/homelab|usc/);
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { readBrainConfigSecure } from './brain-secure-config.js';
|
||||
import { resolveBrainOwnerPolicy } from './brain-owner-resolver.js';
|
||||
import { deriveBrainTarget } from './brain-store.js';
|
||||
import {
|
||||
collectBrainDoctorReport,
|
||||
repairBrainDoctor,
|
||||
type CommandRunner,
|
||||
type DoctorRuntimeReport,
|
||||
} from './brain-store-runtime.js';
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const manifestSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
status: z.literal('committed'),
|
||||
sourceRepo: z.string().min(1),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export interface InstalledDoctorFinding {
|
||||
readonly code: string;
|
||||
readonly reasonCode: string | null;
|
||||
}
|
||||
|
||||
export interface InstalledDoctorResult {
|
||||
readonly status: 'ok' | 'warn' | 'error';
|
||||
readonly findings: readonly InstalledDoctorFinding[];
|
||||
readonly lines: readonly string[];
|
||||
}
|
||||
|
||||
function configurationError(code: string, reasonCode = code): InstalledDoctorResult {
|
||||
return {
|
||||
status: 'error',
|
||||
findings: [{ code, reasonCode }],
|
||||
lines: [`[mosaic-doctor] [ERROR] ${code}`],
|
||||
};
|
||||
}
|
||||
|
||||
function renderReport(report: DoctorRuntimeReport): InstalledDoctorResult {
|
||||
const findings = report.findings.map(
|
||||
(finding): InstalledDoctorFinding => ({
|
||||
code: finding.code,
|
||||
reasonCode: finding.reasonCode,
|
||||
}),
|
||||
);
|
||||
const hard = findings.some(
|
||||
(finding): boolean =>
|
||||
finding.code.endsWith('-error') ||
|
||||
finding.code.endsWith('-indeterminate') ||
|
||||
finding.code === 'brain-not-git-repository' ||
|
||||
finding.code === 'brain-root-permissions-unsafe',
|
||||
);
|
||||
const status: InstalledDoctorResult['status'] =
|
||||
findings.length === 0 ? 'ok' : hard ? 'error' : 'warn';
|
||||
const severity = status === 'error' ? 'ERROR' : status === 'warn' ? 'WARN' : 'OK';
|
||||
const lines =
|
||||
findings.length === 0
|
||||
? ['[mosaic-doctor] [OK] mosaic-brain ready']
|
||||
: findings.map(
|
||||
(finding): string =>
|
||||
`[mosaic-doctor] [${severity}] ${finding.code}${
|
||||
finding.reasonCode === null ? '' : ` reason=${finding.reasonCode}`
|
||||
}`,
|
||||
);
|
||||
return { status, findings, lines };
|
||||
}
|
||||
|
||||
export function runInstalledBrainDoctorCheck(
|
||||
options: {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity?: string;
|
||||
readonly fix: boolean;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): InstalledDoctorResult {
|
||||
if (options.identity === undefined || !IDENTITY.test(options.identity)) {
|
||||
return configurationError('brain-identity-required', 'identity-required');
|
||||
}
|
||||
|
||||
const registryPath = join(options.mosaicHome, 'cred', 'estates.json');
|
||||
const manifestPath = join(options.mosaicHome, '.install-manifest.json');
|
||||
const ownerPolicyPath = join(options.mosaicHome, 'brain', 'owners.json');
|
||||
let registrySource: string;
|
||||
try {
|
||||
registrySource = readBrainConfigSecure(registryPath, options.mosaicHome);
|
||||
} catch {
|
||||
return configurationError('brain-estate-registry-unavailable');
|
||||
}
|
||||
let manifestSource: string;
|
||||
try {
|
||||
manifestSource = readBrainConfigSecure(manifestPath, options.mosaicHome);
|
||||
} catch {
|
||||
return configurationError('brain-install-manifest-unavailable');
|
||||
}
|
||||
let manifestRaw: unknown;
|
||||
try {
|
||||
manifestRaw = JSON.parse(manifestSource);
|
||||
} catch {
|
||||
return configurationError('brain-install-manifest-invalid');
|
||||
}
|
||||
const manifest = manifestSchema.safeParse(manifestRaw);
|
||||
if (!manifest.success) return configurationError('brain-install-manifest-invalid');
|
||||
let ownerPolicySource: string;
|
||||
try {
|
||||
ownerPolicySource = readBrainConfigSecure(ownerPolicyPath, options.mosaicHome);
|
||||
} catch {
|
||||
return configurationError('brain-owner-policy-unavailable');
|
||||
}
|
||||
let preliminaryTarget: ReturnType<typeof deriveBrainTarget>;
|
||||
try {
|
||||
preliminaryTarget = deriveBrainTarget(registrySource, manifest.data.sourceRepo, 'policy-probe');
|
||||
} catch {
|
||||
return configurationError('brain-estate-registry-invalid');
|
||||
}
|
||||
const ownerPolicy = resolveBrainOwnerPolicy(ownerPolicySource, preliminaryTarget.estate);
|
||||
if (ownerPolicy === undefined) return configurationError('brain-owner-policy-invalid');
|
||||
|
||||
const input = {
|
||||
registrySource,
|
||||
targetGitUrl: manifest.data.sourceRepo,
|
||||
brainNamespace: ownerPolicy.brainNamespace,
|
||||
identity: options.identity,
|
||||
root: join(options.home, '.mosaic'),
|
||||
};
|
||||
try {
|
||||
return renderReport(
|
||||
options.fix ? repairBrainDoctor(input, run) : collectBrainDoctorReport(input, run),
|
||||
);
|
||||
} catch {
|
||||
return configurationError('brain-estate-registry-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultInstalledBrainDoctorOptions(fix: boolean): {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity?: string;
|
||||
readonly fix: boolean;
|
||||
} {
|
||||
const home = homedir();
|
||||
const identity = process.env['MOSAIC_GIT_IDENTITY'];
|
||||
return {
|
||||
mosaicHome: process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic'),
|
||||
home,
|
||||
...(identity === undefined ? {} : { identity }),
|
||||
fix,
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
function installerSource(): string {
|
||||
return readFileSync(join(process.cwd(), '..', '..', 'tools', 'install.sh'), 'utf8');
|
||||
}
|
||||
|
||||
describe('root installer P7 durable-brain integration', (): void => {
|
||||
it('records the configured source repository in the committed manifest for doctor derivation', (): void => {
|
||||
const installer = installerSource();
|
||||
|
||||
expect(installer).toContain('sourceRepo:');
|
||||
expect(installer).toMatch(/sourceRepo:\s*process\.argv\[/);
|
||||
expect(installer).toMatch(/MANIFEST_SOURCE_REPO/);
|
||||
});
|
||||
|
||||
it('invokes the broker-only provision command in P7 with explicit owner and refusal controls', (): void => {
|
||||
const installer = installerSource();
|
||||
const provision = installer.indexOf('__brain-provision');
|
||||
const p7 = installer.indexOf('state_phase_begin P7');
|
||||
|
||||
expect(provision).toBeGreaterThan(-1);
|
||||
expect(p7).toBeGreaterThan(-1);
|
||||
expect(provision).toBeGreaterThan(p7);
|
||||
for (const flag of [
|
||||
'--identity',
|
||||
'--target-url',
|
||||
'--owner',
|
||||
'--refusal-identity',
|
||||
'--lane',
|
||||
'--owner-policy',
|
||||
]) {
|
||||
expect(installer).toContain(flag);
|
||||
}
|
||||
expect(installer).not.toMatch(/__brain-provision[^\n]*(?:token|password|authorization)/i);
|
||||
});
|
||||
|
||||
it('journals ~/.mosaic and the owner policy as P7 mutations and checks the resulting object', (): void => {
|
||||
const installer = installerSource();
|
||||
|
||||
expect(installer).toContain('state_record_mutation P7 "$HOME/.mosaic"');
|
||||
expect(installer).toContain('state_record_mutation P7 "$MOSAIC_HOME/brain/owners.json"');
|
||||
expect(installer).toMatch(/P7\)[\s\S]*\.mosaic[\s\S]*(?:remote|get-url)[\s\S]*main/);
|
||||
});
|
||||
|
||||
it('discovers every legacy lane directory rather than silently migrating only one lane', (): void => {
|
||||
const installer = installerSource();
|
||||
|
||||
expect(installer).toMatch(/memory\/lanes/);
|
||||
expect(installer).toMatch(/for\s+[^\n]*lane/);
|
||||
expect(installer).toMatch(/__brain-provision[\s\S]*--lane/);
|
||||
});
|
||||
});
|
||||
@@ -1,373 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Red-first owner-authority resolver contract for #1051.
|
||||
*
|
||||
* Fixtures are operator-agnostic. The HOMELAB owner name belongs in the local
|
||||
* estate policy, never in framework source. Anonymous lookup is intentional:
|
||||
* the ruled owner class is PUBLIC and least-privilege seats may lack read:user.
|
||||
*/
|
||||
|
||||
interface MigrationOwnerResolution {
|
||||
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
||||
readonly reasonCode: string;
|
||||
readonly principal: {
|
||||
readonly name: string;
|
||||
readonly kind: 'durable-human';
|
||||
} | null;
|
||||
readonly authority: {
|
||||
readonly system: 'gitea';
|
||||
readonly endpoint: string;
|
||||
readonly contentType: 'application/json';
|
||||
} | null;
|
||||
}
|
||||
|
||||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface OwnerResolverModule {
|
||||
resolveProviderDurableOwner(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly host: string;
|
||||
readonly requestedOwner: string;
|
||||
},
|
||||
dependencies: {
|
||||
readonly fetch: FetchLike;
|
||||
readonly absentControlName: () => string;
|
||||
},
|
||||
): Promise<MigrationOwnerResolution>;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-owner-resolver.js';
|
||||
|
||||
async function loadResolver(requirement: string): Promise<OwnerResolverModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as OwnerResolverModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: brain owner resolver is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function estateRegistry(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function ownerPolicy(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
estate: 'homelab',
|
||||
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||
controls: {
|
||||
publicIdentity: 'public-control',
|
||||
privateIdentity: 'private-control',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
function publicUser(login: string, active = false): Response {
|
||||
return jsonResponse(200, {
|
||||
id: 42,
|
||||
login,
|
||||
visibility: 'public',
|
||||
active,
|
||||
});
|
||||
}
|
||||
|
||||
function identityFromUrl(input: string | URL | Request): string {
|
||||
const value = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
return decodeURIComponent(new URL(value).pathname.split('/').at(-1) ?? '');
|
||||
}
|
||||
|
||||
function controlledFetch(
|
||||
overrides: Readonly<Record<string, Response>> = {},
|
||||
calls: Array<{ identity: string; authorization: string | null }> = [],
|
||||
): FetchLike {
|
||||
return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const identity = identityFromUrl(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({ identity, authorization: headers.get('authorization') });
|
||||
const override = overrides[identity];
|
||||
if (override !== undefined) return override.clone();
|
||||
if (identity === 'public-control') return publicUser('public-control');
|
||||
if (identity === 'private-control' || identity === 'generated-absent-control') {
|
||||
return jsonResponse(404, { message: 'not found' });
|
||||
}
|
||||
if (identity === 'durable-owner') return publicUser('durable-owner', false);
|
||||
return jsonResponse(404, { message: 'not found' });
|
||||
};
|
||||
}
|
||||
|
||||
describe('provider-backed durable owner resolver', (): void => {
|
||||
it('resolves an allowlisted PUBLIC owner by exact login with public/private/absent controls and ignores active=false', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 provider owner resolution');
|
||||
const calls: Array<{ identity: string; authorization: string | null }> = [];
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch({}, calls),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
verdict: 'resolved',
|
||||
reasonCode: 'owner-verified',
|
||||
principal: { name: 'user:durable-owner', kind: 'durable-human' },
|
||||
authority: {
|
||||
system: 'gitea',
|
||||
endpoint: 'GET /api/v1/users/durable-owner',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
});
|
||||
expect(calls.map((call) => call.identity)).toEqual([
|
||||
'public-control',
|
||||
'private-control',
|
||||
'generated-absent-control',
|
||||
'durable-owner',
|
||||
]);
|
||||
expect(calls.every((call) => call.authorization === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses provider redirects and configures a bounded no-redirect request', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 owner lookup SSRF boundary');
|
||||
const requests: RequestInit[] = [];
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: async (_input, init): Promise<Response> => {
|
||||
requests.push(init ?? {});
|
||||
return new Response(JSON.stringify({ message: 'redirect' }), {
|
||||
status: 302,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
location: 'http://127.0.0.1/internal',
|
||||
},
|
||||
});
|
||||
},
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'not-measured', reasonCode: 'owner-control-invalid' });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.redirect).toBe('manual');
|
||||
expect(requests[0]?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('cancels a chunked provider body as soon as it exceeds the byte ceiling', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 bounded owner response stream');
|
||||
let cancelled = false;
|
||||
const oversized = new ReadableStream<Uint8Array>({
|
||||
start(controller): void {
|
||||
controller.enqueue(new Uint8Array(200_000));
|
||||
controller.enqueue(new Uint8Array(100_000));
|
||||
},
|
||||
cancel(): void {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: async (): Promise<Response> =>
|
||||
new Response(oversized, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
verdict: 'not-measured',
|
||||
reasonCode: 'owner-unexpected-provider-shape',
|
||||
});
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('requires the GLPI standing remediation queue in the local estate policy', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 standing process policy');
|
||||
const raw = JSON.parse(ownerPolicy()) as { estates: Array<Record<string, unknown>> };
|
||||
delete raw.estates[0]?.['standingProcess'];
|
||||
let fetchCalls = 0;
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: JSON.stringify(raw),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: async (): Promise<Response> => {
|
||||
fetchCalls += 1;
|
||||
return publicUser('durable-owner');
|
||||
},
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-policy-invalid' });
|
||||
expect(fetchCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a provider-valid but unlisted principal before provider lookup', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 provider-valid unlisted owner refusal');
|
||||
const calls: Array<{ identity: string; authorization: string | null }> = [];
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:other-public-user',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch({ 'other-public-user': publicUser('other-public-user') }, calls),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-not-allowlisted' });
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['user:durable–owner', 'owner-name-invalid'],
|
||||
[' user:durable-owner ', 'owner-name-invalid'],
|
||||
['user:durable.owner', 'owner-not-allowlisted'],
|
||||
['user:durable owner', 'owner-name-invalid'],
|
||||
['user:durable-owner', 'owner-name-invalid'],
|
||||
['user:be-coder-07@mission-seat', 'owner-name-invalid'],
|
||||
] as const)(
|
||||
'rejects non-canonical, unlisted, or transient-seat presentation %s before lookup',
|
||||
async (name, reasonCode): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 owner allowlist grammar');
|
||||
let fetchCalls = 0;
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: name,
|
||||
},
|
||||
{
|
||||
fetch: async (): Promise<Response> => {
|
||||
fetchCalls += 1;
|
||||
return publicUser('durable-owner');
|
||||
},
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'refused', reasonCode });
|
||||
expect(fetchCalls).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('fails closed as not-resolvable rather than claiming a private-or-absent owner does not exist', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 private/absent ambiguity');
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch({ 'durable-owner': jsonResponse(404, { message: 'hidden' }) }),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
verdict: 'not-measured',
|
||||
reasonCode: 'owner-not-resolvable',
|
||||
principal: null,
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['public control hidden', { 'public-control': jsonResponse(404, {}) }],
|
||||
['public control login mismatch', { 'public-control': publicUser('other') }],
|
||||
['private control unexpectedly public', { 'private-control': publicUser('private-control') }],
|
||||
[
|
||||
'generated absent control unexpectedly resolves',
|
||||
{ 'generated-absent-control': publicUser('generated-absent-control') },
|
||||
],
|
||||
] as const)(
|
||||
'makes the whole result not-measured when %s',
|
||||
async (_caseName, overrides): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 owner resolver controls');
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch(overrides),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
verdict: 'not-measured',
|
||||
reasonCode: 'owner-control-invalid',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,280 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||
import type { MigrationOwnerResolution } from './brain-store.js';
|
||||
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
const LOGIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/;
|
||||
const REQUESTED_OWNER = /^user:(.+)$/;
|
||||
|
||||
const ownerPolicySchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
estates: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
estate: z.string().min(1),
|
||||
laneArchiveOwners: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('provider-user'),
|
||||
login: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.min(1),
|
||||
standingProcess: z
|
||||
.object({
|
||||
kind: z.literal('glpi-queue'),
|
||||
queue: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
||||
})
|
||||
.strict(),
|
||||
controls: z
|
||||
.object({
|
||||
publicIdentity: z.string().min(1),
|
||||
privateIdentity: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const providerUserSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
login: z.string().min(1),
|
||||
visibility: z.literal('public'),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type OwnerFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
function unresolved(reasonCode: string): MigrationOwnerResolution {
|
||||
return {
|
||||
verdict: 'not-measured',
|
||||
reasonCode,
|
||||
principal: null,
|
||||
authority: null,
|
||||
};
|
||||
}
|
||||
|
||||
function refused(reasonCode: string): MigrationOwnerResolution {
|
||||
return {
|
||||
verdict: 'refused',
|
||||
reasonCode,
|
||||
principal: null,
|
||||
authority: null,
|
||||
};
|
||||
}
|
||||
|
||||
function exactCanonicalLogin(value: string): boolean {
|
||||
return value.normalize('NFKC') === value && LOGIN.test(value);
|
||||
}
|
||||
|
||||
async function boundedJson(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (!contentType.toLowerCase().startsWith('application/json')) {
|
||||
throw new Error('owner-unexpected-content-type');
|
||||
}
|
||||
const declared = response.headers.get('content-length');
|
||||
let declaredSize: number | null = null;
|
||||
if (declared !== null) {
|
||||
if (!/^\d+$/.test(declared)) throw new Error('owner-unexpected-provider-shape');
|
||||
declaredSize = Number.parseInt(declared, 10);
|
||||
if (!Number.isSafeInteger(declaredSize) || declaredSize > MAX_BODY_BYTES) {
|
||||
throw new Error('owner-unexpected-provider-shape');
|
||||
}
|
||||
}
|
||||
if (response.body === null) throw new Error('owner-unexpected-provider-shape');
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
total += next.value.byteLength;
|
||||
if (total > MAX_BODY_BYTES) {
|
||||
await reader.cancel('owner response exceeds byte ceiling');
|
||||
throw new Error('owner-unexpected-provider-shape');
|
||||
}
|
||||
chunks.push(next.value);
|
||||
}
|
||||
if (declaredSize !== null && declaredSize !== total) {
|
||||
throw new Error('owner-unexpected-provider-shape');
|
||||
}
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
|
||||
} catch {
|
||||
throw new Error('owner-unexpected-provider-shape');
|
||||
}
|
||||
}
|
||||
|
||||
async function readPublicIdentity(
|
||||
origin: string,
|
||||
identity: string,
|
||||
fetchImpl: OwnerFetch,
|
||||
): Promise<{ readonly status: number; readonly user: unknown }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(`${origin}/api/v1/users/${encodeURIComponent(identity)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'mosaic-brain-owner/1',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
} catch {
|
||||
throw new Error('owner-provider-unavailable');
|
||||
}
|
||||
return { status: response.status, user: await boundedJson(response) };
|
||||
}
|
||||
|
||||
function publicIdentityMatches(value: unknown, identity: string): boolean {
|
||||
const parsed = providerUserSchema.safeParse(value);
|
||||
return parsed.success && parsed.data.login === identity;
|
||||
}
|
||||
|
||||
export interface BrainOwnerPolicyBinding {
|
||||
readonly brainNamespace: string;
|
||||
readonly publicControl: string;
|
||||
readonly privateControl: string;
|
||||
readonly standingQueue: string;
|
||||
}
|
||||
|
||||
export function resolveBrainOwnerPolicy(
|
||||
ownerPolicySource: string,
|
||||
estate: string,
|
||||
): BrainOwnerPolicyBinding | undefined {
|
||||
let rawPolicy: unknown;
|
||||
try {
|
||||
rawPolicy = JSON.parse(ownerPolicySource);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const policy = ownerPolicySchema.safeParse(rawPolicy);
|
||||
if (!policy.success) return undefined;
|
||||
const estatePolicies = policy.data.estates.filter(
|
||||
(candidate): boolean => candidate.estate === estate,
|
||||
);
|
||||
if (estatePolicies.length !== 1) return undefined;
|
||||
const estatePolicy = estatePolicies[0];
|
||||
if (estatePolicy === undefined || estatePolicy.laneArchiveOwners.length !== 1) return undefined;
|
||||
const brainNamespace = estatePolicy.laneArchiveOwners[0]?.login;
|
||||
if (brainNamespace === undefined || !exactCanonicalLogin(brainNamespace)) return undefined;
|
||||
return {
|
||||
brainNamespace,
|
||||
publicControl: estatePolicy.controls.publicIdentity,
|
||||
privateControl: estatePolicy.controls.privateIdentity,
|
||||
standingQueue: estatePolicy.standingProcess.queue,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRequestedOwner(requestedOwner: string): string | null {
|
||||
if (requestedOwner.normalize('NFKC') !== requestedOwner) return null;
|
||||
const match = REQUESTED_OWNER.exec(requestedOwner);
|
||||
const login = match?.[1];
|
||||
if (login === undefined || !exactCanonicalLogin(login)) return null;
|
||||
return login;
|
||||
}
|
||||
|
||||
export async function resolveProviderDurableOwner(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly host: string;
|
||||
readonly requestedOwner: string;
|
||||
},
|
||||
dependencies: {
|
||||
readonly fetch: OwnerFetch;
|
||||
readonly absentControlName: () => string;
|
||||
},
|
||||
): Promise<MigrationOwnerResolution> {
|
||||
const requestedLogin = parseRequestedOwner(input.requestedOwner);
|
||||
if (requestedLogin === null) return refused('owner-name-invalid');
|
||||
|
||||
const target = parseCredentialEstateRegistry(input.estateRegistrySource).resolveByHost(
|
||||
input.host,
|
||||
);
|
||||
if (target === undefined) return refused('estate-host-unmapped');
|
||||
|
||||
const policy = resolveBrainOwnerPolicy(input.ownerPolicySource, target.estate);
|
||||
if (policy === undefined) return refused('owner-policy-invalid');
|
||||
if (policy.brainNamespace !== requestedLogin) return refused('owner-not-allowlisted');
|
||||
|
||||
const publicControl = policy.publicControl;
|
||||
const privateControl = policy.privateControl;
|
||||
const absentControl = dependencies.absentControlName();
|
||||
if (
|
||||
!exactCanonicalLogin(publicControl) ||
|
||||
!exactCanonicalLogin(privateControl) ||
|
||||
!exactCanonicalLogin(absentControl) ||
|
||||
new Set([publicControl, privateControl, absentControl, requestedLogin]).size !== 4
|
||||
) {
|
||||
return refused('owner-policy-invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
const publicResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
publicControl,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (publicResult.status !== 200 || !publicIdentityMatches(publicResult.user, publicControl)) {
|
||||
return unresolved('owner-control-invalid');
|
||||
}
|
||||
|
||||
const privateResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
privateControl,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (privateResult.status !== 404) return unresolved('owner-control-invalid');
|
||||
|
||||
const absentResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
absentControl,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (absentResult.status !== 404) return unresolved('owner-control-invalid');
|
||||
|
||||
const ownerResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
requestedLogin,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (ownerResult.status === 401 || ownerResult.status === 403 || ownerResult.status === 404) {
|
||||
return unresolved('owner-not-resolvable');
|
||||
}
|
||||
if (ownerResult.status !== 200) return unresolved('owner-provider-unavailable');
|
||||
if (!publicIdentityMatches(ownerResult.user, requestedLogin)) {
|
||||
return unresolved('owner-provider-identity-mismatch');
|
||||
}
|
||||
return {
|
||||
verdict: 'resolved',
|
||||
reasonCode: 'owner-verified',
|
||||
principal: { name: `user:${requestedLogin}`, kind: 'durable-human' },
|
||||
authority: {
|
||||
system: 'gitea',
|
||||
endpoint: `GET /api/v1/users/${requestedLogin}`,
|
||||
contentType: 'application/json',
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const reason = error instanceof Error ? error.message : 'owner-provider-unavailable';
|
||||
if (reason === 'owner-unexpected-content-type') return unresolved(reason);
|
||||
if (reason === 'owner-unexpected-provider-shape') return unresolved(reason);
|
||||
return unresolved('owner-provider-unavailable');
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface BrainProvisionCommandModule {
|
||||
readonly BRAIN_PROVISION_COMMAND: string;
|
||||
registerBrainProvisionCommand(program: Command): void;
|
||||
executeBrainProvisionCommand(
|
||||
options: {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly targetUrl: string;
|
||||
readonly owner: string;
|
||||
readonly lane: string;
|
||||
readonly sourceRoot?: string;
|
||||
readonly brainRoot?: string;
|
||||
readonly ownerPolicy?: string;
|
||||
readonly registry?: string;
|
||||
},
|
||||
dependencies: {
|
||||
readonly run: () => never;
|
||||
readonly fetch: typeof fetch;
|
||||
readonly absentControlName: () => string;
|
||||
},
|
||||
): Promise<{
|
||||
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||
readonly reasonCode: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-provision-command.js';
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadCommand(requirement: string): Promise<BrainProvisionCommandModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as BrainProvisionCommandModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: brain provision command is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function tempRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-command-'));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('internal P7 brain provision command', (): void => {
|
||||
it('registers only explicit non-secret contract inputs and no credential/token lookup switches', async (): Promise<void> => {
|
||||
const module = await loadCommand('MB-REQ-03 broker-only provision command');
|
||||
const program = new Command();
|
||||
module.registerBrainProvisionCommand(program);
|
||||
const command = program.commands.find(
|
||||
(candidate) => candidate.name() === module.BRAIN_PROVISION_COMMAND,
|
||||
);
|
||||
|
||||
expect(command).toBeDefined();
|
||||
const flags = command?.options.map((option) => option.flags) ?? [];
|
||||
expect(flags.join(' ')).toContain('--identity');
|
||||
expect(flags.join(' ')).toContain('--target-url');
|
||||
expect(flags.join(' ')).toContain('--refusal-identity');
|
||||
expect(flags.join(' ')).toContain('--owner-policy');
|
||||
expect(flags.join(' ')).toContain('--owner');
|
||||
expect(flags.join(' ')).toContain('--lane');
|
||||
expect(flags.join(' ')).not.toMatch(/token|password|authorization|grant-authority/i);
|
||||
});
|
||||
|
||||
it('is registered by the shipped CLI', async (): Promise<void> => {
|
||||
const module = await loadCommand('MB-REQ-10 shipped P7 command');
|
||||
const cli = readFileSync(join(process.cwd(), 'src', 'cli.ts'), 'utf8');
|
||||
|
||||
expect(cli).toContain('registerBrainProvisionCommand');
|
||||
expect(cli).toContain(`registerBrainProvisionCommand(program)`);
|
||||
expect(module.BRAIN_PROVISION_COMMAND).toBe('__brain-provision');
|
||||
});
|
||||
|
||||
it('fails closed before commands when the local owner policy is absent', async (): Promise<void> => {
|
||||
const module = await loadCommand('MB-REQ-09 owner policy required');
|
||||
const root = tempRoot();
|
||||
const home = join(root, 'home');
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
mkdirSync(join(mosaicHome, 'cred'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(mosaicHome, 'cred', 'estates.json'),
|
||||
JSON.stringify({ version: 1, estates: [] }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
let commands = 0;
|
||||
|
||||
const result = await module.executeBrainProvisionCommand(
|
||||
{
|
||||
mosaicHome,
|
||||
home,
|
||||
identity: 'seat-a',
|
||||
refusalIdentity: 'outside-seat',
|
||||
targetUrl: 'https://git.example.invalid/example/stack.git',
|
||||
owner: 'user:durable-owner',
|
||||
lane: 'lane-a',
|
||||
},
|
||||
{
|
||||
run: (): never => {
|
||||
commands += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
fetch,
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'failed', reasonCode: 'owner-policy-unavailable' });
|
||||
expect(commands).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { readBrainConfigSecure } from './brain-secure-config.js';
|
||||
import { provisionBrain, type ProvisionResult } from './brain-provision.js';
|
||||
import { systemCommandRunner, type CommandRunner } from './brain-store-runtime.js';
|
||||
import type { OwnerFetch } from './brain-owner-resolver.js';
|
||||
|
||||
export const BRAIN_PROVISION_COMMAND = '__brain-provision';
|
||||
|
||||
interface BrainProvisionCommandOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly targetUrl: string;
|
||||
readonly owner: string;
|
||||
readonly lane: string;
|
||||
readonly sourceRoot?: string;
|
||||
readonly brainRoot?: string;
|
||||
readonly ownerPolicy?: string;
|
||||
readonly registry?: string;
|
||||
}
|
||||
|
||||
interface BrainProvisionCommandDependencies {
|
||||
readonly run: CommandRunner;
|
||||
readonly fetch: OwnerFetch;
|
||||
readonly absentControlName: () => string;
|
||||
}
|
||||
|
||||
function configFailure(reasonCode: string): ProvisionResult {
|
||||
return {
|
||||
status: 'failed',
|
||||
reasonCode,
|
||||
findings: [{ code: `brain-${reasonCode}`, reasonCode }],
|
||||
owner: null,
|
||||
migration: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeBrainProvisionCommand(
|
||||
options: BrainProvisionCommandOptions,
|
||||
dependencies: BrainProvisionCommandDependencies,
|
||||
): Promise<ProvisionResult> {
|
||||
const registry = options.registry ?? join(options.mosaicHome, 'cred', 'estates.json');
|
||||
const ownerPolicy = options.ownerPolicy ?? join(options.mosaicHome, 'brain', 'owners.json');
|
||||
let estateRegistrySource: string;
|
||||
try {
|
||||
estateRegistrySource = readBrainConfigSecure(registry, options.mosaicHome);
|
||||
} catch {
|
||||
return configFailure('estate-registry-unavailable');
|
||||
}
|
||||
let ownerPolicySource: string;
|
||||
try {
|
||||
ownerPolicySource = readBrainConfigSecure(ownerPolicy, options.mosaicHome);
|
||||
} catch {
|
||||
return configFailure('owner-policy-unavailable');
|
||||
}
|
||||
|
||||
try {
|
||||
return await provisionBrain(
|
||||
{
|
||||
estateRegistrySource,
|
||||
ownerPolicySource,
|
||||
targetGitUrl: options.targetUrl,
|
||||
requestedOwner: options.owner,
|
||||
identity: options.identity,
|
||||
refusalIdentity: options.refusalIdentity,
|
||||
root: options.brainRoot ?? join(options.home, '.mosaic'),
|
||||
sourceRoot: options.sourceRoot ?? join(options.mosaicHome, 'memory'),
|
||||
seat: options.identity,
|
||||
lane: options.lane,
|
||||
laneActive: false,
|
||||
},
|
||||
dependencies,
|
||||
);
|
||||
} catch {
|
||||
return configFailure('brain-provision-exception');
|
||||
}
|
||||
}
|
||||
|
||||
export function registerBrainProvisionCommand(program: Command): void {
|
||||
program
|
||||
.command(BRAIN_PROVISION_COMMAND, { hidden: true })
|
||||
.description('Internal installer P7 durable-brain provisioner')
|
||||
.requiredOption('--identity <name>', 'explicit fleet identity')
|
||||
.requiredOption('--target-url <url>', 'configured target git URL')
|
||||
.requiredOption('--refusal-identity <name>', 'explicit out-of-estate negative control')
|
||||
.requiredOption('--owner <owner>', 'policy-bound durable owner candidate')
|
||||
.requiredOption('--lane <name>', 'source lane to migrate')
|
||||
.option('--mosaic-home <path>', 'installed Mosaic home')
|
||||
.option('--home <path>', 'principal home')
|
||||
.option('--source-root <path>', 'legacy memory root')
|
||||
.option('--brain-root <path>', 'per-estate brain checkout root')
|
||||
.option('--owner-policy <path>', 'durable-owner allowlist policy')
|
||||
.option('--registry <path>', 'estate registry path')
|
||||
.action(async (raw: Record<string, string | undefined>): Promise<void> => {
|
||||
const home = raw['home'] ?? homedir();
|
||||
const mosaicHome =
|
||||
raw['mosaicHome'] ?? process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic');
|
||||
const result = await executeBrainProvisionCommand(
|
||||
{
|
||||
mosaicHome,
|
||||
home,
|
||||
identity: raw['identity']!,
|
||||
targetUrl: raw['targetUrl']!,
|
||||
refusalIdentity: raw['refusalIdentity']!,
|
||||
owner: raw['owner']!,
|
||||
lane: raw['lane']!,
|
||||
...(raw['sourceRoot'] === undefined ? {} : { sourceRoot: raw['sourceRoot'] }),
|
||||
...(raw['brainRoot'] === undefined ? {} : { brainRoot: raw['brainRoot'] }),
|
||||
...(raw['ownerPolicy'] === undefined ? {} : { ownerPolicy: raw['ownerPolicy'] }),
|
||||
...(raw['registry'] === undefined ? {} : { registry: raw['registry'] }),
|
||||
},
|
||||
{
|
||||
run: systemCommandRunner,
|
||||
fetch,
|
||||
absentControlName: (): string => `mosaic-absent-${randomUUID()}`,
|
||||
},
|
||||
);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
status: result.status,
|
||||
reasonCode: result.reasonCode,
|
||||
findings: result.findings,
|
||||
owner: result.owner,
|
||||
migration: result.migration,
|
||||
})}\n`,
|
||||
);
|
||||
if (result.status !== 'provisioned') process.exitCode = result.status === 'blocked' ? 30 : 20;
|
||||
});
|
||||
}
|
||||
@@ -1,561 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface CommandRequest {
|
||||
readonly program: 'git' | 'mosaic';
|
||||
readonly args: readonly string[];
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
readonly status: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
type CommandRunner = (request: CommandRequest) => CommandResult;
|
||||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface ProvisionResult {
|
||||
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||
readonly reasonCode: string;
|
||||
readonly findings: readonly { code: string; reasonCode: string | null }[];
|
||||
readonly owner: {
|
||||
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
||||
readonly reasonCode: string;
|
||||
} | null;
|
||||
readonly migration: {
|
||||
readonly status: 'migrated' | 'reported' | 'failed';
|
||||
readonly reported: readonly { path: string; reason: string }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ProvisionModule {
|
||||
provisionBrain(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly requestedOwner: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly root: string;
|
||||
readonly sourceRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
},
|
||||
dependencies: {
|
||||
readonly run: CommandRunner;
|
||||
readonly fetch: FetchLike;
|
||||
readonly absentControlName: () => string;
|
||||
readonly approveMigrationContent?: (path: string, content: Uint8Array) => boolean;
|
||||
},
|
||||
): Promise<ProvisionResult>;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-provision.js';
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadProvisioner(requirement: string): Promise<ProvisionModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as ProvisionModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: brain provisioner is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function tempRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-provision-'));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function estateRegistry(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function ownerPolicy(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
estate: 'homelab',
|
||||
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||
controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function validateResult(
|
||||
outcome: 'ok' | 'refused' | 'indeterminate',
|
||||
reasonCode: string,
|
||||
identity = 'seat-a',
|
||||
): string {
|
||||
const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30;
|
||||
return JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation: 'validate',
|
||||
outcome,
|
||||
exitCode,
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity,
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'durable-owner/mosaic-brain',
|
||||
},
|
||||
mutation: 'none',
|
||||
reason: { code: reasonCode, message: 'non-secret' },
|
||||
evidence: {
|
||||
providerIdentity:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
login: identity,
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
repositoryPermission:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
requested: 'write',
|
||||
effective: 'write',
|
||||
endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
writeDifferential:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
state: 'can-write',
|
||||
credentialBinding: 'same-resolution',
|
||||
transportPrincipal: identity,
|
||||
authenticatedReceivePack: 'advertised',
|
||||
readOnlyControl: {
|
||||
identity: 'read-control',
|
||||
providerPermission: 'read',
|
||||
receivePack: 'refused',
|
||||
},
|
||||
unauthenticatedReceivePack: 'refused',
|
||||
artifactCreated: false,
|
||||
proves: 'non-secret evidence',
|
||||
doesNotProve: 'branch update acceptance',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
audit: { journalId: 'opaque', state: 'sealed' },
|
||||
});
|
||||
}
|
||||
|
||||
function publicUser(login: string): Response {
|
||||
return new Response(JSON.stringify({ id: 1, login, visibility: 'public', active: false }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function ownerFetch(ownerStatus = 200): FetchLike {
|
||||
return async (input): Promise<Response> => {
|
||||
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
const identity = decodeURIComponent(new URL(raw).pathname.split('/').at(-1) ?? '');
|
||||
if (identity === 'public-control') return publicUser(identity);
|
||||
if (identity === 'private-control' || identity === 'generated-absent-control') {
|
||||
return new Response(JSON.stringify({ message: 'hidden or absent' }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (identity === 'durable-owner' && ownerStatus === 200) return publicUser(identity);
|
||||
return new Response(JSON.stringify({ message: 'hidden or absent' }), {
|
||||
status: ownerStatus,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function baseInput(root: string): {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly requestedOwner: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly root: string;
|
||||
readonly sourceRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
} {
|
||||
return {
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
targetGitUrl: 'https://git.example.invalid/example/stack.git',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
identity: 'seat-a',
|
||||
refusalIdentity: 'outside-seat',
|
||||
root: join(root, 'brain'),
|
||||
sourceRoot: join(root, 'local-memory'),
|
||||
seat: 'seat-a',
|
||||
lane: 'lane-a',
|
||||
laneActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('P7 brain provisioning orchestration', (): void => {
|
||||
it('requires the P5 write-capability postcondition and never grants or clones on refusal', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-10 P5 before P7');
|
||||
const root = tempRoot();
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = await provisioner.provisionBrain(baseInput(root), {
|
||||
run: (request): CommandResult => {
|
||||
requests.push(request);
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
},
|
||||
fetch: ownerFetch(),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
reasonCode: 'credential-postcondition-failed',
|
||||
});
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.program).toBe('mosaic');
|
||||
expect(requests[0]?.args.slice(0, 3)).toEqual(['cred', 'validate', 'seat-a']);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
expect(requests.some((request) => request.args.includes('clone'))).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks before clone when the out-of-estate Git and API axes disagree', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-05 P7 refusal control gate');
|
||||
const root = tempRoot();
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = await provisioner.provisionBrain(baseInput(root), {
|
||||
run: (request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic' && request.args[2] === 'outside-seat') {
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
if (request.program === 'mosaic') {
|
||||
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||
}
|
||||
if (request.args.includes('ls-remote')) {
|
||||
return { status: 0, stdout: 'refs are visible', stderr: '' };
|
||||
}
|
||||
return { status: 99, stdout: '', stderr: 'unexpected command' };
|
||||
},
|
||||
fetch: ownerFetch(),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
reasonCode: 'refusal-control-failed',
|
||||
});
|
||||
expect(requests.some((request) => request.args.includes('clone'))).toBe(false);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks before skeleton publication when the existing checkout is dirty', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-06 dirty checkout publication gate');
|
||||
const root = tempRoot();
|
||||
const input = baseInput(root);
|
||||
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = await provisioner.provisionBrain(input, {
|
||||
run: (request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic') {
|
||||
return request.args[2] === 'outside-seat'
|
||||
? {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
}
|
||||
: { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||
}
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('ls-remote')) {
|
||||
return {
|
||||
status: 128,
|
||||
stdout: '',
|
||||
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('remote get-url origin')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('branch --show-current')) {
|
||||
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('status --porcelain')) {
|
||||
return { status: 0, stdout: '?? .gitignore\n', stderr: '' };
|
||||
}
|
||||
return { status: 99, stdout: '', stderr: 'unexpected publication command' };
|
||||
},
|
||||
fetch: ownerFetch(),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
reasonCode: 'brain-postcondition-failed',
|
||||
});
|
||||
expect(requests.some((request) => request.args.includes('commit'))).toBe(false);
|
||||
expect(requests.some((request) => request.args.includes('push'))).toBe(false);
|
||||
});
|
||||
|
||||
it('clones, seeds, resolves owner, migrates, pushes on each write, and archives source only after reachability', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-07 complete migration transaction');
|
||||
const root = tempRoot();
|
||||
const input = baseInput(root);
|
||||
mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||
const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||
writeFileSync(source, 'durable finding\n');
|
||||
const requests: CommandRequest[] = [];
|
||||
let commitOrdinal = 0;
|
||||
let approvedPaths: string[] = [];
|
||||
let privateAtClone = false;
|
||||
const runner: CommandRunner = (request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic') {
|
||||
if (request.args[2] === 'outside-seat') {
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||
}
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('ls-remote')) {
|
||||
return {
|
||||
status: 128,
|
||||
stdout: '',
|
||||
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
if (request.args[0] === 'clone') {
|
||||
privateAtClone = existsSync(input.root) && (lstatSync(input.root).mode & 0o077) === 0;
|
||||
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('read-tree')) {
|
||||
approvedPaths = [];
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('hash-object')) {
|
||||
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
if (command.includes('update-index')) {
|
||||
const path = request.args.at(-1);
|
||||
if (path !== undefined) approvedPaths.push(path);
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) {
|
||||
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
if (command.includes('diff --cached --quiet')) {
|
||||
return { status: 1, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('diff-tree')) {
|
||||
return { status: 0, stdout: `${approvedPaths.join('\0')}\0`, stderr: '' };
|
||||
}
|
||||
if (command.includes('show -s')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'seat-a\[email protected]\0seat-a\[email protected]\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('remote get-url origin')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('branch --show-current')) {
|
||||
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('status --porcelain')) {
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse HEAD')) {
|
||||
commitOrdinal += 1;
|
||||
return {
|
||||
status: 0,
|
||||
stdout: `${commitOrdinal === 1 ? 'a' : 'c'.repeat(1)}`.repeat(40) + '\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('rev-parse origin/main')) {
|
||||
const value = commitOrdinal === 1 ? 'b' : 'd';
|
||||
return { status: 0, stdout: `${value.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
|
||||
const result = await provisioner.provisionBrain(input, {
|
||||
run: runner,
|
||||
fetch: ownerFetch(),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
approveMigrationContent: (): boolean => true,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'provisioned',
|
||||
reasonCode: 'brain-provisioned',
|
||||
owner: { verdict: 'resolved', reasonCode: 'owner-verified' },
|
||||
migration: { status: 'reported' },
|
||||
});
|
||||
expect(privateAtClone).toBe(true);
|
||||
expect(existsSync(source)).toBe(true);
|
||||
const imported = result.migration?.reported ?? [];
|
||||
expect(imported).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: source, reason: expect.stringMatching(/retained/i) }),
|
||||
]),
|
||||
);
|
||||
const laneImports = join(input.root, 'lanes', 'lane-a', 'findings', 'imports');
|
||||
const archiveImports = join(input.root, 'archives', 'imports', 'lane');
|
||||
expect(existsSync(laneImports)).toBe(true);
|
||||
expect(existsSync(archiveImports)).toBe(true);
|
||||
expect(
|
||||
requests.filter((request) => request.program === 'git' && request.args.includes('push')),
|
||||
).toHaveLength(2);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps every source and reports the owner ambiguity when the public owner cannot be resolved', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-07 owner-blocked detection/reporting');
|
||||
const root = tempRoot();
|
||||
const input = baseInput(root);
|
||||
mkdirSync(input.root, { recursive: true });
|
||||
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||
mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||
const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||
writeFileSync(source, 'retain me\n');
|
||||
let commitOrdinal = 0;
|
||||
|
||||
const result = await provisioner.provisionBrain(input, {
|
||||
run: (request): CommandResult => {
|
||||
if (request.program === 'mosaic') {
|
||||
if (request.args[2] === 'outside-seat') {
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||
}
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('ls-remote')) {
|
||||
return {
|
||||
status: 128,
|
||||
stdout: '',
|
||||
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('remote get-url origin')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('branch --show-current')) {
|
||||
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('status --porcelain')) {
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse HEAD')) {
|
||||
commitOrdinal += 1;
|
||||
return { status: 0, stdout: `${'a'.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse origin/main')) {
|
||||
return { status: 0, stdout: `${'b'.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
fetch: ownerFetch(404),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
reasonCode: 'owner-not-resolvable',
|
||||
owner: { verdict: 'not-measured', reasonCode: 'owner-not-resolvable' },
|
||||
migration: { status: 'reported' },
|
||||
});
|
||||
expect(readFileSync(source, 'utf8')).toBe('retain me\n');
|
||||
expect(result.migration?.reported).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: source, reason: expect.stringMatching(/owner/i) }),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/);
|
||||
expect(commitOrdinal).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { parseRequestedOwner, resolveProviderDurableOwner } from './brain-owner-resolver.js';
|
||||
import {
|
||||
createBrainSkeleton,
|
||||
deriveBrainTarget,
|
||||
discoverBrainMigration,
|
||||
ensureBrainRootPrivate,
|
||||
migrateBrainState,
|
||||
type MigrationResult,
|
||||
type MigrationOwnerResolution,
|
||||
type MigrationPublishEntry,
|
||||
} from './brain-store.js';
|
||||
import {
|
||||
collectBrainDoctorReport,
|
||||
collectBrainRefusalControl,
|
||||
publishBrainPaths,
|
||||
type CommandRequest,
|
||||
type CommandResult,
|
||||
type CommandRunner,
|
||||
} from './brain-store-runtime.js';
|
||||
import type { OwnerFetch } from './brain-owner-resolver.js';
|
||||
|
||||
export interface ProvisionResult {
|
||||
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||
readonly reasonCode: string;
|
||||
readonly findings: readonly {
|
||||
readonly code: string;
|
||||
readonly reasonCode: string | null;
|
||||
}[];
|
||||
readonly owner: Pick<MigrationOwnerResolution, 'verdict' | 'reasonCode'> | null;
|
||||
readonly migration: Pick<MigrationResult, 'status' | 'reported'> | null;
|
||||
}
|
||||
|
||||
function commandEnv(identity: string): Readonly<Record<string, string>> {
|
||||
return { MOSAIC_GIT_IDENTITY: identity, GIT_TERMINAL_PROMPT: '0' };
|
||||
}
|
||||
|
||||
function findingView(
|
||||
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||
): readonly { readonly code: string; readonly reasonCode: string | null }[] {
|
||||
return findings.map((finding): { readonly code: string; readonly reasonCode: string | null } => ({
|
||||
code: finding.code,
|
||||
reasonCode: finding.reasonCode,
|
||||
}));
|
||||
}
|
||||
|
||||
function blocked(
|
||||
reasonCode: string,
|
||||
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||
owner: MigrationOwnerResolution | null = null,
|
||||
migration: MigrationResult | null = null,
|
||||
): ProvisionResult {
|
||||
return {
|
||||
status: 'blocked',
|
||||
reasonCode,
|
||||
findings: findingView(findings),
|
||||
owner: owner === null ? null : { verdict: owner.verdict, reasonCode: owner.reasonCode },
|
||||
migration:
|
||||
migration === null ? null : { status: migration.status, reported: migration.reported },
|
||||
};
|
||||
}
|
||||
|
||||
function failed(
|
||||
reasonCode: string,
|
||||
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||
owner: MigrationOwnerResolution | null = null,
|
||||
migration: MigrationResult | null = null,
|
||||
): ProvisionResult {
|
||||
return {
|
||||
...blocked(reasonCode, findings, owner, migration),
|
||||
status: 'failed',
|
||||
};
|
||||
}
|
||||
|
||||
export async function provisionBrain(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly requestedOwner: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly root: string;
|
||||
readonly sourceRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
},
|
||||
dependencies: {
|
||||
readonly run: CommandRunner;
|
||||
readonly fetch: OwnerFetch;
|
||||
readonly absentControlName: () => string;
|
||||
readonly approveMigrationContent?: (path: string, content: Uint8Array) => boolean;
|
||||
},
|
||||
): Promise<ProvisionResult> {
|
||||
const brainNamespace = parseRequestedOwner(input.requestedOwner);
|
||||
if (brainNamespace === null) return blocked('owner-name-invalid', []);
|
||||
const target = deriveBrainTarget(input.estateRegistrySource, input.targetGitUrl, brainNamespace);
|
||||
if (existsSync(input.root)) {
|
||||
try {
|
||||
ensureBrainRootPrivate(input.root);
|
||||
} catch {
|
||||
return blocked('brain-root-permissions-unsafe', []);
|
||||
}
|
||||
}
|
||||
const doctorInput = {
|
||||
registrySource: input.estateRegistrySource,
|
||||
targetGitUrl: input.targetGitUrl,
|
||||
brainNamespace,
|
||||
identity: input.identity,
|
||||
root: input.root,
|
||||
};
|
||||
let report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||
if (report.access.outcome !== 'ok') {
|
||||
return blocked('credential-postcondition-failed', report.findings);
|
||||
}
|
||||
|
||||
const refusalControl = collectBrainRefusalControl(
|
||||
{
|
||||
registrySource: input.estateRegistrySource,
|
||||
targetGitUrl: input.targetGitUrl,
|
||||
brainNamespace,
|
||||
refusalIdentity: input.refusalIdentity,
|
||||
},
|
||||
dependencies.run,
|
||||
);
|
||||
if (!refusalControl.observed) {
|
||||
return blocked('refusal-control-failed', [
|
||||
...report.findings,
|
||||
{
|
||||
code: 'brain-refusal-control-indeterminate',
|
||||
reasonCode: refusalControl.reasonCode,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const owner = await resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: input.estateRegistrySource,
|
||||
ownerPolicySource: input.ownerPolicySource,
|
||||
host: target.host,
|
||||
requestedOwner: input.requestedOwner,
|
||||
},
|
||||
{
|
||||
fetch: dependencies.fetch,
|
||||
absentControlName: dependencies.absentControlName,
|
||||
},
|
||||
);
|
||||
if (owner.verdict !== 'resolved') {
|
||||
const plan = discoverBrainMigration(
|
||||
{
|
||||
sourceRoot: input.sourceRoot,
|
||||
brainRoot: input.root,
|
||||
seat: input.seat,
|
||||
lane: input.lane,
|
||||
laneActive: input.laneActive,
|
||||
},
|
||||
(): MigrationOwnerResolution => owner,
|
||||
);
|
||||
const migration = migrateBrainState(
|
||||
plan,
|
||||
(): never => {
|
||||
throw new Error('blocked owner cannot publish');
|
||||
},
|
||||
input.root,
|
||||
);
|
||||
return blocked(owner.reasonCode, report.findings, owner, migration);
|
||||
}
|
||||
|
||||
if (report.findings.some((finding): boolean => finding.code === 'brain-clone-missing')) {
|
||||
try {
|
||||
ensureBrainRootPrivate(input.root);
|
||||
} catch {
|
||||
return failed('brain-root-permissions-unsafe', report.findings);
|
||||
}
|
||||
const clone: CommandRequest = {
|
||||
program: 'git',
|
||||
args: ['clone', '--branch', 'main', '--single-branch', target.cloneUrl, input.root],
|
||||
env: commandEnv(input.identity),
|
||||
};
|
||||
const cloneResult: CommandResult = dependencies.run(clone);
|
||||
if (cloneResult.status !== 0) return failed('brain-clone-failed', report.findings);
|
||||
try {
|
||||
ensureBrainRootPrivate(input.root);
|
||||
} catch {
|
||||
return failed('brain-root-permissions-unsafe', report.findings);
|
||||
}
|
||||
report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||
}
|
||||
|
||||
const blockingCloneFindings = report.findings.filter(
|
||||
(finding): boolean =>
|
||||
finding.code === 'brain-clone-missing' ||
|
||||
finding.code === 'brain-not-git-repository' ||
|
||||
finding.code === 'brain-remote-mismatch' ||
|
||||
finding.code === 'brain-branch-mismatch' ||
|
||||
finding.code === 'brain-uncommitted-state' ||
|
||||
finding.code === 'brain-git-state-indeterminate' ||
|
||||
finding.code === 'brain-root-permissions-unsafe' ||
|
||||
finding.code.startsWith('brain-write-access-'),
|
||||
);
|
||||
if (blockingCloneFindings.length > 0) {
|
||||
return blocked('brain-postcondition-failed', report.findings);
|
||||
}
|
||||
|
||||
let skeletonEntries: readonly MigrationPublishEntry[];
|
||||
try {
|
||||
const skeleton = createBrainSkeleton(input.root);
|
||||
skeletonEntries = skeleton.publicationEntries;
|
||||
} catch {
|
||||
return failed('brain-skeleton-failed', report.findings);
|
||||
}
|
||||
if (skeletonEntries.length > 0) {
|
||||
try {
|
||||
const evidence = publishBrainPaths(
|
||||
{
|
||||
root: input.root,
|
||||
identity: input.identity,
|
||||
entries: skeletonEntries,
|
||||
message: 'chore: seed durable brain layout',
|
||||
},
|
||||
dependencies.run,
|
||||
);
|
||||
if (!evidence.reachable) return failed('brain-skeleton-not-reachable', report.findings);
|
||||
} catch {
|
||||
return failed('brain-skeleton-publish-failed', report.findings);
|
||||
}
|
||||
}
|
||||
|
||||
const plan = discoverBrainMigration(
|
||||
{
|
||||
sourceRoot: input.sourceRoot,
|
||||
brainRoot: input.root,
|
||||
seat: input.seat,
|
||||
lane: input.lane,
|
||||
laneActive: input.laneActive,
|
||||
},
|
||||
(): MigrationOwnerResolution => owner,
|
||||
dependencies.approveMigrationContent,
|
||||
);
|
||||
const migration = migrateBrainState(
|
||||
plan,
|
||||
(brainRoot: string, entries: readonly MigrationPublishEntry[]) =>
|
||||
publishBrainPaths(
|
||||
{
|
||||
root: brainRoot,
|
||||
identity: input.identity,
|
||||
entries,
|
||||
message: `migrate: archive ${input.lane} working memory`,
|
||||
},
|
||||
dependencies.run,
|
||||
),
|
||||
input.root,
|
||||
);
|
||||
|
||||
if (migration.status === 'failed') {
|
||||
return failed('brain-migration-publish-failed', report.findings, owner, migration);
|
||||
}
|
||||
|
||||
report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||
if (report.findings.length > 0) {
|
||||
return blocked('brain-final-postcondition-failed', report.findings, owner, migration);
|
||||
}
|
||||
return {
|
||||
status: 'provisioned',
|
||||
reasonCode: 'brain-provisioned',
|
||||
findings: [],
|
||||
owner: { verdict: owner.verdict, reasonCode: owner.reasonCode },
|
||||
migration: { status: migration.status, reported: migration.reported },
|
||||
};
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface SecureConfigModule {
|
||||
readBrainConfigSecure(path: string, root: string): string;
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadSecureConfig(): Promise<SecureConfigModule> {
|
||||
try {
|
||||
return (await import('./brain-secure-config.js')) as SecureConfigModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`MB-REQ-06 secure brain config reader is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(): { readonly root: string; readonly directory: string; readonly file: string } {
|
||||
const outer = mkdtempSync(join(tmpdir(), 'mosaic-brain-secure-config-'));
|
||||
roots.push(outer);
|
||||
const root = join(outer, 'mosaic');
|
||||
const directory = join(root, 'brain');
|
||||
const file = join(directory, 'owners.json');
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(file, '{"version":1}\n', { mode: 0o600 });
|
||||
return { root, directory, file };
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
vi.restoreAllMocks();
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('security-critical brain configuration reads', (): void => {
|
||||
it('reads a principal-owned non-writable regular file through the secure descriptor path', async (): Promise<void> => {
|
||||
const secure = await loadSecureConfig();
|
||||
const config = fixture();
|
||||
|
||||
expect(secure.readBrainConfigSecure(config.file, config.root)).toBe('{"version":1}\n');
|
||||
});
|
||||
|
||||
it('rejects a managed root owned by a UID other than the running principal', async (): Promise<void> => {
|
||||
const secure = await loadSecureConfig();
|
||||
const config = fixture();
|
||||
if (typeof process.getuid !== 'function') throw new Error('test requires POSIX getuid');
|
||||
const processWithUid = process as typeof process & { getuid: () => number };
|
||||
const actualUid = processWithUid.getuid();
|
||||
vi.spyOn(processWithUid, 'getuid').mockReturnValue(actualUid + 1);
|
||||
|
||||
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
|
||||
/config-ancestor-owner-unsafe/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a group/world-writable policy file', async (): Promise<void> => {
|
||||
const secure = await loadSecureConfig();
|
||||
const config = fixture();
|
||||
chmodSync(config.file, 0o666);
|
||||
|
||||
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
|
||||
/config-file-permissions-unsafe/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a group/world-writable managed ancestor', async (): Promise<void> => {
|
||||
const secure = await loadSecureConfig();
|
||||
const config = fixture();
|
||||
chmodSync(config.directory, 0o777);
|
||||
|
||||
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
|
||||
/config-ancestor-permissions-unsafe/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { dirname, relative, resolve, sep } from 'node:path';
|
||||
import { assertCanonicalContainment, readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
|
||||
const MAX_CONFIG_BYTES = 256 * 1024;
|
||||
const GROUP_OR_OTHER_WRITE = 0o022;
|
||||
|
||||
function currentUid(): number {
|
||||
if (typeof process.getuid !== 'function') {
|
||||
throw new Error('config-owner-check-unsupported');
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function assertOwnedNonWritableDirectory(path: string, uid: number): void {
|
||||
const status = lstatSync(path);
|
||||
if (!status.isDirectory() || status.isSymbolicLink() || status.uid !== uid) {
|
||||
throw new Error('config-ancestor-owner-unsafe');
|
||||
}
|
||||
if ((status.mode & GROUP_OR_OTHER_WRITE) !== 0) {
|
||||
throw new Error('config-ancestor-permissions-unsafe');
|
||||
}
|
||||
}
|
||||
|
||||
export function readBrainConfigSecure(path: string, root: string): string {
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalPath = resolve(path);
|
||||
assertCanonicalContainment(canonicalRoot, canonicalPath);
|
||||
const uid = currentUid();
|
||||
assertOwnedNonWritableDirectory(canonicalRoot, uid);
|
||||
let cursor = canonicalRoot;
|
||||
for (const component of relative(canonicalRoot, dirname(canonicalPath))
|
||||
.split(sep)
|
||||
.filter(Boolean)) {
|
||||
cursor = resolve(cursor, component);
|
||||
assertOwnedNonWritableDirectory(cursor, uid);
|
||||
}
|
||||
|
||||
const snapshot = readRegularFileSecure(canonicalPath, {
|
||||
root: canonicalRoot,
|
||||
maxBytes: MAX_CONFIG_BYTES,
|
||||
});
|
||||
if (snapshot.uid !== uid) throw new Error('config-file-owner-unsafe');
|
||||
if ((snapshot.mode & GROUP_OR_OTHER_WRITE) !== 0) {
|
||||
throw new Error('config-file-permissions-unsafe');
|
||||
}
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(snapshot.content);
|
||||
} catch {
|
||||
throw new Error('config-file-not-utf8');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,569 +0,0 @@
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import {
|
||||
assessCredentialResult,
|
||||
brainRootIsPrivate,
|
||||
deriveBrainTarget,
|
||||
ensureBrainRootPrivate,
|
||||
evaluateBrainDoctor,
|
||||
planBrainDoctorFix,
|
||||
type BrainDoctorFinding,
|
||||
type BrainDoctorObservation,
|
||||
type CredentialAssessment,
|
||||
} from './brain-store.js';
|
||||
|
||||
const COMMIT = /^[0-9a-f]{40}$/;
|
||||
const GIT_OBJECT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
||||
const MAX_PUBLISH_ENTRY_BYTES = 1024 * 1024;
|
||||
|
||||
export interface CommandRequest {
|
||||
readonly program: 'git' | 'mosaic';
|
||||
readonly args: readonly string[];
|
||||
readonly cwd?: string;
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
readonly stdin?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
readonly status: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
export type CommandRunner = (request: CommandRequest) => CommandResult;
|
||||
|
||||
export const systemCommandRunner: CommandRunner = (request: CommandRequest): CommandResult => {
|
||||
const result = spawnSync(request.program, request.args, {
|
||||
cwd: request.cwd,
|
||||
env: { ...process.env, ...request.env },
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
input: request.stdin,
|
||||
});
|
||||
return {
|
||||
status: result.status ?? 127,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? result.error?.message ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
export interface DoctorRuntimeReport {
|
||||
readonly findings: readonly BrainDoctorFinding[];
|
||||
readonly access: CredentialAssessment;
|
||||
readonly refusalControl: {
|
||||
readonly observed: boolean;
|
||||
readonly reasonCode: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BrainRefusalControlResult {
|
||||
readonly observed: boolean;
|
||||
readonly reasonCode: string | null;
|
||||
readonly gitReasonCode: string;
|
||||
readonly apiReasonCode: string;
|
||||
}
|
||||
|
||||
export interface PublishEvidence {
|
||||
readonly commit: string;
|
||||
readonly remoteHead: string;
|
||||
readonly reachable: boolean;
|
||||
}
|
||||
|
||||
function commandEnv(identity: string): Readonly<Record<string, string>> {
|
||||
return {
|
||||
MOSAIC_GIT_IDENTITY: identity,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
};
|
||||
}
|
||||
|
||||
function integrationFailure(): CredentialAssessment {
|
||||
return {
|
||||
outcome: 'indeterminate',
|
||||
exitCode: 30,
|
||||
reasonCode: 'unexpected-provider-shape',
|
||||
diagnostic: 'indeterminate: unexpected-provider-shape',
|
||||
};
|
||||
}
|
||||
|
||||
function runGit(run: CommandRunner, identity: string, args: readonly string[]): CommandResult {
|
||||
return run({ program: 'git', args, env: commandEnv(identity) });
|
||||
}
|
||||
|
||||
function runGitWithEnv(
|
||||
run: CommandRunner,
|
||||
identity: string,
|
||||
args: readonly string[],
|
||||
env: Readonly<Record<string, string>>,
|
||||
stdin?: Uint8Array,
|
||||
): CommandResult {
|
||||
return run({ program: 'git', args, env: { ...commandEnv(identity), ...env }, stdin });
|
||||
}
|
||||
|
||||
export function collectBrainDoctorReport(
|
||||
input: {
|
||||
readonly registrySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly brainNamespace: string;
|
||||
readonly identity: string;
|
||||
readonly root: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): DoctorRuntimeReport {
|
||||
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
||||
const validation = run({
|
||||
program: 'mosaic',
|
||||
args: [
|
||||
'cred',
|
||||
'validate',
|
||||
input.identity,
|
||||
'--estate',
|
||||
target.estate,
|
||||
'--host',
|
||||
target.host,
|
||||
'--repo',
|
||||
target.repo,
|
||||
'--require',
|
||||
'write',
|
||||
'--json',
|
||||
],
|
||||
env: commandEnv(input.identity),
|
||||
});
|
||||
let access = assessCredentialResult(validation.stdout, {
|
||||
identity: input.identity,
|
||||
estate: target.estate,
|
||||
host: target.host,
|
||||
repo: target.repo,
|
||||
});
|
||||
if (validation.status !== access.exitCode) access = integrationFailure();
|
||||
|
||||
const rootExists = existsSync(input.root);
|
||||
let gitRepository = false;
|
||||
let remote: string | null = null;
|
||||
let branch: string | null = null;
|
||||
let worktreeState: BrainDoctorObservation['worktreeState'] = 'unmeasurable';
|
||||
if (rootExists) {
|
||||
const repository = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'rev-parse',
|
||||
'--is-inside-work-tree',
|
||||
]);
|
||||
gitRepository = repository.status === 0 && repository.stdout.trim() === 'true';
|
||||
if (gitRepository) {
|
||||
const remoteResult = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'remote',
|
||||
'get-url',
|
||||
'origin',
|
||||
]);
|
||||
const branchResult = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'branch',
|
||||
'--show-current',
|
||||
]);
|
||||
const statusResult = runGit(run, input.identity, ['-C', input.root, 'status', '--porcelain']);
|
||||
if (remoteResult.status === 0) remote = remoteResult.stdout.trim();
|
||||
if (branchResult.status === 0) branch = branchResult.stdout.trim();
|
||||
if (statusResult.status === 0) {
|
||||
worktreeState = statusResult.stdout.trim().length > 0 ? 'dirty' : 'clean';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observation: BrainDoctorObservation = {
|
||||
rootExists,
|
||||
rootPrivate: rootExists && brainRootIsPrivate(input.root),
|
||||
gitRepository,
|
||||
remote,
|
||||
branch,
|
||||
worktreeState,
|
||||
access,
|
||||
};
|
||||
const refusalMarker = `refused reason=${access.reasonCode}`;
|
||||
const refusalObserved =
|
||||
validation.status === 10 &&
|
||||
access.outcome === 'refused' &&
|
||||
access.reasonCode === 'no-token-for-identity' &&
|
||||
validation.stderr.includes(refusalMarker);
|
||||
return {
|
||||
findings: evaluateBrainDoctor(observation, target.cloneUrl),
|
||||
access,
|
||||
refusalControl: {
|
||||
observed: refusalObserved,
|
||||
reasonCode: refusalObserved ? access.reasonCode : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function collectBrainRefusalControl(
|
||||
input: {
|
||||
readonly registrySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly brainNamespace: string;
|
||||
readonly refusalIdentity: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): BrainRefusalControlResult {
|
||||
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(input.refusalIdentity)) {
|
||||
return {
|
||||
observed: false,
|
||||
reasonCode: 'permission-evidence-disagrees',
|
||||
gitReasonCode: 'invalid-control-identity',
|
||||
apiReasonCode: 'invalid-control-identity',
|
||||
};
|
||||
}
|
||||
const apiResult = run({
|
||||
program: 'mosaic',
|
||||
args: [
|
||||
'cred',
|
||||
'validate',
|
||||
input.refusalIdentity,
|
||||
'--estate',
|
||||
target.estate,
|
||||
'--host',
|
||||
target.host,
|
||||
'--repo',
|
||||
target.repo,
|
||||
'--require',
|
||||
'write',
|
||||
'--json',
|
||||
],
|
||||
env: commandEnv(input.refusalIdentity),
|
||||
});
|
||||
let api = assessCredentialResult(apiResult.stdout, {
|
||||
identity: input.refusalIdentity,
|
||||
estate: target.estate,
|
||||
host: target.host,
|
||||
repo: target.repo,
|
||||
});
|
||||
if (apiResult.status !== api.exitCode) api = integrationFailure();
|
||||
|
||||
const gitResult = runGit(run, input.refusalIdentity, ['ls-remote', target.cloneUrl, 'HEAD']);
|
||||
const marker = /(?:^|\s)reason=([a-z0-9-]+)(?:\s|$)/.exec(gitResult.stderr)?.[1];
|
||||
const stableRefusals = new Set([
|
||||
'identity-required',
|
||||
'estate-required',
|
||||
'estate-host-mismatch',
|
||||
'cross-estate-resolution',
|
||||
'no-token-for-identity',
|
||||
'tea-login-missing',
|
||||
'tea-login-host-mismatch',
|
||||
'provider-identity-mismatch',
|
||||
'credential-rejected',
|
||||
'permission-denied',
|
||||
'organization-membership-required',
|
||||
'team-membership-required',
|
||||
]);
|
||||
const gitReasonCode =
|
||||
gitResult.status === 0
|
||||
? 'transport-accepted'
|
||||
: marker !== undefined && stableRefusals.has(marker) && gitResult.stdout.length === 0
|
||||
? marker
|
||||
: 'transport-indeterminate';
|
||||
const observed =
|
||||
api.outcome === 'refused' &&
|
||||
gitReasonCode !== 'transport-accepted' &&
|
||||
gitReasonCode !== 'transport-indeterminate' &&
|
||||
gitReasonCode === api.reasonCode;
|
||||
return {
|
||||
observed,
|
||||
reasonCode: observed ? api.reasonCode : 'permission-evidence-disagrees',
|
||||
gitReasonCode,
|
||||
apiReasonCode: api.reasonCode,
|
||||
};
|
||||
}
|
||||
|
||||
export function repairBrainDoctor(
|
||||
input: {
|
||||
readonly registrySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly brainNamespace: string;
|
||||
readonly identity: string;
|
||||
readonly root: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): DoctorRuntimeReport {
|
||||
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
||||
let report = collectBrainDoctorReport(input, run);
|
||||
const actions = planBrainDoctorFix({
|
||||
findings: report.findings,
|
||||
target,
|
||||
identity: input.identity,
|
||||
root: input.root,
|
||||
});
|
||||
for (const action of actions) {
|
||||
if (action.program === 'mosaic') {
|
||||
run({ program: 'mosaic', args: action.args, env: commandEnv(input.identity) });
|
||||
report = collectBrainDoctorReport(input, run);
|
||||
if (report.access.outcome !== 'ok') return report;
|
||||
continue;
|
||||
}
|
||||
if (action.findingCode === 'brain-clone-missing') {
|
||||
try {
|
||||
ensureBrainRootPrivate(input.root);
|
||||
} catch {
|
||||
return collectBrainDoctorReport(input, run);
|
||||
}
|
||||
}
|
||||
const result = runGit(run, input.identity, action.args);
|
||||
if (result.status !== 0) return collectBrainDoctorReport(input, run);
|
||||
}
|
||||
return collectBrainDoctorReport(input, run);
|
||||
}
|
||||
|
||||
function requireSuccess(result: CommandResult, operation: string): void {
|
||||
if (result.status !== 0) throw new Error(`${operation}-failed`);
|
||||
}
|
||||
|
||||
function containedRelative(root: string, path: string): string {
|
||||
if (isAbsolute(path) === false) throw new Error('brain-publish-path-must-be-absolute');
|
||||
const absoluteRoot = resolve(root);
|
||||
const absolutePath = resolve(path);
|
||||
if (absolutePath === absoluteRoot || !absolutePath.startsWith(`${absoluteRoot}${sep}`)) {
|
||||
throw new Error('brain-publish-path-escaped-root');
|
||||
}
|
||||
return relative(absoluteRoot, absolutePath).split(sep).join('/');
|
||||
}
|
||||
|
||||
export function publishBrainPaths(
|
||||
input: {
|
||||
readonly root: string;
|
||||
readonly identity: string;
|
||||
readonly entries: readonly {
|
||||
readonly path: string;
|
||||
readonly content: Uint8Array;
|
||||
}[];
|
||||
readonly message: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): PublishEvidence {
|
||||
if (input.entries.length === 0) throw new Error('brain-publish-paths-empty');
|
||||
if (input.message.trim().length === 0) throw new Error('brain-publish-message-empty');
|
||||
const entries = input.entries.map((entry) => {
|
||||
if (entry.content.byteLength > MAX_PUBLISH_ENTRY_BYTES) {
|
||||
throw new Error('brain-publish-entry-too-large');
|
||||
}
|
||||
return {
|
||||
path: containedRelative(input.root, entry.path),
|
||||
content: Uint8Array.from(entry.content),
|
||||
};
|
||||
});
|
||||
const paths = entries.map((entry): string => entry.path);
|
||||
if (new Set(paths).size !== paths.length) throw new Error('brain-publish-path-duplicate');
|
||||
|
||||
const readHead = (): string => {
|
||||
const result = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'HEAD']);
|
||||
requireSuccess(result, 'brain-git-read-commit');
|
||||
const value = result.stdout.trim();
|
||||
if (!COMMIT.test(value)) throw new Error('brain-git-commit-shape-invalid');
|
||||
return value;
|
||||
};
|
||||
const verifyCommitIdentity = (commit: string): void => {
|
||||
const result = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'show',
|
||||
'-s',
|
||||
'--format=%an%x00%ae%x00%cn%x00%ce',
|
||||
commit,
|
||||
]);
|
||||
requireSuccess(result, 'brain-git-read-commit-identity');
|
||||
const expectedEmail = `${input.identity}@fleet.mosaicstack.dev`;
|
||||
const [author, authorEmail, committer, committerEmail] = result.stdout.trimEnd().split('\0');
|
||||
if (
|
||||
author !== input.identity ||
|
||||
authorEmail !== expectedEmail ||
|
||||
committer !== input.identity ||
|
||||
committerEmail !== expectedEmail
|
||||
) {
|
||||
throw new Error('brain-git-commit-identity-mismatch');
|
||||
}
|
||||
};
|
||||
const expectedObjects = new Map<string, string>();
|
||||
const verifyCommitObjects = (commit: string): void => {
|
||||
for (const [path, expected] of expectedObjects) {
|
||||
const result = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'rev-parse',
|
||||
`${commit}:${path}`,
|
||||
]);
|
||||
requireSuccess(result, 'brain-git-read-commit-object');
|
||||
if (result.stdout.trim() !== expected) throw new Error('brain-git-commit-content-mismatch');
|
||||
}
|
||||
};
|
||||
const reconcileRealIndex = (): void => {
|
||||
for (const [path, objectId] of expectedObjects) {
|
||||
requireSuccess(
|
||||
runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'update-index',
|
||||
'--add',
|
||||
'--cacheinfo',
|
||||
'100644',
|
||||
objectId,
|
||||
path,
|
||||
]),
|
||||
'brain-git-reconcile-checkout-index',
|
||||
);
|
||||
}
|
||||
};
|
||||
const verifyCommitPaths = (commit: string): void => {
|
||||
const changed = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'diff-tree',
|
||||
'--root',
|
||||
'--no-commit-id',
|
||||
'--name-only',
|
||||
'-r',
|
||||
'-z',
|
||||
commit,
|
||||
]);
|
||||
requireSuccess(changed, 'brain-git-read-commit-paths');
|
||||
const names = changed.stdout.split('\0').filter(Boolean);
|
||||
const approved = new Set(paths);
|
||||
if (names.length === 0 || names.some((name: string): boolean => !approved.has(name))) {
|
||||
throw new Error('brain-git-commit-paths-unapproved');
|
||||
}
|
||||
};
|
||||
|
||||
const base = readHead();
|
||||
const indexRoot = mkdtempSync(join(tmpdir(), 'mosaic-brain-index-'));
|
||||
const isolatedEnv = { GIT_INDEX_FILE: join(indexRoot, 'index') };
|
||||
let commit = base;
|
||||
let createdCommit = false;
|
||||
try {
|
||||
requireSuccess(
|
||||
runGitWithEnv(run, input.identity, ['-C', input.root, 'read-tree', base], isolatedEnv),
|
||||
'brain-git-isolated-index-init',
|
||||
);
|
||||
for (const entry of entries) {
|
||||
const object = runGitWithEnv(
|
||||
run,
|
||||
input.identity,
|
||||
['-C', input.root, 'hash-object', '-w', '--stdin'],
|
||||
isolatedEnv,
|
||||
entry.content,
|
||||
);
|
||||
requireSuccess(object, 'brain-git-write-approved-object');
|
||||
const objectId = object.stdout.trim();
|
||||
if (!GIT_OBJECT.test(objectId)) throw new Error('brain-git-object-shape-invalid');
|
||||
expectedObjects.set(entry.path, objectId);
|
||||
requireSuccess(
|
||||
runGitWithEnv(
|
||||
run,
|
||||
input.identity,
|
||||
[
|
||||
'-C',
|
||||
input.root,
|
||||
'update-index',
|
||||
'--add',
|
||||
'--cacheinfo',
|
||||
'100644',
|
||||
objectId,
|
||||
entry.path,
|
||||
],
|
||||
isolatedEnv,
|
||||
),
|
||||
'brain-git-stage-approved-object',
|
||||
);
|
||||
}
|
||||
const difference = runGitWithEnv(
|
||||
run,
|
||||
input.identity,
|
||||
['-C', input.root, 'diff', '--cached', '--quiet', '--exit-code', base, '--', ...paths],
|
||||
isolatedEnv,
|
||||
);
|
||||
if (difference.status === 1) {
|
||||
requireSuccess(
|
||||
runGitWithEnv(
|
||||
run,
|
||||
input.identity,
|
||||
[
|
||||
'-C',
|
||||
input.root,
|
||||
'-c',
|
||||
`user.name=${input.identity}`,
|
||||
'-c',
|
||||
`user.email=${input.identity}@fleet.mosaicstack.dev`,
|
||||
'commit',
|
||||
'-m',
|
||||
input.message,
|
||||
],
|
||||
isolatedEnv,
|
||||
),
|
||||
'brain-git-commit',
|
||||
);
|
||||
commit = readHead();
|
||||
verifyCommitPaths(commit);
|
||||
verifyCommitObjects(commit);
|
||||
verifyCommitIdentity(commit);
|
||||
reconcileRealIndex();
|
||||
createdCommit = true;
|
||||
} else if (difference.status !== 0) {
|
||||
throw new Error('brain-git-isolated-diff-failed');
|
||||
}
|
||||
} finally {
|
||||
rmSync(indexRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
let pushed = !createdCommit;
|
||||
for (let attempt = 0; createdCommit && attempt < 3; attempt += 1) {
|
||||
const push = runGit(run, input.identity, ['-C', input.root, 'push', 'origin', 'HEAD:main']);
|
||||
if (push.status === 0) {
|
||||
pushed = true;
|
||||
break;
|
||||
}
|
||||
const concurrentUpdate = /non-fast-forward|fetch first|\[rejected\]/i.test(push.stderr);
|
||||
if (!concurrentUpdate || attempt === 2) throw new Error('brain-git-push-failed');
|
||||
requireSuccess(
|
||||
runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']),
|
||||
'brain-git-fetch-concurrent',
|
||||
);
|
||||
requireSuccess(
|
||||
runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'-c',
|
||||
`user.name=${input.identity}`,
|
||||
'-c',
|
||||
`user.email=${input.identity}@fleet.mosaicstack.dev`,
|
||||
'rebase',
|
||||
'origin/main',
|
||||
]),
|
||||
'brain-git-rebase-concurrent',
|
||||
);
|
||||
commit = readHead();
|
||||
verifyCommitPaths(commit);
|
||||
verifyCommitObjects(commit);
|
||||
verifyCommitIdentity(commit);
|
||||
}
|
||||
if (!pushed) throw new Error('brain-git-push-failed');
|
||||
requireSuccess(
|
||||
runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']),
|
||||
'brain-git-fetch-readback',
|
||||
);
|
||||
const reachableResult = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'merge-base',
|
||||
'--is-ancestor',
|
||||
commit,
|
||||
'origin/main',
|
||||
]);
|
||||
if (reachableResult.status !== 0 && reachableResult.status !== 1) {
|
||||
throw new Error('brain-git-reachability-check-failed');
|
||||
}
|
||||
const remoteResult = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'origin/main']);
|
||||
requireSuccess(remoteResult, 'brain-git-read-remote-head');
|
||||
const remoteHead = remoteResult.stdout.trim();
|
||||
if (!COMMIT.test(remoteHead)) throw new Error('brain-git-remote-head-shape-invalid');
|
||||
return { commit, remoteHead, reachable: reachableResult.status === 0 };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -29,11 +29,6 @@ import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
||||
import {
|
||||
defaultInstalledBrainDoctorOptions,
|
||||
runInstalledBrainDoctorCheck,
|
||||
} from './brain-doctor-check.js';
|
||||
import { systemCommandRunner } from './brain-store-runtime.js';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||
@@ -1262,11 +1257,8 @@ export function registerLaunchCommands(program: Command): void {
|
||||
});
|
||||
}
|
||||
|
||||
// `doctor` — the framework drift audit (bash script), the #869
|
||||
// Point-1 C5 lease-enforcement activation check, and the #1051 per-estate
|
||||
// durable brain check. Both TS checks run before the bash audit and can
|
||||
// force a non-zero result for hard/indeterminate failures.
|
||||
// The lease check reuses C1's
|
||||
// `doctor` — the framework drift audit (bash script) PLUS the #869
|
||||
// Point-1 C5 lease-enforcement activation check (TS, reusing C1's
|
||||
// `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`).
|
||||
// Kept out of the generic `directCommands` loop above because this check
|
||||
// must run and report BEFORE the bash script's own exit, and must be able
|
||||
@@ -1275,29 +1267,14 @@ export function registerLaunchCommands(program: Command): void {
|
||||
// undiagnosed (see lease-doctor-check.ts docstring).
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Health audit — detect drift, lease gaps, and per-estate brain defects')
|
||||
.description('Health audit — detect drift, missing files, and #869 lease-activation gaps')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action(async (_opts: unknown, cmd: Command) => {
|
||||
checkMosaicHome();
|
||||
const leaseCheck = await runLeaseEnforcementDoctorCheck();
|
||||
const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck);
|
||||
const fix = cmd.args.includes('--fix');
|
||||
const brainCheck = runInstalledBrainDoctorCheck(
|
||||
defaultInstalledBrainDoctorOptions(fix),
|
||||
systemCommandRunner,
|
||||
);
|
||||
for (const line of brainCheck.lines) {
|
||||
(brainCheck.status === 'ok' ? console.log : console.error)(line);
|
||||
}
|
||||
const brainCheckFailed =
|
||||
brainCheck.status === 'error' ||
|
||||
(brainCheck.status === 'warn' && cmd.args.includes('--fail-on-warn'));
|
||||
runDoctorScriptAndExit(
|
||||
fwScript('mosaic-doctor'),
|
||||
cmd.args,
|
||||
leaseCheckFailed || brainCheckFailed,
|
||||
);
|
||||
runDoctorScriptAndExit(fwScript('mosaic-doctor'), cmd.args, leaseCheckFailed);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ export interface SecureFileSnapshot {
|
||||
mode: number;
|
||||
dev: number | bigint;
|
||||
ino: number | bigint;
|
||||
uid: number;
|
||||
gid: number;
|
||||
}
|
||||
|
||||
function sameIdentity(
|
||||
@@ -237,8 +235,6 @@ export function readRegularFileSecure(
|
||||
mode: Number(opened.mode),
|
||||
dev: opened.dev,
|
||||
ino: opened.ino,
|
||||
uid: opened.uid,
|
||||
gid: opened.gid,
|
||||
};
|
||||
} finally {
|
||||
closeDescriptors(openedFile.descriptors);
|
||||
|
||||
+339
-163
@@ -1,184 +1,360 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─── Mosaic Stack — End-to-End Install Test ────────────────────────────────────
|
||||
# Greenfield installer acceptance fixture.
|
||||
#
|
||||
# Runs a clean-container install test to verify the full first-run flow:
|
||||
# tools/install.sh -> mosaic wizard (non-interactive)
|
||||
# -> mosaic gateway install
|
||||
# -> mosaic gateway verify
|
||||
#
|
||||
# Usage:
|
||||
# bash tools/e2e-install-test.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - Docker (skips gracefully if not available)
|
||||
# - Run from the repository root
|
||||
#
|
||||
# How it works:
|
||||
# 1. Mounts the repository into a node:22-alpine container.
|
||||
# 2. Installs prerequisites (bash, curl, jq, git) inside the container.
|
||||
# 3. Runs `bash tools/install.sh --yes --no-auto-launch` to install the
|
||||
# framework and CLI from the Gitea registry.
|
||||
# 4. Runs `mosaic wizard --non-interactive` to set up SOUL/USER.
|
||||
# 5. Runs `mosaic gateway install` with piped defaults (non-interactive).
|
||||
# 6. Runs `mosaic gateway verify` and checks its exit code.
|
||||
# NOTE: `mosaic gateway verify` is a new command added in the
|
||||
# feat/mosaic-first-run-ux branch. If the installed CLI version
|
||||
# pre-dates this branch (does not have `gateway verify`), the test
|
||||
# marks this step as EXPECTED-SKIP and reports the installed version.
|
||||
# 7. Reports PASS or FAIL with a summary.
|
||||
#
|
||||
# To run manually:
|
||||
# cd /path/to/mosaic-stack
|
||||
# bash tools/e2e-install-test.sh
|
||||
#
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# The fixture itself is intentionally RED until the C2-C5 phase owners repair
|
||||
# their postconditions. C1's CI gate executes it and validates that the RED is
|
||||
# attributable (including the discriminating P3 PASS); it does not turn the
|
||||
# failed install into a false green.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IMAGE="node:22-alpine"
|
||||
CONTAINER_NAME="mosaic-e2e-install-$$"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LANE="${MOSAIC_INSTALL_LANE:-next}"
|
||||
SOURCE="${MOSAIC_INSTALL_SOURCE:-checkout}"
|
||||
IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}"
|
||||
GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}"
|
||||
INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}"
|
||||
IN_CLEAN_CONTAINER="${MOSAIC_GREENFIELD_CONTAINER:-0}"
|
||||
|
||||
# ─── Colour helpers ───────────────────────────────────────────────────────────
|
||||
if [[ -t 1 ]]; then
|
||||
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m' BOLD=$'\033[1m' RESET=$'\033[0m'
|
||||
else
|
||||
R="" G="" Y="" BOLD="" RESET=""
|
||||
fi
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: tools/e2e-install-test.sh [--lane next|main] [--source checkout|remote] [--git present|absent]
|
||||
|
||||
info() { echo "${BOLD}[e2e]${RESET} $*"; }
|
||||
ok() { echo "${G}[PASS]${RESET} $*"; }
|
||||
fail() { echo "${R}[FAIL]${RESET} $*" >&2; }
|
||||
warn() { echo "${Y}[WARN]${RESET} $*"; }
|
||||
|
||||
# ─── Docker availability check ────────────────────────────────────────────────
|
||||
if ! command -v docker &>/dev/null; then
|
||||
warn "Docker not found — skipping e2e install test."
|
||||
warn "Install Docker and re-run this script to exercise the full install flow."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! docker info &>/dev/null 2>&1; then
|
||||
warn "Docker daemon is not running or not accessible — skipping e2e install test."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Docker available — proceeding with e2e install test."
|
||||
info "Repo root: ${REPO_ROOT}"
|
||||
info "Container image: ${IMAGE}"
|
||||
|
||||
# ─── Inline script that runs INSIDE the container ────────────────────────────
|
||||
INNER_SCRIPT="$(mktemp /tmp/mosaic-e2e-inner-XXXXXX.sh)"
|
||||
trap 'rm -f "$INNER_SCRIPT"' EXIT
|
||||
|
||||
cat > "$INNER_SCRIPT" <<'INNER_SCRIPT_EOF'
|
||||
#!/bin/sh
|
||||
# Bootstrap: /bin/sh until bash is installed, then re-exec.
|
||||
set -e
|
||||
|
||||
echo "=== [inner] Installing system prerequisites ==="
|
||||
apk add --no-cache bash curl jq git 2>/dev/null || \
|
||||
apt-get install -y -q bash curl jq git 2>/dev/null || true
|
||||
|
||||
# Re-exec under bash.
|
||||
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1; then
|
||||
exec bash "$0" "$@"
|
||||
fi
|
||||
|
||||
# ── bash from here ────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== [inner] Node.js / npm versions ==="
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
echo "=== [inner] Setting up npm global prefix ==="
|
||||
export NPM_PREFIX="/root/.npm-global"
|
||||
mkdir -p "$NPM_PREFIX/bin"
|
||||
npm config set prefix "$NPM_PREFIX" 2>/dev/null || true
|
||||
export PATH="$NPM_PREFIX/bin:$PATH"
|
||||
|
||||
echo "=== [inner] Running install.sh --yes --no-auto-launch ==="
|
||||
# Install both framework and CLI from the Gitea registry.
|
||||
MOSAIC_SKIP_SKILLS_SYNC=1 \
|
||||
MOSAIC_ASSUME_YES=1 \
|
||||
bash /repo/tools/install.sh --yes --no-auto-launch
|
||||
|
||||
INSTALLED_VERSION="$(mosaic --version 2>/dev/null || echo 'unknown')"
|
||||
echo "[inner] mosaic CLI installed: ${INSTALLED_VERSION}"
|
||||
|
||||
echo "=== [inner] Running mosaic wizard (non-interactive) ==="
|
||||
mosaic wizard \
|
||||
--non-interactive \
|
||||
--name "test-agent" \
|
||||
--user-name "tester" \
|
||||
--pronouns "they/them" \
|
||||
--timezone "UTC" || {
|
||||
echo "[WARN] mosaic wizard exited non-zero — continuing"
|
||||
Runs the documented installer command from zero in Debian/glibc as a non-root
|
||||
uid with an isolated HOME. The fixture exits non-zero when any P0-P8
|
||||
postcondition fails. `next` is always selected with the --next installer flag.
|
||||
EOF
|
||||
}
|
||||
|
||||
echo "=== [inner] Running mosaic gateway install ==="
|
||||
# Feed non-interactive answers:
|
||||
# "1" → storage tier: local
|
||||
# "" → port: accept default (14242)
|
||||
# "" → ANTHROPIC_API_KEY: skip
|
||||
# "" → CORS origin: accept default
|
||||
# Then admin bootstrap: name, email, password
|
||||
printf '1\n\n\n\nTest Admin\[email protected]\ntestpassword123\n' \
|
||||
| mosaic gateway install
|
||||
INSTALL_EXIT="$?"
|
||||
if [ "${INSTALL_EXIT}" -ne 0 ]; then
|
||||
echo "[ERR] mosaic gateway install exited ${INSTALL_EXIT}"
|
||||
mosaic gateway status 2>/dev/null || true
|
||||
exit "${INSTALL_EXIT}"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--lane) LANE="${2:-}"; shift 2 ;;
|
||||
--source) SOURCE="${2:-}"; shift 2 ;;
|
||||
--git) GIT_MODE="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "[fixture] unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expected next|main)" >&2; exit 2 ;; esac
|
||||
case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac
|
||||
case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac
|
||||
|
||||
if [[ "$IN_CLEAN_CONTAINER" != "1" ]]; then
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== [inner] Running mosaic gateway verify ==="
|
||||
# `gateway verify` was added in feat/mosaic-first-run-ux.
|
||||
# If the installed version pre-dates this, skip gracefully.
|
||||
if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then
|
||||
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
|
||||
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
|
||||
echo "[SKIP] Re-run after the new version is published to validate this step."
|
||||
# Treat as pass — the install flow itself worked.
|
||||
exit 0
|
||||
installer_b64=""
|
||||
framework_payload_count="NOT-MEASURED"
|
||||
repo_root_count="NOT-MEASURED"
|
||||
checkout_archive=""
|
||||
checkout_digest=""
|
||||
checkout_content_id=""
|
||||
if [[ "$SOURCE" == "checkout" ]]; then
|
||||
installer_b64="$(base64 -w0 "$INSTALLER_FILE")"
|
||||
[[ -d "$ROOT/packages/mosaic/framework/skills" ]] \
|
||||
&& framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
[[ -d "$ROOT/skills" ]] \
|
||||
&& repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
checkout_archive="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-checkout.XXXXXX.tar.gz")"
|
||||
repo_parent="$(dirname "$ROOT")"
|
||||
repo_name="$(basename "$ROOT")"
|
||||
tar -C "$repo_parent" \
|
||||
--exclude='*/.git' --exclude='*/node_modules' --exclude='*/dist' \
|
||||
--exclude='*/coverage' --exclude='*/.turbo' --exclude='*/.mosaic-test-work' \
|
||||
--exclude='*/.env' --exclude='*/.env.*' \
|
||||
-czf "$checkout_archive" "$repo_name"
|
||||
checkout_digest="$(sha256sum "$checkout_archive" | awk '{print $1}')"
|
||||
checkout_content_id="${checkout_digest:0:40}"
|
||||
fi
|
||||
|
||||
mosaic gateway verify
|
||||
VERIFY_EXIT="$?"
|
||||
echo "=== [inner] verify exit code: ${VERIFY_EXIT} ==="
|
||||
exit "${VERIFY_EXIT}"
|
||||
INNER_SCRIPT_EOF
|
||||
inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX.sh")"
|
||||
trap 'rm -f "$inner" "$checkout_archive"' EXIT
|
||||
cat > "$inner" <<'INNER'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
chmod +x "$INNER_SCRIPT"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
packages=(bash ca-certificates curl jq passwd python3 util-linux)
|
||||
[[ "$FIXTURE_GIT_MODE" == "present" ]] && packages+=(git)
|
||||
apt-get install -y -qq "${packages[@]}" >/dev/null
|
||||
|
||||
# ─── Pull image ───────────────────────────────────────────────────────────────
|
||||
info "Pulling ${IMAGE}…"
|
||||
docker pull "${IMAGE}" --quiet
|
||||
if [[ "$FIXTURE_SOURCE" == "checkout" ]]; then
|
||||
awk 'found { print } /^__MOSAIC_CHECKOUT_ARCHIVE__$/ { found=1; next }' "$0" | base64 -d > /tmp/source-checkout.tar.gz
|
||||
actual_checkout_digest="$(sha256sum /tmp/source-checkout.tar.gz | awk '{print $1}')"
|
||||
if [[ "$actual_checkout_digest" != "$FIXTURE_CHECKOUT_SHA256" ]]; then
|
||||
echo "[fixture] checkout archive transport digest mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Run container ────────────────────────────────────────────────────────────
|
||||
info "Starting container ${CONTAINER_NAME}…"
|
||||
useradd --create-home --uid 1001 --shell /bin/bash mosaic
|
||||
install -d -o mosaic -g mosaic /home/mosaic/work
|
||||
|
||||
EXIT_CODE=0
|
||||
docker run --rm \
|
||||
--name "${CONTAINER_NAME}" \
|
||||
--volume "${REPO_ROOT}:/repo:ro" \
|
||||
--volume "${INNER_SCRIPT}:/e2e-inner.sh:ro" \
|
||||
--network host \
|
||||
"${IMAGE}" \
|
||||
/bin/sh /e2e-inner.sh \
|
||||
|| EXIT_CODE=$?
|
||||
case "$FIXTURE_SOURCE" in
|
||||
checkout)
|
||||
printf '%s' "$FIXTURE_INSTALLER_B64" | base64 -d > /tmp/install.sh
|
||||
;;
|
||||
remote)
|
||||
curl -fsSL "https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/${FIXTURE_LANE}/tools/install.sh" > /tmp/install.sh
|
||||
;;
|
||||
esac
|
||||
chmod 0755 /tmp/install.sh
|
||||
sha256sum /tmp/install.sh | sed 's/^/[fixture] installer sha256: /'
|
||||
|
||||
# ─── Report ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
if [[ "$EXIT_CODE" -eq 0 ]]; then
|
||||
ok "End-to-end install test PASSED (exit ${EXIT_CODE})"
|
||||
cat > /tmp/run-as-target.sh <<'TARGET'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
lane="$FIXTURE_LANE"
|
||||
home="$HOME"
|
||||
prefix="$home/.npm-global"
|
||||
mosaic_home="$home/.config/mosaic"
|
||||
install_log="$home/install.log"
|
||||
failures=0
|
||||
|
||||
phase_pass() { printf '[%s] PASS: %s\n' "$1" "$2"; }
|
||||
phase_fail() { printf '[%s] FAIL: %s\n' "$1" "$2"; failures=$((failures + 1)); }
|
||||
|
||||
lane_args=()
|
||||
resolved_spec='@mosaicstack/mosaic'
|
||||
if [[ "$lane" == "next" ]]; then
|
||||
lane_args+=(--next)
|
||||
resolved_spec='@mosaicstack/mosaic@next'
|
||||
fi
|
||||
|
||||
resolved_version="$(npm view "$resolved_spec" version --registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ 2>/dev/null || true)"
|
||||
printf '[fixture] resolved lane=%s package=%s version=%s\n' "$lane" "$resolved_spec" "${resolved_version:-UNRESOLVED}"
|
||||
|
||||
set +e
|
||||
MOSAIC_NO_COLOR=1 MOSAIC_ASSUME_YES=1 \
|
||||
bash /tmp/install.sh "${lane_args[@]}" --yes --no-auto-launch >"$install_log" 2>&1
|
||||
install_status=$?
|
||||
set -e
|
||||
cat "$install_log"
|
||||
printf '[fixture] installer_exit=%d done_claims=%s\n' \
|
||||
"$install_status" "$(grep -cF 'Done.' "$install_log" || true)"
|
||||
|
||||
# P0 Resolve context
|
||||
shell="$(getent passwd "$(id -u)" | cut -d: -f7)"
|
||||
if [[ "$(id -u)" -ne 0 && "$home" == "/home/mosaic" && "$shell" == "/bin/bash" ]] \
|
||||
&& ldd --version 2>&1 | grep -i 'glibc\|gnu libc' >/dev/null \
|
||||
&& [[ "$(node -p 'Number(process.versions.node.split(".")[0])')" -ge 20 ]]; then
|
||||
phase_pass P0 "target=mosaic uid=$(id -u) HOME=$home shell=$shell libc=glibc node=$(node --version)"
|
||||
else
|
||||
fail "End-to-end install test FAILED (exit ${EXIT_CODE})"
|
||||
echo ""
|
||||
echo " Troubleshooting:"
|
||||
echo " - Review the output above for the failing step."
|
||||
echo " - Re-run with bash -x tools/e2e-install-test.sh for verbose trace."
|
||||
echo " - Run mosaic gateway logs inside a manual container for daemon output."
|
||||
phase_fail P0 "context unresolved or unsupported (uid=$(id -u) HOME=$home shell=${shell:-unknown})"
|
||||
fi
|
||||
|
||||
# P1 Preflight
|
||||
missing_tools=()
|
||||
for tool in bash curl git node npm python3 tar; do
|
||||
command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool")
|
||||
done
|
||||
if [[ "${#missing_tools[@]}" -eq 0 && -n "$resolved_version" && -w "$home" ]]; then
|
||||
phase_pass P1 "required tools present (including downstream git); target HOME writable; registry lane resolved"
|
||||
else
|
||||
phase_fail P1 "undeclared/missing prerequisite(s)=${missing_tools[*]:-none}; target_writable=$([[ -w "$home" ]] && echo yes || echo no) registry_resolved=$([[ -n "$resolved_version" ]] && echo yes || echo no)"
|
||||
fi
|
||||
|
||||
# P2 Acquire artifacts
|
||||
if [[ -n "$resolved_version" ]] && grep -qF "$resolved_version" "$install_log"; then
|
||||
phase_pass P2 "lane=$lane pinned_version=$resolved_version recorded in installer transcript"
|
||||
else
|
||||
phase_fail P2 "lane=$lane did not resolve and record a pinned artifact version"
|
||||
fi
|
||||
|
||||
# P3 Install CLI — the discriminating row. Use the known absolute path only.
|
||||
cli="$prefix/bin/mosaic"
|
||||
cli_version=""
|
||||
if [[ -x "$cli" ]]; then
|
||||
cli_version="$($cli --version 2>/dev/null | tail -n 1 | tr -d '\r' || true)"
|
||||
fi
|
||||
if [[ -x "$cli" && "$cli_version" == "$resolved_version" ]]; then
|
||||
phase_pass P3 "absolute_path=$cli version=$cli_version equals resolved lane version"
|
||||
else
|
||||
phase_fail P3 "absolute_path=$cli executable=$([[ -x "$cli" ]] && echo yes || echo no) got=${cli_version:-missing} expected=${resolved_version:-unresolved}"
|
||||
fi
|
||||
|
||||
# P4 Framework + skills. C1 does not choose among the four disagreeing
|
||||
# candidate populations. It requires the installer to publish a lane/versioned
|
||||
# shipped-set declaration that a checkout-free install can resolve; C5 owns its
|
||||
# contents. Without that artifact P4 is NOT-MEASURED, never a fabricated count.
|
||||
declared_set="$mosaic_home/.install-shipped-skills.json"
|
||||
sync_store_count=0
|
||||
runtime_link_count=0
|
||||
[[ -d "$mosaic_home/skills" ]] \
|
||||
&& sync_store_count="$(find "$mosaic_home/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
[[ -d "$home/.pi/agent/skills" ]] \
|
||||
&& runtime_link_count="$(find "$home/.pi/agent/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) | wc -l | tr -d ' ')"
|
||||
printf '[P4-EVIDENCE] candidate_populations framework_payload=%s repo_root=%s sync_store=%s jarvis_W-jarvis_observation=7 runtime_links=%s\n' \
|
||||
"$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" "$FIXTURE_REPO_ROOT_COUNT" "$sync_store_count" "$runtime_link_count"
|
||||
if [[ ! -s "$declared_set" ]]; then
|
||||
phase_fail P4 "NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set"
|
||||
elif EXPECTED_LANE="$([[ "$lane" == next ]] && echo next || echo latest)" EXPECTED_VERSION="$resolved_version" \
|
||||
MOSAIC_SKILLS_ROOT="$mosaic_home/skills" node - "$declared_set" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
const root = path.resolve(process.env.MOSAIC_SKILLS_ROOT);
|
||||
if (!data || data.lane !== process.env.EXPECTED_LANE || data.version !== process.env.EXPECTED_VERSION ||
|
||||
!Array.isArray(data.skills) || data.skills.length === 0) process.exit(1);
|
||||
for (const name of data.skills) {
|
||||
if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) process.exit(1);
|
||||
const skill = path.join(root, name, 'SKILL.md');
|
||||
let real;
|
||||
try { real = fs.realpathSync(skill); } catch { process.exit(1); }
|
||||
const text = fs.readFileSync(real, 'utf8');
|
||||
const declaredName = text.match(/^---\s*$[\s\S]*?^name:\s*([^\s]+)\s*$/m)?.[1];
|
||||
if (!real.startsWith(root + path.sep) || !fs.statSync(real).isFile() || !text || declaredName !== name) process.exit(1);
|
||||
}
|
||||
NODE
|
||||
then
|
||||
declared_count="$(node -p "require('$declared_set').skills.length")"
|
||||
if [[ -s "$mosaic_home/.install-manifest.json" ]] \
|
||||
&& [[ "$(node -p "require('$mosaic_home/.install-manifest.json').phaseOutcomes?.P4 || 'committed'")" == failed ]]; then
|
||||
phase_fail P4 "declared skills are present but the required framework/skills action reported failure"
|
||||
else
|
||||
phase_pass P4 "declared shipped-set matches lane/version and all $declared_count skill(s) are contained and loadable"
|
||||
fi
|
||||
else
|
||||
phase_fail P4 "shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable"
|
||||
fi
|
||||
|
||||
# P5 Identity
|
||||
identity_ok=true
|
||||
identity_reason=()
|
||||
for f in SOUL.md USER.md; do
|
||||
path="$mosaic_home/$f"
|
||||
if [[ ! -s "$path" ]]; then
|
||||
identity_ok=false; identity_reason+=("$f missing-or-empty"); continue
|
||||
fi
|
||||
owner="$(stat -c '%u' "$path")"; mode="$(stat -c '%a' "$path")"
|
||||
if [[ "$owner" != "$(id -u)" || "$mode" =~ [2367]$ ]]; then
|
||||
identity_ok=false; identity_reason+=("$f owner=$owner mode=$mode")
|
||||
fi
|
||||
done
|
||||
if [[ "$identity_ok" == true ]]; then
|
||||
phase_pass P5 "SOUL.md and USER.md are non-empty and target-user owned with non-world-writable modes"
|
||||
else
|
||||
phase_fail P5 "${identity_reason[*]}"
|
||||
fi
|
||||
|
||||
# P6 Runtime linking / activation. #869 must remain unwired without its broker.
|
||||
manifest="$mosaic_home/.install-manifest.json"
|
||||
broker_present=false
|
||||
[[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker_present=true
|
||||
dead_hooks=0
|
||||
if [[ -f "$home/.claude/settings.json" ]]; then
|
||||
dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$home/.claude/settings.json" || true)"
|
||||
fi
|
||||
p6_action_failed=false
|
||||
if [[ -s "$manifest" ]]; then
|
||||
p6_action_failed="$(node -p "require('$manifest').phaseOutcomes?.P6 === 'failed' ? 'true' : 'false'" 2>/dev/null || echo true)"
|
||||
fi
|
||||
if [[ "$p6_action_failed" == true ]]; then
|
||||
phase_fail P6 "runtime linking/activation action reported a required failure"
|
||||
elif [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then
|
||||
phase_pass P6 "broker absent and #869 enforcement hooks remain inactive"
|
||||
elif [[ "$broker_present" == true ]]; then
|
||||
phase_pass P6 "activation broker present; hook state is evaluable"
|
||||
else
|
||||
phase_fail P6 "broker absent but dead enforcement hooks are active (count=$dead_hooks)"
|
||||
fi
|
||||
|
||||
# P7 Services — none requested by --no-auto-launch.
|
||||
phase_pass P7 "no services requested by this fixture"
|
||||
|
||||
# P8 Shell discoverability — actual target shell, fresh login and non-login.
|
||||
base_env=(env -i HOME="$home" USER=mosaic LOGNAME=mosaic SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin)
|
||||
login_path="$("${base_env[@]}" /bin/bash -lc 'command -v mosaic' 2>/dev/null || true)"
|
||||
nonlogin_path="$("${base_env[@]}" /bin/bash -c 'command -v mosaic' 2>/dev/null || true)"
|
||||
if [[ "$login_path" == "$cli" && "$nonlogin_path" == "$cli" ]]; then
|
||||
phase_pass P8 "login=$login_path nonlogin=$nonlogin_path equals P3 path"
|
||||
else
|
||||
phase_fail P8 "fresh bash login=${login_path:-missing} nonlogin=${nonlogin_path:-missing} expected=$cli"
|
||||
fi
|
||||
|
||||
manifest="$mosaic_home/.install-manifest.json"
|
||||
p0_p8_failures="$failures"
|
||||
if [[ "$p0_p8_failures" -eq 0 && -s "$manifest" ]]; then
|
||||
phase_pass P9 "P0-P8 reasserted; manifest present"
|
||||
else
|
||||
phase_fail P9 "P0-P8_failed_postconditions=$p0_p8_failures manifest=$([[ -s "$manifest" ]] && echo present || echo missing); install must not certify success"
|
||||
fi
|
||||
|
||||
printf '[fixture] P0-P9_failed_rows=%d (includes P9 aggregate row)\n' "$failures"
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
TARGET
|
||||
chmod 0755 /tmp/run-as-target.sh
|
||||
chown mosaic:mosaic /tmp/run-as-target.sh
|
||||
|
||||
exec runuser -u mosaic -- env -i \
|
||||
HOME=/home/mosaic USER=mosaic LOGNAME=mosaic SHELL=/bin/bash \
|
||||
PATH=/usr/local/bin:/usr/bin:/bin \
|
||||
FIXTURE_LANE="$FIXTURE_LANE" \
|
||||
FIXTURE_GIT_MODE="$FIXTURE_GIT_MODE" \
|
||||
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" \
|
||||
FIXTURE_REPO_ROOT_COUNT="$FIXTURE_REPO_ROOT_COUNT" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_ARCHIVE="$([[ "$FIXTURE_SOURCE" == "checkout" ]] && echo /tmp/source-checkout.tar.gz)" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_COMMIT="$FIXTURE_CHECKOUT_CONTENT_ID" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_SHA256="$FIXTURE_CHECKOUT_SHA256" \
|
||||
/bin/bash /tmp/run-as-target.sh
|
||||
INNER
|
||||
if [[ "$SOURCE" == "checkout" ]]; then
|
||||
{
|
||||
printf '\n__MOSAIC_CHECKOUT_ARCHIVE__\n'
|
||||
base64 "$checkout_archive"
|
||||
} >> "$inner"
|
||||
fi
|
||||
chmod 0755 "$inner"
|
||||
|
||||
printf '[fixture] platform=Debian/glibc image=%s target_uid=1001 lane=%s source=%s git=%s\n' "$IMAGE" "$LANE" "$SOURCE" "$GIT_MODE"
|
||||
printf '[fixture] host inheritance: no bind mounts, no host HOME, no npm cache, no credentials\n'
|
||||
|
||||
if [[ "$IN_CLEAN_CONTAINER" == "1" ]]; then
|
||||
# Woodpecker already supplies the clean Debian container. The target install
|
||||
# still runs through runuser + env -i, so CI variables/credentials do not
|
||||
# enter the target user's process.
|
||||
FIXTURE_LANE="$LANE" \
|
||||
FIXTURE_SOURCE="$SOURCE" \
|
||||
FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||
FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||
FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||
FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||
FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||
/bin/bash "$inner"
|
||||
else
|
||||
# Copy the self-contained script+archive into a stopped container instead of
|
||||
# bind-mounting the checkout or passing host paths. The target runtime still
|
||||
# inherits no host HOME/cache/credentials, and the multi-megabyte checkout
|
||||
# payload avoids argv/environment size limits.
|
||||
fixture_cid="$(docker create \
|
||||
--network bridge \
|
||||
--env FIXTURE_LANE="$LANE" \
|
||||
--env FIXTURE_SOURCE="$SOURCE" \
|
||||
--env FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||
--env FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||
--env FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||
--env FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||
--env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||
--env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||
"$IMAGE" /bin/bash /tmp/mosaic-greenfield-fixture.sh)"
|
||||
docker cp "$inner" "$fixture_cid:/tmp/mosaic-greenfield-fixture.sh"
|
||||
set +e
|
||||
docker start -a "$fixture_cid"
|
||||
fixture_status=$?
|
||||
set -e
|
||||
docker rm "$fixture_cid" >/dev/null
|
||||
exit "$fixture_status"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Pinned C1 expected-RED contract. Updating a verdict/reason requires review by the owning remediation lane.
|
||||
# case kind key/value
|
||||
next-git-present exit 1
|
||||
next-git-present phase P0=PASS
|
||||
next-git-present phase P1=PASS
|
||||
next-git-present phase P2=PASS
|
||||
next-git-present phase P3=PASS
|
||||
next-git-present phase P4=FAIL
|
||||
next-git-present phase P5=FAIL
|
||||
next-git-present phase P6=FAIL
|
||||
next-git-present phase P7=PASS
|
||||
next-git-present phase P8=FAIL
|
||||
next-git-present phase P9=FAIL
|
||||
next-git-present require ^\[fixture\] resolved lane=next .*version=[0-9]+\.[0-9]+\.[0-9]+-next\.
|
||||
next-git-present require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||
next-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$
|
||||
next-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:
|
||||
next-git-present require ^\[P6\] FAIL:
|
||||
next-git-present forbid Done\.
|
||||
main-git-present exit 1
|
||||
main-git-present phase P0=PASS
|
||||
main-git-present phase P1=PASS
|
||||
main-git-present phase P2=PASS
|
||||
main-git-present phase P3=PASS
|
||||
main-git-present phase P4=FAIL
|
||||
main-git-present phase P5=FAIL
|
||||
main-git-present phase P6=FAIL
|
||||
main-git-present phase P7=PASS
|
||||
main-git-present phase P8=FAIL
|
||||
main-git-present phase P9=FAIL
|
||||
main-git-present require ^\[fixture\] resolved lane=main .*version=[0-9]+\.[0-9]+\.[0-9]+$
|
||||
main-git-present require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||
main-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$
|
||||
main-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:
|
||||
main-git-present require ^\[P6\] FAIL:
|
||||
main-git-present forbid Done\.
|
||||
next-git-absent exit 1
|
||||
next-git-absent phase P0=PASS
|
||||
next-git-absent phase P1=FAIL
|
||||
next-git-absent phase P2=FAIL
|
||||
next-git-absent phase P3=FAIL
|
||||
next-git-absent phase P4=FAIL
|
||||
next-git-absent phase P5=FAIL
|
||||
next-git-absent phase P6=PASS
|
||||
next-git-absent phase P7=PASS
|
||||
next-git-absent phase P8=FAIL
|
||||
next-git-absent phase P9=FAIL
|
||||
next-git-absent require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||
next-git-absent require ^\[P1\] FAIL: undeclared/missing prerequisite\(s\)=git;
|
||||
next-git-absent require ^\[P3\] FAIL: .*executable=no
|
||||
next-git-absent forbid Done\.
|
||||
|
Executable
+359
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
FAKE_BIN="$TMP/bin"
|
||||
HOME_DIR="$TMP/home"
|
||||
PREFIX="$HOME_DIR/prefix"
|
||||
MOSAIC_HOME="$HOME_DIR/mosaic"
|
||||
STATE="$TMP/state"
|
||||
LOG="$TMP/npm.log"
|
||||
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
||||
|
||||
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
STATE="${MOSAIC_TEST_STATE:?}"
|
||||
echo "$*" >> "$LOG"
|
||||
|
||||
if [[ "${1:-}" == "--version" ]]; then
|
||||
echo "10.6.2"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
install_cli() {
|
||||
local version="$1"
|
||||
echo "$version" > "$STATE/mosaic"
|
||||
mkdir -p "${MOSAIC_PREFIX:?}/bin"
|
||||
cat > "$MOSAIC_PREFIX/bin/mosaic" <<CLI
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\\n' '$version'
|
||||
CLI
|
||||
chmod +x "$MOSAIC_PREFIX/bin/mosaic"
|
||||
}
|
||||
|
||||
if [[ "$1" == "view" ]]; then
|
||||
if [[ "${MOSAIC_TEST_FAIL_NPM_VIEW:-0}" == "1" ]]; then
|
||||
echo "forced registry metadata failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$2 $3" in
|
||||
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
|
||||
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
|
||||
"@mosaicstack/mosaic version") echo "0.0.48" ;;
|
||||
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "install" ]]; then
|
||||
case "$*" in
|
||||
*"@mosaicstack/[email protected]"*)
|
||||
install_cli "0.0.49-next.999"
|
||||
;;
|
||||
*"@mosaicstack/[email protected]"*)
|
||||
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
|
||||
echo "forced gateway install failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "0.0.7-next.999" > "$STATE/gateway"
|
||||
;;
|
||||
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
|
||||
install_cli "0.0.0-source"
|
||||
;;
|
||||
*"mosaicstack-gateway-0.0.0-source.tgz"*)
|
||||
echo "0.0.0-source" > "$STATE/gateway"
|
||||
;;
|
||||
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "ls" ]]; then
|
||||
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
|
||||
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
|
||||
node -e '
|
||||
const cli = process.argv[1];
|
||||
const gateway = process.argv[2];
|
||||
const dependencies = {};
|
||||
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
|
||||
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
|
||||
process.stdout.write(JSON.stringify({ dependencies }));
|
||||
' "$cli" "$gateway"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected npm command: $*" >&2
|
||||
exit 1
|
||||
FAKE_NPM
|
||||
chmod +x "$FAKE_BIN/npm"
|
||||
|
||||
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
headers=""; output=""; url=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-D) headers="$2"; shift 2 ;;
|
||||
-o) output="$2"; shift 2 ;;
|
||||
--max-filesize) shift 2 ;;
|
||||
-*) shift ;;
|
||||
*) url="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
case "$url" in
|
||||
*/api/v1/repos/mosaicstack/stack/commits?sha=*)
|
||||
printf 'HTTP/1.1 200 OK\r\ncontent-type: application/json; charset=utf-8\r\n\r\n' > "$headers"
|
||||
printf '[{"sha":"1111111111111111111111111111111111111111"}]\n' > "$output"
|
||||
;;
|
||||
*/archive/*.tar.gz)
|
||||
if [[ "${MOSAIC_TEST_CORRUPT_ARCHIVE:-0}" == "1" ]]; then
|
||||
printf 'not-a-tarball\n' > "$output"
|
||||
else
|
||||
archive_root="$(mktemp -d)"
|
||||
mkdir -p "$archive_root/stack"
|
||||
printf 'fixture\n' > "$archive_root/stack/.fixture"
|
||||
/bin/tar czf "$output" -C "$archive_root" stack
|
||||
rm -rf "$archive_root"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
FAKE_CURL
|
||||
chmod +x "$FAKE_BIN/curl"
|
||||
|
||||
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
dest=""; list=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-C) dest="$2"; shift 2 ;;
|
||||
-*t*|t*) list=true; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
[[ "$list" == true ]] && exit 0
|
||||
if [[ -z "$dest" ]]; then
|
||||
echo "fake tar missing -C destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
|
||||
FAKE_TAR
|
||||
chmod +x "$FAKE_BIN/tar"
|
||||
|
||||
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
echo "pnpm $*" >> "$LOG"
|
||||
|
||||
if [[ "$1" == "pack" ]]; then
|
||||
out=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pack-destination) out="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$out" ]]; then
|
||||
echo "fake pnpm pack missing destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$out"
|
||||
case "$PWD" in
|
||||
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
|
||||
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
|
||||
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${MOSAIC_TEST_FAIL_PNPM_INSTALL:-0}" == "1" && "$1" == "install" ]]; then
|
||||
echo "forced pnpm install failure" >&2
|
||||
exit 42
|
||||
fi
|
||||
|
||||
# Other install/build commands are no-ops in this harness.
|
||||
exit 0
|
||||
FAKE_PNPM
|
||||
chmod +x "$FAKE_BIN/pnpm"
|
||||
|
||||
reset_state() {
|
||||
: > "$LOG"
|
||||
rm -f "$STATE"/*
|
||||
}
|
||||
|
||||
prefix_fingerprint() {
|
||||
if [[ ! -d "$PREFIX" ]]; then printf 'ABSENT\n'; return; fi
|
||||
(
|
||||
cd "$PREFIX"
|
||||
find . -mindepth 1 -printf '%P|%y|%m|%l\n' | LC_ALL=C sort
|
||||
find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum
|
||||
) | sha256sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
reset_state
|
||||
echo "[test] --next fast path pins resolved package versions"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
|
||||
echo "expected exact-version installs, found mutable @next install" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -qF 'Downloading source ref next at pinned commit' <<<"$OUTPUT"; then
|
||||
echo "fast path unexpectedly fell back to source" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTIVE="$HOME_DIR/.local/state/mosaic/install/active.json"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "committed" ]]
|
||||
JOURNAL="$(node -p "require('$ACTIVE').journal")"
|
||||
[[ "$(stat -c '%a' "$JOURNAL")" == "444" ]]
|
||||
( cd "$(dirname "$JOURNAL")" && sha256sum -c "$(basename "$JOURNAL").sha256" >/dev/null )
|
||||
grep -q '"event":"mutation".*"phase":"P3".*path=.*prior=.*reverse=' "$JOURNAL"
|
||||
|
||||
reset_state
|
||||
echo "[test] fast path failure falls back to source build"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
|
||||
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
|
||||
grep -qF 'Downloading source ref next at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
|
||||
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
|
||||
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] source-build failure is fatal and restores the pre-install prefix"
|
||||
before_prefix="$(prefix_fingerprint)"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
MOSAIC_TEST_FAIL_PNPM_INSTALL=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||
)"
|
||||
FAIL_STATUS=$?
|
||||
set -e
|
||||
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||
grep -qF 'forced pnpm install failure' <<<"$OUTPUT"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] corrupt source archive is fatal and restores the pre-install prefix"
|
||||
before_prefix="$(prefix_fingerprint)"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
MOSAIC_TEST_CORRUPT_ARCHIVE=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||
)"
|
||||
FAIL_STATUS=$?
|
||||
set -e
|
||||
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||
grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] --dev source install does not require registry version resolution"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NPM_VIEW=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --dev --ref feature-x --yes --no-auto-launch
|
||||
)"
|
||||
grep -qF 'Downloading source ref feature-x at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||
|
||||
reset_state
|
||||
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
|
||||
)"
|
||||
CHECK_STATUS=$?
|
||||
set -e
|
||||
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||
if grep -qF '@next version' "$LOG"; then
|
||||
echo "explicit ref should not query @next dist-tags" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
reset_state
|
||||
echo "[test] --check --next rejects mismatched prerelease pipeline suffixes"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next
|
||||
)"
|
||||
CHECK_STATUS=$?
|
||||
set -e
|
||||
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||
grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT"
|
||||
|
||||
echo "[test] installer next lane tests passed"
|
||||
Executable
+338
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env bash
|
||||
# Red-first acceptance checks for #1050. This file is committed before the
|
||||
# installer implementation. Do not weaken these properties to make it green.
|
||||
|
||||
# pass_case always returns zero and fail_case records the aggregate failure;
|
||||
# the compact A&&pass||fail assertions are intentional.
|
||||
# shellcheck disable=SC2015
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-state-test.XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
failures=0
|
||||
COMPAT_BIN="$TMP/compat-bin"
|
||||
mkdir -p "$COMPAT_BIN"
|
||||
cat > "$COMPAT_BIN/realpath" <<'REALPATH'
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
mode = args.pop(0) if args and args[0] in ("-e", "-m") else "-m"
|
||||
if args and args[0] == "--":
|
||||
args.pop(0)
|
||||
if len(args) != 1 or (mode == "-e" and not os.path.exists(args[0])):
|
||||
raise SystemExit(1)
|
||||
print(os.path.realpath(args[0]))
|
||||
REALPATH
|
||||
chmod 0755 "$COMPAT_BIN/realpath"
|
||||
|
||||
fail_case() { printf '[test] FAIL: %s\n' "$*" >&2; failures=$((failures + 1)); }
|
||||
pass_case() { printf '[test] PASS: %s\n' "$*"; }
|
||||
|
||||
fingerprint() {
|
||||
local dir="$1"
|
||||
if [[ ! -d "$dir" ]]; then printf 'ABSENT\n'; return; fi
|
||||
python3 - "$dir" <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
root = os.path.abspath(sys.argv[1])
|
||||
rows = []
|
||||
for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
|
||||
for name in dirs + files:
|
||||
path = os.path.join(current, name)
|
||||
rel = os.path.relpath(path, root)
|
||||
meta = os.lstat(path)
|
||||
target = os.readlink(path) if stat.S_ISLNK(meta.st_mode) else ""
|
||||
digest = ""
|
||||
if stat.S_ISREG(meta.st_mode):
|
||||
with open(path, "rb") as handle:
|
||||
digest = hashlib.sha256(handle.read()).hexdigest()
|
||||
rows.append((rel, stat.S_IFMT(meta.st_mode), stat.S_IMODE(meta.st_mode), meta.st_uid, meta.st_gid, target, digest))
|
||||
payload = "\n".join("|".join(map(str, row)) for row in sorted(rows)).encode()
|
||||
print(hashlib.sha256(payload).hexdigest())
|
||||
PY
|
||||
}
|
||||
|
||||
make_fake_npm() {
|
||||
local bin="$1"
|
||||
mkdir -p "$bin"
|
||||
cat > "$bin/npm" <<'FAKE'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" == "--version" ]]; then echo '10.6.2'; exit 0; fi
|
||||
case "${1:-} ${2:-} ${3:-}" in
|
||||
'view @mosaicstack/mosaic@next version') echo '0.0.50-next.999' ;;
|
||||
'view @mosaicstack/gateway@next version') echo '0.0.7-next.999' ;;
|
||||
'view @mosaicstack/mosaic version') echo '0.0.49' ;;
|
||||
'ls -g --depth=0'|'ls -g --json') echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;;
|
||||
ls*) echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;;
|
||||
*) echo "unexpected fake npm command: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
FAKE
|
||||
chmod 0755 "$bin/npm"
|
||||
}
|
||||
|
||||
printf '[test] case: --check enumerates exactly P0-P8, discriminates, and mutates nothing\n'
|
||||
check_home="$TMP/check-home"
|
||||
check_bin="$TMP/check-bin"
|
||||
mkdir -p "$check_home/.config/mosaic/skills/alpha" "$check_home/.npm-global/bin" "$check_bin"
|
||||
printf '# framework\n' > "$check_home/.config/mosaic/AGENTS.md"
|
||||
printf '# skill\n' > "$check_home/.config/mosaic/skills/alpha/SKILL.md"
|
||||
cat > "$check_home/.npm-global/bin/mosaic" <<'CLI'
|
||||
#!/usr/bin/env bash
|
||||
printf '0.0.50-next.999\n'
|
||||
CLI
|
||||
chmod 0755 "$check_home/.npm-global/bin/mosaic"
|
||||
make_fake_npm "$check_bin"
|
||||
before="$(fingerprint "$check_home")"
|
||||
set +e
|
||||
HOME="$check_home" MOSAIC_HOME="$check_home/.config/mosaic" MOSAIC_PREFIX="$check_home/.npm-global" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/check.log" 2>&1
|
||||
check_status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$check_home")"
|
||||
|
||||
[[ "$before" == "$after" ]] && pass_case '--check left the complete HOME fingerprint unchanged' \
|
||||
|| fail_case "--check mutated HOME (before=$before after=$after)"
|
||||
[[ "$check_status" -ne 0 ]] && pass_case '--check exited non-zero for failed P4/P5/P8 predicates' \
|
||||
|| fail_case '--check returned zero on the deliberately broken host'
|
||||
|
||||
phase_rows=0
|
||||
for phase in P0 P1 P2 P3 P4 P5 P6 P7 P8; do
|
||||
count="$(grep -Ec "^\[$phase\] (PASS|FAIL):" "$TMP/check.log" || true)"
|
||||
[[ "$count" -eq 1 ]] || fail_case "$phase expected exactly one PASS/FAIL row, got $count"
|
||||
phase_rows=$((phase_rows + count))
|
||||
done
|
||||
[[ "$phase_rows" -eq 9 ]] && pass_case '--check emitted exactly nine P0-P8 result rows' \
|
||||
|| fail_case "--check emitted $phase_rows canonical rows instead of 9"
|
||||
grep -q '^\[P3\] PASS:.*0\.0\.50-next\.999' "$TMP/check.log" \
|
||||
&& pass_case 'P3 preserves the absolute-path exact-version discriminator' \
|
||||
|| fail_case 'P3 did not PASS with the exact resolved next-lane version'
|
||||
grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' "$TMP/check.log" \
|
||||
&& pass_case 'P4 refuses fabricated precision when no shipped-set declaration exists' \
|
||||
|| fail_case 'P4 did not report the declared-set population as NOT-MEASURED / UNDECLARED'
|
||||
for phase in P5 P8; do
|
||||
grep -q "^\[$phase\] FAIL:" "$TMP/check.log" \
|
||||
&& pass_case "$phase remains an attributable expected RED" \
|
||||
|| fail_case "$phase did not report its own expected failure"
|
||||
done
|
||||
|
||||
printf '[test] case: --check discriminates a constructed good host without mutation\n'
|
||||
good_home="$TMP/good-home"
|
||||
good_bin="$TMP/good-bin"
|
||||
good_prefix="$good_home/.npm-global"
|
||||
good_mosaic="$good_home/.config/mosaic"
|
||||
mkdir -p "$good_bin" "$good_prefix/bin" "$good_mosaic/skills/declared-skill"
|
||||
make_fake_npm "$good_bin"
|
||||
cp "$COMPAT_BIN/realpath" "$good_bin/realpath"
|
||||
cat > "$good_bin/id" <<'ID'
|
||||
#!/bin/bash
|
||||
case "${1:-}" in
|
||||
-u) echo 1001 ;;
|
||||
-g) echo 1001 ;;
|
||||
-un) echo fixture-user ;;
|
||||
*) exec /bin/id "$@" ;;
|
||||
esac
|
||||
ID
|
||||
cat > "$good_bin/stat" <<'STAT'
|
||||
#!/bin/bash
|
||||
if [[ "${1:-} ${2:-}" == '-c %u' ]]; then echo 1001; exit 0; fi
|
||||
exec /bin/stat "$@"
|
||||
STAT
|
||||
cat > "$good_bin/curl" <<'CURL'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
CURL
|
||||
cat > "$good_bin/ldd" <<'LDD'
|
||||
#!/bin/bash
|
||||
echo 'ldd (GNU libc) 2.36'
|
||||
LDD
|
||||
chmod 0755 "$good_bin/id" "$good_bin/stat" "$good_bin/curl" "$good_bin/ldd"
|
||||
cat > "$good_prefix/bin/mosaic" <<'CLI'
|
||||
#!/usr/bin/env bash
|
||||
printf '0.0.50-next.999\n'
|
||||
CLI
|
||||
chmod 0755 "$good_prefix/bin/mosaic"
|
||||
cat > "$good_bin/getent" <<GETENT
|
||||
#!/bin/bash
|
||||
printf '%s:x:%s:%s::%s:%s\\n' '$(id -un)' '$(id -u)' '$(id -g)' '$good_home' '$good_bin/bash'
|
||||
GETENT
|
||||
cat > "$good_bin/bash" <<SHELL
|
||||
#!/bin/bash
|
||||
if [[ "\${*: -1}" == 'command -v mosaic' ]]; then
|
||||
printf '%s\\n' '$good_prefix/bin/mosaic'
|
||||
exit 0
|
||||
fi
|
||||
exec /bin/bash "\$@"
|
||||
SHELL
|
||||
chmod 0755 "$good_bin/getent" "$good_bin/bash"
|
||||
printf '# Soul\n\nConfigured.\n' > "$good_mosaic/SOUL.md"
|
||||
printf '# User\n\nConfigured.\n' > "$good_mosaic/USER.md"
|
||||
chmod 0600 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md"
|
||||
cat > "$good_mosaic/skills/declared-skill/SKILL.md" <<'SKILL'
|
||||
---
|
||||
name: declared-skill
|
||||
description: Constructed loadable acceptance skill.
|
||||
---
|
||||
|
||||
# Declared skill
|
||||
SKILL
|
||||
printf '{"lane":"next","version":"0.0.50-next.999","skills":["declared-skill"]}\n' > "$good_mosaic/.install-shipped-skills.json"
|
||||
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999"\n}\n' > "$good_mosaic/.install-manifest.json"
|
||||
before="$(fingerprint "$good_home")"
|
||||
set +e
|
||||
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/good-check.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$good_home")"
|
||||
[[ "$status" -eq 0 ]] && pass_case 'good-host --check exited zero' || fail_case "good-host --check exited $status"
|
||||
[[ "$before" == "$after" ]] && pass_case 'good-host --check left HOME unchanged' || fail_case 'good-host --check mutated HOME'
|
||||
good_rows="$(grep -Ec '^\[P[0-8]\] PASS:' "$TMP/good-check.log" || true)"
|
||||
[[ "$good_rows" -eq 9 ]] && pass_case 'good-host --check emitted nine PASS rows' \
|
||||
|| { cat "$TMP/good-check.log" >&2; fail_case "good-host --check emitted $good_rows PASS rows"; }
|
||||
|
||||
printf '[test] case: persisted required-action failures remain blocking\n'
|
||||
for blocked_phase in P4 P6; do
|
||||
node -e '
|
||||
const fs=require("fs"); const p=process.argv[1]; const phase=process.argv[2];
|
||||
const m=JSON.parse(fs.readFileSync(p,"utf8")); m.phaseOutcomes={P4:"committed",P6:"committed"};
|
||||
m.phaseOutcomes[phase]="failed"; fs.writeFileSync(p,JSON.stringify(m)+"\n");
|
||||
' "$good_mosaic/.install-manifest.json" "$blocked_phase"
|
||||
set +e
|
||||
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/action-$blocked_phase.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
[[ "$status" -ne 0 ]] || fail_case "$blocked_phase action failure returned zero"
|
||||
grep -q "^\[$blocked_phase\] FAIL:.*action reported a required $blocked_phase failure" "$TMP/action-$blocked_phase.log" \
|
||||
&& pass_case "$blocked_phase action failure remained blocking in a later --check" \
|
||||
|| fail_case "$blocked_phase persisted action failure was not attributed"
|
||||
done
|
||||
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999",\n "phaseOutcomes": {"P4":"committed","P6":"committed"}\n}\n' > "$good_mosaic/.install-manifest.json"
|
||||
|
||||
printf '[test] case: per-phase P2-P8 fault injection restores representative host mutations\n'
|
||||
for phase in P2 P3 P4 P5 P6 P7 P8; do
|
||||
home="$TMP/fault-$phase/home"
|
||||
state="$TMP/fault-$phase/state"
|
||||
mkdir -p "$home/.config/mosaic" "$home/.npm-global/bin" "$home/.claude" "$state"
|
||||
printf 'operator-framework-sentinel\n' > "$home/.config/mosaic/operator.txt"
|
||||
printf '@scope:registry=https://pre.example.invalid/\n' > "$home/.npmrc"
|
||||
printf 'old-cli\n' > "$home/.npm-global/bin/mosaic"
|
||||
printf '{"hooks":{"safe":true}}\n' > "$home/.claude/settings.json"
|
||||
before="$(fingerprint "$home")"
|
||||
set +e
|
||||
HOME="$home" MOSAIC_HOME="$home/.config/mosaic" MOSAIC_PREFIX="$home/.npm-global" \
|
||||
MOSAIC_INSTALL_STATE_DIR="$state" MOSAIC_INSTALL_FAULT_AFTER="$phase" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test \
|
||||
>"$TMP/fault-$phase.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$home")"
|
||||
[[ "$status" -ne 0 ]] || fail_case "$phase injected fault returned zero"
|
||||
grep -q "phase=$phase" "$TMP/fault-$phase.log" \
|
||||
|| fail_case "$phase fault transcript did not name the injected phase"
|
||||
[[ "$before" == "$after" ]] \
|
||||
&& pass_case "$phase rollback restored framework/npmrc/prefix/runtime representative state" \
|
||||
|| fail_case "$phase rollback mismatch (before=$before after=$after)"
|
||||
if find "$state" -type f -exec grep -l '"status"[[:space:]]*:[[:space:]]*"in-progress"' {} + 2>/dev/null | grep -q .; then
|
||||
fail_case "$phase left a journal in-progress"
|
||||
else
|
||||
pass_case "$phase left no journal falsely in-progress"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '[test] case: unsafe and overlapping rollback roots fail before mutation\n'
|
||||
unsafe_home="$TMP/unsafe-home"
|
||||
mkdir -p "$unsafe_home"
|
||||
for case_name in root-target home-target overlap-target; do
|
||||
case "$case_name" in
|
||||
root-target) unsafe_mosaic=/; unsafe_prefix="$unsafe_home/.npm-global" ;;
|
||||
home-target) unsafe_mosaic="$unsafe_home"; unsafe_prefix="$unsafe_home/.npm-global" ;;
|
||||
overlap-target) unsafe_mosaic="$unsafe_home/.config"; unsafe_prefix="$unsafe_home/.config/mosaic/prefix" ;;
|
||||
esac
|
||||
before="$(fingerprint "$unsafe_home")"
|
||||
set +e
|
||||
HOME="$unsafe_home" MOSAIC_HOME="$unsafe_mosaic" MOSAIC_PREFIX="$unsafe_prefix" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/$case_name.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$unsafe_home")"
|
||||
[[ "$status" -ne 0 ]] || fail_case "$case_name unsafe path returned zero"
|
||||
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/$case_name.log" \
|
||||
&& pass_case "$case_name was rejected by P0" || fail_case "$case_name lacked an attributable P0 failure"
|
||||
[[ "$before" == "$after" ]] || fail_case "$case_name mutated HOME"
|
||||
done
|
||||
|
||||
symlink_home="$TMP/symlink-home"
|
||||
symlink_outside="$TMP/symlink-outside"
|
||||
mkdir -p "$symlink_home" "$symlink_outside"
|
||||
ln -s "$symlink_outside" "$symlink_home/.config"
|
||||
set +e
|
||||
HOME="$symlink_home" MOSAIC_HOME="$symlink_home/.config/mosaic" MOSAIC_PREFIX="$symlink_home/.npm-global" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/symlink-target.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
[[ "$status" -ne 0 ]] || fail_case 'symlink-parent unsafe path returned zero'
|
||||
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/symlink-target.log" \
|
||||
&& pass_case 'symlinked rollback parent was rejected by P0' \
|
||||
|| fail_case 'symlinked rollback parent lacked an attributable P0 failure'
|
||||
[[ -z "$(find "$symlink_outside" -mindepth 1 -print -quit)" ]] || fail_case 'symlink target was mutated'
|
||||
|
||||
printf '[test] case: stale in-progress projection does not impersonate a live OS lock\n'
|
||||
stale_home="$TMP/stale/home"
|
||||
stale_state="$TMP/stale/state"
|
||||
mkdir -p "$stale_home/.config/mosaic" "$stale_state"
|
||||
printf '{"status":"in-progress","journal":"%s"}\n' "$stale_state/dead-run/journal.ndjson" > "$stale_state/active.json"
|
||||
set +e
|
||||
HOME="$stale_home" MOSAIC_HOME="$stale_home/.config/mosaic" MOSAIC_PREFIX="$stale_home/.npm-global" \
|
||||
MOSAIC_INSTALL_STATE_DIR="$stale_state" MOSAIC_INSTALL_FAULT_AFTER=P2 MOSAIC_NO_COLOR=1 \
|
||||
PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test >"$TMP/stale.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
[[ "$status" -eq 97 ]] || fail_case "stale projection recovery expected injected status 97, got $status"
|
||||
if find "$stale_state" -name prior-active.json -type f -print -quit | grep -q .; then
|
||||
pass_case 'stale projection was preserved and superseded after the free OS lock was acquired'
|
||||
else
|
||||
fail_case 'stale projection was not preserved for recovery evidence'
|
||||
fi
|
||||
[[ "$(node -p "require('$stale_state/active.json').status")" == "rolled-back" ]] \
|
||||
|| fail_case 'stale retry did not reach an honest rolled-back terminal state'
|
||||
|
||||
printf '[test] case: journal initialization failure is fatal before mutation\n'
|
||||
journal_home="$TMP/journal-failure/home"
|
||||
mkdir -p "$journal_home/.config/mosaic"
|
||||
printf 'journal-sentinel\n' > "$journal_home/.config/mosaic/operator.txt"
|
||||
before="$(fingerprint "$journal_home")"
|
||||
set +e
|
||||
HOME="$journal_home" MOSAIC_HOME="$journal_home/.config/mosaic" MOSAIC_PREFIX="$journal_home/.npm-global" \
|
||||
MOSAIC_INSTALL_STATE_DIR="/proc/mosaic-journal-denied-$$" MOSAIC_INSTALL_FAULT_AFTER=P2 \
|
||||
MOSAIC_NO_COLOR=1 bash "$ROOT/tools/install.sh" --state-machine-self-test \
|
||||
>"$TMP/journal-failure.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$journal_home")"
|
||||
[[ "$status" -ne 0 ]] && pass_case 'unwritable journal directory failed non-zero' \
|
||||
|| fail_case 'unwritable journal directory returned zero'
|
||||
grep -q 'cannot create private journal directory' "$TMP/journal-failure.log" \
|
||||
&& pass_case 'journal initialization failure was named' \
|
||||
|| fail_case 'journal initialization failure lacked a named diagnostic'
|
||||
[[ "$before" == "$after" ]] && pass_case 'journal failure occurred before target mutation' \
|
||||
|| fail_case "journal failure mutated target HOME (before=$before after=$after)"
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
printf '[test] install state-machine acceptance RED: %d failed assertion(s)\n' "$failures" >&2
|
||||
printf '[test] --check transcript: %s\n' "$TMP/check.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '[test] installer state-machine acceptance passed\n'
|
||||
+1148
-61
File diff suppressed because it is too large
Load Diff
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify that the detector found exactly the pinned C1 phase verdicts. The
|
||||
# fixture is expected to exit non-zero; this verifier is the green CI contract.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MANIFEST="${MOSAIC_EXPECTED_RED_MANIFEST:-$ROOT/tools/fixtures/greenfield-expected-red.tsv}"
|
||||
CASE="${1:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||
LOG="${2:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||
FIXTURE_EXIT="${3:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||
|
||||
[[ -r "$MANIFEST" ]] || { echo "expected-RED manifest is unreadable: $MANIFEST" >&2; exit 2; }
|
||||
[[ -r "$LOG" ]] || { echo "fixture log is unreadable: $LOG" >&2; exit 2; }
|
||||
[[ "$FIXTURE_EXIT" =~ ^[0-9]+$ ]] || { echo "fixture exit is not numeric: $FIXTURE_EXIT" >&2; exit 2; }
|
||||
|
||||
checks=0
|
||||
failures=0
|
||||
while IFS=$'\t' read -r case_name kind expectation; do
|
||||
[[ -n "$case_name" && "${case_name:0:1}" != "#" ]] || continue
|
||||
[[ "$case_name" == "$CASE" ]] || continue
|
||||
checks=$((checks + 1))
|
||||
case "$kind" in
|
||||
exit)
|
||||
if [[ "$FIXTURE_EXIT" != "$expectation" ]]; then
|
||||
echo "expected-RED mismatch: case=$CASE fixture_exit=$FIXTURE_EXIT expected=$expectation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
phase)
|
||||
phase="${expectation%%=*}"
|
||||
expected_verdict="${expectation#*=}"
|
||||
last_row="$(grep -E "^\[$phase\] (PASS|FAIL):" "$LOG" | tail -n 1 || true)"
|
||||
actual_verdict="$(printf '%s\n' "$last_row" | sed -n "s/^\[$phase\] \(PASS\|FAIL\):.*/\1/p")"
|
||||
if [[ "$actual_verdict" != "$expected_verdict" ]]; then
|
||||
echo "expected-RED mismatch: case=$CASE phase=$phase got=${actual_verdict:-missing} expected=$expected_verdict" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
require)
|
||||
if ! grep -Eq -- "$expectation" "$LOG"; then
|
||||
echo "expected-RED missing required evidence: case=$CASE regex=$expectation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
forbid)
|
||||
if grep -Eq -- "$expectation" "$LOG"; then
|
||||
echo "expected-RED found forbidden evidence: case=$CASE regex=$expectation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "invalid expected-RED manifest kind: case=$case_name kind=$kind" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done < "$MANIFEST"
|
||||
|
||||
[[ "$checks" -gt 0 ]] || { echo "expected-RED manifest has no checks for case=$CASE" >&2; exit 2; }
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "expected-RED verification failed: case=$CASE failures=$failures checks=$checks" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'expected-RED verification passed: case=%s checks=%d\n' "$CASE" "$checks"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-expected-red-test.XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
cat > "$TMP/match.log" <<'LOG'
|
||||
[fixture] resolved lane=next package=@mosaicstack/mosaic@next version=0.0.50-next.999
|
||||
[fixture] installer_exit=1 done_claims=0
|
||||
[P0] PASS: supported context
|
||||
[P1] PASS: preflight complete
|
||||
[P2] PASS: pinned artifact
|
||||
[P3] PASS: absolute_path=/home/test/.npm-global/bin/mosaic version=0.0.50-next.999 equals resolved lane version
|
||||
[P4] FAIL: NOT-MEASURED / UNDECLARED: declaration absent
|
||||
[P5] FAIL: identity absent
|
||||
[P6] FAIL: activation unavailable
|
||||
[P7] PASS: no services requested
|
||||
[P8] FAIL: shell path absent
|
||||
[P9] FAIL: aggregate refusal
|
||||
LOG
|
||||
|
||||
bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null
|
||||
printf '[test] PASS: matching detector findings make the CI verifier green\n'
|
||||
|
||||
sed 's/^\[P4\] FAIL:/[P4] PASS:/' "$TMP/match.log" > "$TMP/drift.log"
|
||||
if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/drift.log" 1 >/dev/null 2>&1; then
|
||||
echo '[test] FAIL: changed P4 verdict did not invalidate the pinned manifest' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '[test] PASS: changed phase verdict requires a deliberate manifest update\n'
|
||||
|
||||
if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 0 >/dev/null 2>&1; then
|
||||
echo '[test] FAIL: unexpected fixture exit did not invalidate the pinned manifest' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '[test] PASS: unexpected fixture exit remains blocking\n'
|
||||
Reference in New Issue
Block a user