Compare commits

..

2 Commits

Author SHA1 Message Date
Jarvis
58af3deb47 test(fleet): correct socket boundary coverage
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:34:54 -05:00
Jarvis
307b07d027 test(fleet): cover reconciler lifecycle gates
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:43:13 -05:00
139 changed files with 1150 additions and 23924 deletions

View File

@@ -42,27 +42,6 @@ steps:
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
# keep mode is a single cp-based path that must not depend on rsync), and that a
# corrupt/empty/missing manifest aborts fail-closed leaving operator files
# untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back
# from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves
# the retained, operator-scoped pre-update backup is taken before any mutation
# (0700/0600, secret never logged, retention-pruned) and that the post-sync
# verify net restores any operator file a manifest bug lets the sync touch. The
# migration matrix pins the v2→v3 contract-file semantics. Pure bash, no
# node_modules — runs early alongside sanitization.
upgrade-guard:
image: *node_image
commands:
- apk add --no-cache bash rsync
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
typecheck:
image: *node_image
commands:
@@ -71,7 +50,6 @@ steps:
depends_on:
- install
- sanitization
- upgrade-guard
# lint, format, and test are independent — run in parallel after typecheck
lint:

View File

@@ -22,10 +22,10 @@
FROM node:24-alpine
# Native toolchain required to compile node-gyp deps on musl, plus the
# postgresql-client used by the test step's pg_isready readiness probe. `bash`,
# `git`, and `jq` are baked here too — framework shell tests and the shipped
# Codex review wrappers require them without per-run installation in ci.yml.
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
# `apk add bash`.
RUN apk add --no-cache python3 make g++ postgresql-client bash
# Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate

View File

@@ -97,10 +97,7 @@ mosaic config path # Print config file path
```bash
mosaic doctor # Health audit — detect drift and missing files
mosaic sync # Sync skills from canonical source
mosaic skill list # Audit Claude skill registrations and conflicts
mosaic skill register <name> # Register one canonical skill with Claude Code
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
mosaic update # Update CLI/framework and auto-register canonical skills
mosaic update # Check for and install CLI updates
mosaic wizard # Full guided setup wizard
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
mosaic coord init # Initialize a new orchestration mission
@@ -352,8 +349,6 @@ 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.
## Contributing
```bash

View File

@@ -125,76 +125,6 @@ are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
---
## Exact Cross-Harness Fleet Communications Contract (#766)
### Problem and objective
Fleet runtime contracts currently combine exact peer rows with generic operational metavariables and
independently parsed roster data. Non-Claude harnesses can mistake those metavariables for values to
infer, producing incorrect host, session, socket, or helper targets. The objective is one
roster-resolved communications contract that every supported harness receives unchanged.
### Normative requirements
1. `FCOM-REQ-01`: Fleet commands and runtime composition SHALL use one shared v1 roster structural
resolver. A second lenient communications parser is forbidden.
2. `FCOM-REQ-02`: The composed contract SHALL render the local roster member's authoritative host,
exact agent/session name, resolved tmux socket, exact helper path, and deterministic communications
generation.
3. `FCOM-REQ-03`: Every known peer SHALL have one exact executable command. Same-host commands SHALL
omit `-H`; cross-host commands SHALL use only that peer's explicit roster `ssh` target; the one
supported fleet-wide named socket SHALL use `-L` with its exact value. A per-agent socket declaration
must equal that fleet-wide value; unsupported independent sockets and missing cross-host SSH data SHALL
fail closed.
4. `FCOM-REQ-04`: Operational fleet examples SHALL not contain unresolved host, session, socket, or
helper-path metavariables. Agents SHALL select an exact rendered peer row and SHALL NOT infer,
substitute, or fuzzy-match targeting values.
5. `FCOM-REQ-05`: An unknown local member or requested peer SHALL fail closed with exact-name discovery
guidance. Runtime composition SHALL not silently omit a requested fleet member's communications
contract.
6. `FCOM-REQ-06`: Claude Code, Codex, OpenCode, and Pi SHALL receive equivalent authoritative
communications data through the common runtime composer.
7. `FCOM-REQ-07`: Tests SHALL prove the contract from framework-source `TOOLS.md`, through a fresh
installed `TOOLS.md`, to final runtime composition and helper executability. User-owned installed
`TOOLS.md` content SHALL remain preserved.
8. `FCOM-REQ-08`: Stale installed or active composed context SHALL be reported with deterministic
generation/repair/relaunch guidance. Currency requires the expected source and installed contract
marker/version plus bounded byte equality. The supported current-version repair SHALL run independently
of package updates, preserve divergent `TOOLS.md` bytes in a digest-qualified no-clobber backup, restore
a regular executable helper without following symlinks, and be idempotent. Detection and reporting SHALL
NOT rewrite active context, restart a session, or mutate a live fleet.
9. `FCOM-REQ-09`: The shared resolver SHALL preserve and strictly validate every schema-supported v1
connector kind (`tmux`, `discord`, and `matrix`) from YAML and JSON. Every accepted snake/camel alias
pair SHALL reject differing dual declarations and accept identical declarations. JSON roster fallback
SHALL occur only when `roster.yaml` is absent; all other YAML access failures SHALL fail closed.
10. `FCOM-REQ-10`: The communications generation SHALL cover the complete canonical rendered semantic
contract, including identity, role/class, resolved host/socket/helper, peer metadata, and exact commands.
Installed helpers SHALL be validated with no-follow filesystem inspection as regular executable files.
Keep-mode reseed and relaunch discovery SHALL preserve and support both YAML and JSON rosters.
### Acceptance criteria
1. `AC-FCOM-01`: Contract fixtures contain no unresolved operational targeting metavariables; local
identity contains exact host/session/socket/helper values.
2. `AC-FCOM-02`: Same-host, cross-host, named-socket, literal-default-socket, and missing-SSH tests prove
exact targeting and fail-closed behavior.
3. `AC-FCOM-03`: Unknown identities and peers report known exact names plus an exact self-scoped
discovery command; no fuzzy session selection is emitted.
4. `AC-FCOM-04`: Four-harness tests prove byte-equal authoritative communications sections.
5. `AC-FCOM-05`: Source, fresh-install, preserved-custom-install, stale-installed, composed-generation,
helper executable, agent-send socket isolation, and exact-target tests pass.
6. `AC-FCOM-06`: Documentation defines non-mutating stale-context detection and operator-authorized,
exact-agent relaunch; no implementation path performs automatic session mutation.
7. `AC-FCOM-07`: YAML and JSON fixtures cover every connector kind; all snake/camel aliases cover
identical acceptance and conflicting rejection; non-`ENOENT` YAML failures do not fall back.
8. `AC-FCOM-08`: Missing, directory, symlink, and non-executable installed helpers fail closed. Explicit
current-version repair proves partial-deletion recovery, digest-qualified backup collision safety,
symlink-target safety, and repeated-run idempotence.
9. `AC-FCOM-09`: Markerless-equal and wrong-version source/installed contracts are stale, and a rendered
role/class change produces a different communications generation.
---
## KBN-101 Database Runtime/Migration Role Split (#771)
### Problem and objective

View File

@@ -1,10 +1,5 @@
# Documentation Sitemap
## 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.
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
## Fleet configuration management
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.

View File

@@ -1,352 +0,0 @@
# Compaction-Refresh WI-0 Gate0 Evidence Pack
- **Issue:** Gitea #827
- **Milestone:** 188 — Compaction-Refresh Mechanism
- **Branch:** `feat/827-gate0-probe`
- **Starting HEAD:** `d801d6c4c8a984d6a95033c49714210018d3d9a8`
- **Host/runtime:** Linux 6.1.0-48-amd64; Mosaic 0.0.48; Pi 0.80.7; Claude Code 2.1.205
- **Scope:** Probe fixtures and evidence only. No WI-1..WI-7 feature implementation.
## Verdict — 5/6 PASS; BUILD ADMISSION: **NO**
| Probe | Verdict | Short result |
| --- | --- | --- |
| P1 launcher topology + ancestry | **PASS** | Real Mosaic→Pi and Mosaic→Claude chains reached the registered anchor; real Claude `SessionStart` hook ancestry accepted; same-UID sibling with the minted victim ID rejected. |
| P2 Pi last-position + nonce map | **PASS** | Real Pi proved last-or-closed; `message_end` mapped exact `toolCallId → requestNonce` before `tool_call`; provider-response hook occurred before stream consumption/content completion. |
| P3 same-PID generation revocation | **PASS** | Same Pi PID/starttime persisted through reload/fork/new/resume while broker generations increased; reload revoked a prior `VERIFIED` generation. |
| P4 `SO_PEERCRED` + socket posture | **PASS** | Real Unix socket peer PID/UID/starttime matched `/proc`; 0700 directory + 0600 socket demonstrated. Same-UID counterfeit replacement remains explicitly T-C without a distinct principal/authenticated response. |
| P5 source invalidation | **PASS** | Missing, oversize, and hash-mismatched fragments each refused injection/promotion, revoked broker state, and blocked the exact emitted tool call. |
| P6 atomic injection | **T-C GAP** | Both runtimes empirically delivered a complete single block/message, but neither installed runtime contract states an **atomic/prefix-preserving** transport guarantee. Observation is not a guarantee; A-v5-1/T27 cannot be admitted. |
**Planner return item:** P6. The evidence establishes successful complete delivery in these runs, not the required invariant that the harness cannot middle-drop/replace bytes while preserving the terminal token. Per R1, such a middle-drop is not receipt-detectable. It is therefore classed **T-C**, not assumed away.
## STEP 0 — Authority re-verification
Command:
```bash
sha256sum \
~/agent-work/reviews/compaction-refresh-BUILD-BRIEF.md \
~/agent-work/reviews/compaction-refresh-SPEC-v5.md \
~/agent-work/reviews/compaction-refresh-SPEC-RATIFICATION.md
```
Captured result:
```text
89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b .../compaction-refresh-BUILD-BRIEF.md
a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433 .../compaction-refresh-SPEC-v5.md
bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67 .../compaction-refresh-SPEC-RATIFICATION.md
```
All three **MATCH**. They were read in full before probe construction. Raw artifact: [`evidence/raw/STEP0-authority-hashes.txt`](./evidence/raw/STEP0-authority-hashes.txt).
## Evidence method
The scripts under [`probes/`](./probes/) are isolated Gate0 instrumentation, not product implementation. They run the installed `mosaic yolo` launcher and real installed runtime binaries. Broker prototypes use Linux `SO_PEERCRED` and `/proc`; runtime adapters are temporary Claude hooks/Pi extensions. No product source under `packages/mosaic` was changed.
Raw-output artifact integrity is indexed at [`evidence/RAW-SHA256SUMS.txt`](./evidence/RAW-SHA256SUMS.txt).
---
## P1 — Launcher exec/parent topology + supported-hook ancestry (D1)
**Verdict: PASS**
### Commands
```bash
python3 docs/compaction-refresh/probes/p1_run.py --runtime both
rg -n "spawnSync|execRuntime" \
~/.npm-global/lib/node_modules/@mosaicstack/mosaic/dist/commands/launch.js | tail -8
```
Full outputs:
- [`evidence/raw/P1-launch-ancestry.txt`](./evidence/raw/P1-launch-ancestry.txt)
- [`evidence/raw/P1-claude-hook-events.txt`](./evidence/raw/P1-claude-hook-events.txt)
### Real topology observed
The installed Mosaic launcher does **not** replace itself with the runtime despite its `execRuntime` name; installed `launch.js:668` uses `spawnSync`. The Gate0 anchor first registered, then `execvpe` replaced the anchor with the real `mosaic yolo` process (PID/starttime retained). Mosaic remained the stable parent while it spawned the runtime.
Pi run:
```text
anchor before exec: pid=4010843 starttime=365919858 exe=/usr/bin/python3.11
anchor after exec: pid=4010843 starttime=365919858 exe=/usr/bin/node
Pi runtime: pid=4011046 ppid=4010843 starttime=365920219 exe=/usr/bin/node
ps:
4010843 4010840 Fri Jul 17 19:35:18 2026 1001 1001 node
4011046 4010843 Fri Jul 17 19:35:22 2026 1001 1001 pi
```
Claude supported-hook run (latest capture):
```text
hook python pid=4011380 starttime=365920845
-> /bin/sh pid=4011379 starttime=365920845
-> claude pid=4011285 starttime=365920748
-> node/mosaic anchor pid=4011129 starttime=365920380
```
The stream independently recorded the real hook firing:
```json
{"type":"system","subtype":"hook_started","hook_name":"SessionStart:startup","hook_event":"SessionStart"}
{"type":"system","subtype":"hook_response","hook_name":"SessionStart:startup","exit_code":0,"outcome":"success","stdout":"...GATE0_P1_SUPPORTED_HOOK_ANCESTRY_ACCEPTED..."}
```
### Authentication and sibling substitution
The broker minted the logical ID on first peercred contact and keyed the anchor by `(pid,starttime)`. It took the hook/extension PID from `SO_PEERCRED`, walked `/proc/<pid>/stat`, and re-read every starttime before accepting.
Real Pi acceptance excerpt:
```json
{"peercred":{"pid":4011046,"uid":1001,"gid":1001},"decision":"ACCEPT","reason":"ancestry-reaches-registered-anchor","starttimes_rechecked":true}
```
A separately spawned same-UID sibling was given the real minted victim ID. Its ancestry did not reach the anchor:
```json
{"attacker_uid":1001,"victim_session_id_known":true,"broker_decision":"REJECT","broker_reason":"victim-id-known-but-ancestry-mismatch"}
```
The same rejection occurred in both Pi and Claude runs. This is positive runtime evidence for D1/T15a under the supported non-daemonizing topology.
---
## P2 — Pi last-position invariant + nonce map (D5)
**Verdict: PASS**
### Commands
```bash
python3 docs/compaction-refresh/probes/pi_gate0_run.py
python3 docs/compaction-refresh/probes/p2_provider_timing_run.py
```
Full outputs:
- [`evidence/raw/P2-P3-P5-P6-pi.txt`](./evidence/raw/P2-P3-P5-P6-pi.txt)
- [`evidence/raw/P2-provider-timing.txt`](./evidence/raw/P2-provider-timing.txt)
### Last-or-closed evidence
Real Pi argv/load order with the probe last:
```json
{"extensions":[".../mosaic-extension.ts",".../pi_gate0_extension.ts"],"lastPosition":true,"gateState":"UNVERIFIED_READY","pid":4004545}
```
A second real Pi launch deliberately appended a later handler:
```json
{"extensions":[".../mosaic-extension.ts",".../pi_gate0_extension.ts",".../pi_later_extension.ts"],"lastPosition":false,"gateState":"CLOSED_NOT_LAST","pid":4005692}
```
Thus the invariant observed is exactly **last or closed**, not an asserted registration order.
### Exact nonce → tool-call-ID map
In one real GPT-5.6 Sol Pi response, sequence 5 completed the assistant tool-call message and bound its exact ID:
```json
{"seq":5,"event":"message_end","requestNonce":"e5a82358-a6c9-490b-a0de-2e1f1d9b8d79","toolCallIds":["call_bgGE...57c"],"nonceMappings":[{"toolCallId":"call_bgGE...57c","requestNonce":"e5a82358-a6c9-490b-a0de-2e1f1d9b8d79"}]}
```
The following `tool_call` was sequence 6 and carried the same ID/nonce:
```json
{"seq":6,"event":"tool_call","toolCallId":"call_bgGE...57c","mapping":{"nonce":"e5a82358-a6c9-490b-a0de-2e1f1d9b8d79","verified":true},"allowed":true}
```
The harmless tool executed at sequence 7 with that same tool-call ID. No session-global “current epoch” was borrowed.
### `after_provider_response` is not assistant-content observation
A deterministic localhost HTTP provider was used only to force headers/status exposure through the real Pi transport. Actual order:
```json
{"seq":4,"event":"before_provider_request"}
{"seq":5,"event":"after_provider_response","status":200,"assistantContentAvailableAtThisHook":false,"timing":"headers/status before stream consumption"}
{"seq":6,"event":"message_end","role":"assistant","assistantContentObserved":true}
```
```text
headers_hook_precedes_completed_message=True
```
This positively confirms SPEC-v5s precision correction: receipt content is observed at `message_end`; `after_provider_response` is status/headers before stream consumption.
---
## P3 — Same-PID `runtime_generation` bump revokes prior lease (D4)
**Verdict: PASS**
### Command
```bash
python3 docs/compaction-refresh/probes/pi_gate0_run.py
```
Full output: [`evidence/raw/P2-P3-P5-P6-pi.txt`](./evidence/raw/P2-P3-P5-P6-pi.txt).
The real Pi process identity remained:
```text
pid=4004545 starttime_ticks=365907677 uid=1001
```
Broker state around reload:
```json
{"event":"runtime_generation_bump","reason":"startup","old_generation":0,"new_generation":1,"new_lease_state":"UNVERIFIED"}
{"event":"probe_lease_promoted","generation":1,"new_lease_state":"VERIFIED"}
{"event":"runtime_generation_bump","phase":"shutdown","reason":"reload","old_generation":1,"new_generation":2,"prior_lease":"VERIFIED","prior_lease_revoked":true,"new_lease_state":"REVOKED"}
{"event":"runtime_generation_bump","phase":"start","reason":"reload","old_generation":2,"new_generation":3,"new_lease_state":"UNVERIFIED"}
```
The same `(pid,starttime)` then emitted monotonic bumps for real `fork`, `new`, and `resume` replacement flows, reaching generation 12. Pi 0.80.7 emitted an additional conservative `session_start` callback in each of those replacement flows; the broker bumped again rather than reusing authority. This is an availability/idempotence consideration for implementation, not a fail-open result.
---
## P4 — `SO_PEERCRED` + socket authenticity posture
**Verdict: PASS, with the specs named same-UID T-C residual**
### Command
```bash
python3 docs/compaction-refresh/probes/p4_peercred_probe.py
```
Full output: [`evidence/raw/P4-so-peercred.txt`](./evidence/raw/P4-so-peercred.txt).
Captured real socket result:
```text
server_pid=4013762 server_uid=1001 server_gid=1001
directory_mode=0700 socket_mode=0600
SO_PEERCRED pid=4013768 uid=1001 gid=1001
client_claim={"pid":4013768,"starttime_ticks":365927069,"uid":1001,...}
proc_observed={"pid":4013768,"starttime_ticks":365927069,"uid":1001,...}
pid_match=True
uid_match=True
starttime_match=True
client_exit_status=0
```
Achievable unprivileged posture on this host is a user-owned 0700 parent plus 0600 socket. That excludes other UIDs and positively authenticates the connecting kernel PID/UID/GID. It does **not** stop another process running as `hermes` from unlinking/rebinding the socket. A claim stronger than T-C against counterfeit replacement therefore requires the ratified distinct-principal system service or authenticated broker responses. No stronger claim is made.
---
## P5 — Source invalidation fail-closed
**Verdict: PASS**
### Command
```bash
python3 docs/compaction-refresh/probes/pi_gate0_run.py
```
Full output: [`evidence/raw/P2-P3-P5-P6-pi.txt`](./evidence/raw/P2-P3-P5-P6-pi.txt).
Each fault was injected into the manifest/source read by the real Pi `context` hook. Each run reached an actual model-produced `toolCallId`, then the runtime gate refused it:
| Fault | Runtime validation | Injection/promotion | Broker | Tool result |
| --- | --- | --- | --- | --- |
| Missing path | `reason=missing` | `injectionDecision=REFUSED`, `promotion=false` | `source_invalidation_revoke` | `allowed=false`, `unverified-source:missing` |
| 65 bytes with 64-byte max | `reason=oversize` | `REFUSED`, `promotion=false` | revoked | `allowed=false`, `unverified-source:oversize` |
| Bytes differ from pinned SHA-256 | `reason=hash-mismatch` | `REFUSED`, `promotion=false` | revoked | `allowed=false`, `unverified-source:hash-mismatch` |
Missing example:
```json
{"event":"context_return","sourceValidation":{"ok":false,"reason":"missing"},"injectionDecision":"REFUSED","promotion":false,"sourceBroker":{"event":"source_invalidation_revoke","new_lease_state":"REVOKED"}}
{"event":"tool_call","mapping":{"verified":false,"sourceReason":"missing"},"allowed":false,"reason":"unverified-source:missing"}
```
No fault case reached tool execution or promotion.
---
## P6 — Atomic Claude `additionalContext` + Pi `context` injection (A-v5-1 / T27)
**Verdict: T-C GAP — returns to planner**
### Commands
```bash
python3 docs/compaction-refresh/probes/pi_gate0_run.py
python3 docs/compaction-refresh/probes/p6_claude_run.py
rg -n -i "atomic|prefix-preserv" <installed Pi and Claude hook docs>
```
Full outputs:
- [`evidence/raw/P2-P3-P5-P6-pi.txt`](./evidence/raw/P2-P3-P5-P6-pi.txt)
- [`evidence/raw/P6-claude-additional-context.txt`](./evidence/raw/P6-claude-additional-context.txt)
- [`evidence/raw/P6-contract-gap.txt`](./evidence/raw/P6-contract-gap.txt)
### Positive empirical observations
**Pi:** The real `context` hook returned exactly one additional `AgentMessage`; the prior message prefix hash was unchanged. The real final provider payload contained exactly one occurrence in one content item, and the real model copied all bytes exactly:
```json
{"event":"context_return","inputCount":1,"outputCount":2,"injectionDecision":"ONE_ATOMIC_AGENT_MESSAGE","prefixPreservedByReturn":true,"blockLength":108,"blockSha256":"99c3...a0dd"}
{"event":"before_provider_request","markerOccurrences":1,"markerPaths":["$.input[1].content[0].text"],"finalPayloadValid":true}
{"event":"message_end","exactContextBlockCopied":true,"assistantTextSha256":"99c3...a0dd"}
```
**Claude:** The real `SessionStart` hook emitted one `hookSpecificOutput.additionalContext` string. Claudes stream recorded successful hook execution, and the real models exact copied block matched byte length and SHA-256:
```text
block_length=116
block_sha256=ef6377d63552af075f4f4adec00165988418c5f46a992f4dce8e678b56fd34ac
assistant_copy_length=116
assistant_copy_sha256=ef6377d63552af075f4f4adec00165988418c5f46a992f4dce8e678b56fd34ac
assistant_copy_exact=True
```
### Why this is not a PASS
The installed Pi documentation says only that `context` receives a deep copy and may return `{ messages }`. The installed Claude documentation says only that `additionalContext` enters/adds to context/system prompt. The exact search result was:
```text
NO MATCH: neither installed runtime document states an atomic/prefix-preserving transport guarantee.
```
One or several successful complete deliveries cannot prove the transport invariant needed by A-v5-1. In particular, a harness-side middle deletion/replacement that preserves the terminal receipt is not detectable by the receipt. That is precisely R1s assurance boundary. Therefore:
- absent or prefix-truncated terminal token: receipt-detectable;
- middle-drop preserving the tail token: **not receipt-detectable**;
- no documented runtime contract excludes that transform;
- classification: **T-C contract gap**.
No atomicity claim is inferred from empirical success.
---
## Independent probe review
After an initial review identified a session-global P2 correlation flaw, the probe was changed to queue request-scoped cycles from `before_provider_request` through assistant `message_end`; all runtime probes were re-run and raw checksums regenerated. The final independent review command was:
```bash
~/.config/mosaic/tools/codex/codex-code-review.sh \
-b d801d6c4c8a984d6a95033c49714210018d3d9a8 \
-o /tmp/827-gate0-rereview.json
```
Final review: **APPROVE**, confidence 0.91, 18 files reviewed, 0 blockers, 0 should-fix findings, 0 suggestions.
## Final admission decision
Gate0 requires every item to produce positive runtime evidence. P6 does not. **Do not admit WI-1..WI-7. Return A-v5-1/T27 to planner review.**
No feature work, push, PR, merge, or issue closure was performed.

View File

@@ -1,8 +0,0 @@
d19ed51612b52d8f5f4957321776e05157008d048b693217c03d71318dc4c763 docs/compaction-refresh/evidence/raw/P1-claude-hook-events.txt
c2d7bc21200063a4a0e61c67ba91abaf958ee88aa686e86f3f71c2717732b413 docs/compaction-refresh/evidence/raw/P1-launch-ancestry.txt
6efb12d908e9e20badcfda5b070aa1873409bd5a05f533f0b08bb1b4ef53d1a7 docs/compaction-refresh/evidence/raw/P2-P3-P5-P6-pi.txt
a9df6cc9f5d45f60d7d914ad1f80b9601574b82831101b3a10eccf1b93787e94 docs/compaction-refresh/evidence/raw/P2-provider-timing.txt
92e7aa7d69d53e58a151f9d56cfb583d90c206ecc0bc8a1b185e172c598fb177 docs/compaction-refresh/evidence/raw/P4-so-peercred.txt
047d235c6b6553158e27378c4ace081b094e5746734f4b5db6a6dc8ef9e05ff2 docs/compaction-refresh/evidence/raw/P6-claude-additional-context.txt
7df20b2878fc87aa4d1fc89121e494d8f4f7bf313b89e1e3147f16fdaa567cdd docs/compaction-refresh/evidence/raw/P6-contract-gap.txt
405bf3a06bf355d7f4f4d7b29d45a1ae70d93a690af5f7d0fc4819249e9f408f docs/compaction-refresh/evidence/raw/STEP0-authority-hashes.txt

View File

@@ -1,28 +0,0 @@
$ python3 docs/compaction-refresh/probes/p1_run.py --runtime claude
=== P1 CLAUDE REAL LAUNCH ===
$ python3 docs/compaction-refresh/probes/p1_anchor_exec.py --socket <protected-socket> claude <runtime args>
registered_anchor={"argc": 17, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 3933580, "ppid": 3933578, "starttime_ticks": 365788075}
broker_minted_session_id=ebe9f9146ad1ba5b9fd757fe9517d24b
hook_or_extension_record={"ancestry": [{"argc": 2, "argv0": "python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 3933983, "ppid": 3933982, "starttime_ticks": 365788568}, {"argc": 3, "argv0": "/bin/sh", "comm": "sh", "exe": "/usr/bin/dash", "pid": 3933982, "ppid": 3933751, "starttime_ticks": 365788568}, {"argc": 16, "argv0": "claude", "comm": "claude", "exe": "/home/hermes/.local/share/claude/versions/2.1.205", "pid": 3933751, "ppid": 3933580, "starttime_ticks": 365788474}, {"argc": 16, "argv0": "node", "comm": "node", "exe": "/usr/bin/node", "pid": 3933580, "ppid": 3933578, "starttime_ticks": 365788075}], "anchor": {"argc": 17, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 3933580, "ppid": 3933578, "starttime_ticks": 365788075}, "claimed_session_id": null, "decision": "ACCEPT", "event": "resolve-hook", "peercred": {"gid": 1001, "pid": 3933983, "uid": 1001}, "reason": "ancestry-reaches-registered-anchor", "resolved_session_id": "ebe9f9146ad1ba5b9fd757fe9517d24b", "starttimes_rechecked": true}
sibling_attack_record={"ancestry": [{"argc": 6, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 3933581, "ppid": 3933578, "starttime_ticks": 365788080}, {"argc": 4, "argv0": "python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 3933578, "ppid": 3933576, "starttime_ticks": 365788059}, {"argc": 3, "argv0": "/bin/bash", "comm": "bash", "exe": "/usr/bin/bash", "pid": 3933576, "ppid": 3933575, "starttime_ticks": 365788058}, {"argc": 3, "argv0": "/bin/bash", "comm": "bash", "exe": "/usr/bin/bash", "pid": 3933575, "ppid": 3888118, "starttime_ticks": 365788058}, {"argc": 1, "argv0": "pi", "comm": "pi", "exe": "/usr/bin/node", "pid": 3888118, "ppid": 3887912, "starttime_ticks": 365707392}, {"argc": 6, "argv0": "node", "comm": "node", "exe": "/usr/bin/node", "pid": 3887912, "ppid": 3887869, "starttime_ticks": 365707050}, {"argc": 1, "argv0": "-bash", "comm": "bash", "exe": "/usr/bin/bash", "pid": 3887869, "ppid": 1244054, "starttime_ticks": 365706948}, {"argc": 10, "argv0": "tmux", "comm": "tmux: server", "exe": "/usr/bin/tmux", "pid": 1244054, "ppid": 745, "starttime_ticks": 114078803}, {"argc": 2, "argv0": "/lib/systemd/systemd", "comm": "systemd", "exe": "/usr/lib/systemd/systemd", "pid": 745, "ppid": 1, "starttime_ticks": 627}], "anchor": {"argc": 17, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 3933580, "ppid": 3933578, "starttime_ticks": 365788075}, "claimed_session_id": "ebe9f9146ad1ba5b9fd757fe9517d24b", "decision": "REJECT", "event": "claim-session", "peercred": {"gid": 1001, "pid": 3933581, "uid": 1001}, "reason": "victim-id-known-but-ancestry-mismatch", "resolved_session_id": null, "starttimes_rechecked": false}
sibling_process_stdout={"attacker_pid": 3933581, "attacker_uid": 1001, "broker_decision": "REJECT", "broker_reason": "victim-id-known-but-ancestry-mismatch", "victim_session_id_known": true}
sibling_process_exit=0
ps_snapshot=<hook chain exited; broker /proc snapshot above is authoritative>
launcher_stderr_excerpt:
{"argv": ["mosaic", "yolo", "claude", "<12 runtime args>"], "event": "anchor-exec", "note": "os.execvpe retains pid and /proc starttime", "pid": 3933580}
runtime_stdout_excerpt:
[mosaic] Claude Code settings audit:
⚠ Missing PreToolUse hook: prevent-memory-write.sh
⚠ Missing PostToolUse hook: qa-hook-stdin.sh
⚠ Missing PostToolUse hook: typecheck-hook.sh
⚠ Missing plugin: feature-dev
⚠ Missing plugin: pr-review-toolkit
⚠ Missing plugin: code-review
runtime_hook_event_excerpt:
⚠ Missing PreToolUse hook: prevent-memory-write.sh
⚠ Missing PostToolUse hook: qa-hook-stdin.sh
⚠ Missing PostToolUse hook: typecheck-hook.sh
{"type":"system","subtype":"hook_started","hook_id":"cadd5ded-a869-4b05-85fc-cfd1a4988217","hook_name":"SessionStart:startup","hook_event":"SessionStart","uuid":"b63d67bf-2247-4e1c-b16b-7ccffa73180b","session_id":"97e1224c-7c1c-42c7-9fb5-598d2cd3dfaf"}
{"type":"system","subtype":"hook_response","hook_id":"cadd5ded-a869-4b05-85fc-cfd1a4988217","hook_name":"SessionStart:startup","hook_event":"SessionStart","output":"{\"hookSpecificOutput\": {\"hookEventName\": \"SessionStart\", \"additionalContext\": \"GATE0_P1_SUPPORTED_HOOK_ANCESTRY_ACCEPTED\"}}\n","stdout":"{\"hookSpecificOutput\": {\"hookEventName\": \"SessionStart\", \"additionalContext\": \"GATE0_P1_SUPPORTED_HOOK_ANCESTRY_ACCEPTED\"}}\n","stderr":"","exit_code":0,"outcome":"success","uuid":"b2bb583d-d287-4b56-8061-09153a32adc2","session_id":"97e1224c-7c1c-42c7-9fb5-598d2cd3dfaf"}

View File

@@ -1,62 +0,0 @@
$ python3 docs/compaction-refresh/probes/p1_run.py --runtime both
=== P1 PI REAL LAUNCH ===
machine_assertions=PASS
$ python3 docs/compaction-refresh/probes/p1_anchor_exec.py --socket <protected-socket> pi <runtime args>
registered_anchor={"argc": 13, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 4010843, "ppid": 4010840, "starttime_ticks": 365919858}
broker_minted_session_id=5207bd0d8251b616fe4df4c68f438830
hook_or_extension_record={"ancestry": [{"argc": 1, "argv0": "pi", "comm": "pi", "exe": "/usr/bin/node", "pid": 4011046, "ppid": 4010843, "starttime_ticks": 365920219}, {"argc": 12, "argv0": "node", "comm": "node", "exe": "/usr/bin/node", "pid": 4010843, "ppid": 4010840, "starttime_ticks": 365919858}], "anchor": {"argc": 13, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 4010843, "ppid": 4010840, "starttime_ticks": 365919858}, "claimed_session_id": null, "decision": "ACCEPT", "event": "resolve-hook", "peercred": {"gid": 1001, "pid": 4011046, "uid": 1001}, "reason": "ancestry-reaches-registered-anchor", "resolved_session_id": "5207bd0d8251b616fe4df4c68f438830", "starttimes_rechecked": true}
sibling_attack_record={"ancestry": [{"argc": 6, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 4010844, "ppid": 4010840, "starttime_ticks": 365919864}, {"argc": 4, "argv0": "python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 4010840, "ppid": 4010838, "starttime_ticks": 365919843}, {"argc": 3, "argv0": "/bin/bash", "comm": "bash", "exe": "/usr/bin/bash", "pid": 4010838, "ppid": 4010837, "starttime_ticks": 365919843}, {"argc": 3, "argv0": "/bin/bash", "comm": "bash", "exe": "/usr/bin/bash", "pid": 4010837, "ppid": 3888118, "starttime_ticks": 365919842}, {"argc": 1, "argv0": "pi", "comm": "pi", "exe": "/usr/bin/node", "pid": 3888118, "ppid": 3887912, "starttime_ticks": 365707392}, {"argc": 6, "argv0": "node", "comm": "node", "exe": "/usr/bin/node", "pid": 3887912, "ppid": 3887869, "starttime_ticks": 365707050}, {"argc": 1, "argv0": "-bash", "comm": "bash", "exe": "/usr/bin/bash", "pid": 3887869, "ppid": 1244054, "starttime_ticks": 365706948}, {"argc": 10, "argv0": "tmux", "comm": "tmux: server", "exe": "/usr/bin/tmux", "pid": 1244054, "ppid": 745, "starttime_ticks": 114078803}, {"argc": 2, "argv0": "/lib/systemd/systemd", "comm": "systemd", "exe": "/usr/lib/systemd/systemd", "pid": 745, "ppid": 1, "starttime_ticks": 627}], "anchor": {"argc": 13, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 4010843, "ppid": 4010840, "starttime_ticks": 365919858}, "claimed_session_id": "5207bd0d8251b616fe4df4c68f438830", "decision": "REJECT", "event": "claim-session", "peercred": {"gid": 1001, "pid": 4010844, "uid": 1001}, "reason": "victim-id-known-but-ancestry-mismatch", "resolved_session_id": null, "starttimes_rechecked": false}
sibling_process_stdout={"attacker_pid": 4010844, "attacker_uid": 1001, "broker_decision": "REJECT", "broker_reason": "victim-id-known-but-ancestry-mismatch", "victim_session_id_known": true}
sibling_process_exit=0
$ ps -o pid=,ppid=,lstart=,uid=,gid=,comm= -p 4011046,4010843
4010843 4010840 Fri Jul 17 19:35:18 2026 1001 1001 node
4011046 4010843 Fri Jul 17 19:35:22 2026 1001 1001 pi
launcher_stderr_excerpt:
{"argv": ["mosaic", "yolo", "pi", "<8 runtime args>"], "event": "anchor-exec", "note": "os.execvpe retains pid and /proc starttime", "pid": 4010843}
runtime_stdout_excerpt:
[mosaic] Launching Pi in YOLO mode...
{"type":"extension_ui_request","id":"4cf086c7-299e-4734-9264-6ad2964f3664","method":"notify","message":"Mosaic framework loaded","notifyType":"info"}
{"id":"state","type":"response","command":"get_state","success":true,"data":{"model":{"id":"gpt-5.6-sol","name":"GPT-5.6 Sol","api":"openai-codex-responses","provider":"openai-codex","baseUrl":"https://chatgpt.com/backend-api","compat":{"supportsToolSearch":true},"reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh","max":"max","minimal":"low"},"input":["text","image"],"cost":{"input":5,"output":30,"cacheRead":0.5,"cacheWrite":6.25,"tiers":[{"inputTokensAbove":272000,"input":10,"output":45,"cache
runtime_hook_event_excerpt:
=== P1 CLAUDE REAL LAUNCH ===
machine_assertions=PASS
$ python3 docs/compaction-refresh/probes/p1_anchor_exec.py --socket <protected-socket> claude <runtime args>
registered_anchor={"argc": 17, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 4011129, "ppid": 4010840, "starttime_ticks": 365920380}
broker_minted_session_id=f382fa5f4b2142ef79bb76204521ff2a
hook_or_extension_record={"ancestry": [{"argc": 2, "argv0": "python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 4011380, "ppid": 4011379, "starttime_ticks": 365920845}, {"argc": 3, "argv0": "/bin/sh", "comm": "sh", "exe": "/usr/bin/dash", "pid": 4011379, "ppid": 4011285, "starttime_ticks": 365920845}, {"argc": 16, "argv0": "claude", "comm": "claude", "exe": "/home/hermes/.local/share/claude/versions/2.1.205", "pid": 4011285, "ppid": 4011129, "starttime_ticks": 365920748}, {"argc": 16, "argv0": "node", "comm": "node", "exe": "/usr/bin/node", "pid": 4011129, "ppid": 4010840, "starttime_ticks": 365920380}], "anchor": {"argc": 17, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 4011129, "ppid": 4010840, "starttime_ticks": 365920380}, "claimed_session_id": null, "decision": "ACCEPT", "event": "resolve-hook", "peercred": {"gid": 1001, "pid": 4011380, "uid": 1001}, "reason": "ancestry-reaches-registered-anchor", "resolved_session_id": "f382fa5f4b2142ef79bb76204521ff2a", "starttimes_rechecked": true}
sibling_attack_record={"ancestry": [{"argc": 6, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 4011130, "ppid": 4010840, "starttime_ticks": 365920385}, {"argc": 4, "argv0": "python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 4010840, "ppid": 4010838, "starttime_ticks": 365919843}, {"argc": 3, "argv0": "/bin/bash", "comm": "bash", "exe": "/usr/bin/bash", "pid": 4010838, "ppid": 4010837, "starttime_ticks": 365919843}, {"argc": 3, "argv0": "/bin/bash", "comm": "bash", "exe": "/usr/bin/bash", "pid": 4010837, "ppid": 3888118, "starttime_ticks": 365919842}, {"argc": 1, "argv0": "pi", "comm": "pi", "exe": "/usr/bin/node", "pid": 3888118, "ppid": 3887912, "starttime_ticks": 365707392}, {"argc": 6, "argv0": "node", "comm": "node", "exe": "/usr/bin/node", "pid": 3887912, "ppid": 3887869, "starttime_ticks": 365707050}, {"argc": 1, "argv0": "-bash", "comm": "bash", "exe": "/usr/bin/bash", "pid": 3887869, "ppid": 1244054, "starttime_ticks": 365706948}, {"argc": 10, "argv0": "tmux", "comm": "tmux: server", "exe": "/usr/bin/tmux", "pid": 1244054, "ppid": 745, "starttime_ticks": 114078803}, {"argc": 2, "argv0": "/lib/systemd/systemd", "comm": "systemd", "exe": "/usr/lib/systemd/systemd", "pid": 745, "ppid": 1, "starttime_ticks": 627}], "anchor": {"argc": 17, "argv0": "/usr/bin/python3", "comm": "python3", "exe": "/usr/bin/python3.11", "pid": 4011129, "ppid": 4010840, "starttime_ticks": 365920380}, "claimed_session_id": "f382fa5f4b2142ef79bb76204521ff2a", "decision": "REJECT", "event": "claim-session", "peercred": {"gid": 1001, "pid": 4011130, "uid": 1001}, "reason": "victim-id-known-but-ancestry-mismatch", "resolved_session_id": null, "starttimes_rechecked": false}
sibling_process_stdout={"attacker_pid": 4011130, "attacker_uid": 1001, "broker_decision": "REJECT", "broker_reason": "victim-id-known-but-ancestry-mismatch", "victim_session_id_known": true}
sibling_process_exit=0
ps_snapshot=<hook chain exited; broker /proc snapshot above is authoritative>
launcher_stderr_excerpt:
{"argv": ["mosaic", "yolo", "claude", "<12 runtime args>"], "event": "anchor-exec", "note": "os.execvpe retains pid and /proc starttime", "pid": 4011129}
runtime_stdout_excerpt:
[mosaic] Claude Code settings audit:
⚠ Missing PreToolUse hook: prevent-memory-write.sh
⚠ Missing PostToolUse hook: qa-hook-stdin.sh
⚠ Missing PostToolUse hook: typecheck-hook.sh
⚠ Missing plugin: feature-dev
⚠ Missing plugin: pr-review-toolkit
⚠ Missing plugin: code-review
runtime_hook_event_excerpt:
⚠ Missing PreToolUse hook: prevent-memory-write.sh
⚠ Missing PostToolUse hook: qa-hook-stdin.sh
⚠ Missing PostToolUse hook: typecheck-hook.sh
{"type":"system","subtype":"hook_started","hook_id":"2a5f7dab-a064-4610-a6b1-4ad151ddcdd9","hook_name":"SessionStart:startup","hook_event":"SessionStart","uuid":"c6e0690c-f0c9-4d60-a8fb-5f0c25ea3208","session_id":"167d104d-907a-4120-9b07-bdf4762818a9"}
{"type":"system","subtype":"hook_response","hook_id":"2a5f7dab-a064-4610-a6b1-4ad151ddcdd9","hook_name":"SessionStart:startup","hook_event":"SessionStart","output":"{\"hookSpecificOutput\": {\"hookEventName\": \"SessionStart\", \"additionalContext\": \"GATE0_P1_SUPPORTED_HOOK_ANCESTRY_ACCEPTED\"}}\n","stdout":"{\"hookSpecificOutput\": {\"hookEventName\": \"SessionStart\", \"additionalContext\": \"GATE0_P1_SUPPORTED_HOOK_ANCESTRY_ACCEPTED\"}}\n","stderr":"","exit_code":0,"outcome":"success","uuid":"167b2a5b-a45a-4e70-a72b-1f4a609bb979","session_id":"167d104d-907a-4120-9b07-bdf4762818a9"}
$ readlink -f "$(command -v mosaic)"
/home/hermes/.npm-global/lib/node_modules/@mosaicstack/mosaic/dist/cli.js
$ rg -n "spawnSync|execRuntime" ~/.npm-global/lib/node_modules/@mosaicstack/mosaic/dist/commands/launch.js | tail -8
63: spawnSync(initBin, [], { stdio: 'inherit' });
131: const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
624: execRuntime('claude', cliArgs);
637: execRuntime('codex', cliArgs);
643: execRuntime('opencode', args);
658: execRuntime('pi', cliArgs);
665:function execRuntime(cmd, args) {
668: const result = spawnSync(cmd, args, {

View File

@@ -1,52 +0,0 @@
$ python3 docs/compaction-refresh/probes/pi_gate0_run.py
machine_assertions=PASS
runtime_versions:
0.80.7
0.0.48
P2_EVENT_ORDER_AND_NONCE_MAP:
{"assistantContentObserved": true, "assistantTextSha256": "a36f1eb364f062cad2f9f7d7e2b62ef7715d2aef79caafcfccd3a227cecf3e61", "event": "message_end", "exactContextBlockCopied": false, "inFlightDepthAfter": 0, "nonceMappings": [{"requestNonce": "e5a82358-a6c9-490b-a0de-2e1f1d9b8d79", "toolCallId": "call_bgGEFnBJOmwJPEfmzMo1eHOy|fc_0fb3d12b5404a73c016a5ac9d6f9a4819b9ddf70296c0cf57c"}], "pid": 4004545, "requestNonce": "e5a82358-a6c9-490b-a0de-2e1f1d9b8d79", "role": "assistant", "seq": 5, "starttime_ticks": 365907677, "toolCallIds": ["call_bgGEFnBJOmwJPEfmzMo1eHOy|fc_0fb3d12b5404a73c016a5ac9d6f9a4819b9ddf70296c0cf57c"]}
{"allowed": true, "event": "tool_call", "mapping": {"nonce": "e5a82358-a6c9-490b-a0de-2e1f1d9b8d79", "sourceReason": "all-fragments-valid", "verified": true}, "pid": 4004545, "reason": "exact-tool-call-id-mapped-to-verified-request-nonce", "seq": 6, "starttime_ticks": 365907677, "toolCallId": "call_bgGEFnBJOmwJPEfmzMo1eHOy|fc_0fb3d12b5404a73c016a5ac9d6f9a4819b9ddf70296c0cf57c", "toolName": "gate0_nonce_probe"}
{"broker": {"event": "probe_lease_promoted", "generation": 1, "new_lease_state": "VERIFIED", "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "starttime_ticks": 365907677}, "event": "tool_execute", "label": "p2", "pid": 4004545, "seq": 7, "starttime_ticks": 365907677, "toolCallId": "call_bgGEFnBJOmwJPEfmzMo1eHOy|fc_0fb3d12b5404a73c016a5ac9d6f9a4819b9ddf70296c0cf57c"}
{"assistantContentObserved": true, "assistantTextSha256": "99c3dce194b16405dfb555f126ee5ccc014fdc184d0882aee1a903cbc700a0dd", "event": "message_end", "exactContextBlockCopied": true, "inFlightDepthAfter": 0, "nonceMappings": [], "pid": 4004545, "requestNonce": "cca4b1e3-296a-4e4c-9805-a395c270c01f", "role": "assistant", "seq": 11, "starttime_ticks": 365907677, "toolCallIds": []}
P2_LAST_OR_CLOSED:
{"broker": {"event": "runtime_generation_bump", "new_generation": 1, "new_lease_state": "UNVERIFIED", "old_generation": 0, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "start", "prior_lease": "NONE", "prior_lease_revoked": true, "reason": "startup", "starttime_ticks": 365907677}, "event": "session_start", "extensions": ["/home/hermes/.config/mosaic/runtime/pi/mosaic-extension.ts", "/home/hermes/agent-work/stack-cr-wi0-gate0/docs/compaction-refresh/probes/pi_gate0_extension.ts"], "gateState": "UNVERIFIED_READY", "lastPosition": true, "pid": 4004545, "reason": "startup", "self": "/home/hermes/agent-work/stack-cr-wi0-gate0/docs/compaction-refresh/probes/pi_gate0_extension.ts", "seq": 1, "starttime_ticks": 365907677}
{"broker": {"skipped": true}, "event": "session_start", "extensions": ["/home/hermes/.config/mosaic/runtime/pi/mosaic-extension.ts", "/home/hermes/agent-work/stack-cr-wi0-gate0/docs/compaction-refresh/probes/pi_gate0_extension.ts", "/home/hermes/agent-work/stack-cr-wi0-gate0/docs/compaction-refresh/probes/pi_later_extension.ts"], "gateState": "CLOSED_NOT_LAST", "lastPosition": false, "pid": 4005692, "reason": "startup", "self": "/home/hermes/agent-work/stack-cr-wi0-gate0/docs/compaction-refresh/probes/pi_gate0_extension.ts", "seq": 1, "starttime_ticks": 365910545}
P3_GENERATION_BROKER:
{"event": "runtime_generation_bump", "new_generation": 1, "new_lease_state": "UNVERIFIED", "old_generation": 0, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "start", "prior_lease": "NONE", "prior_lease_revoked": true, "reason": "startup", "starttime_ticks": 365907677}
{"event": "probe_lease_promoted", "generation": 1, "new_lease_state": "VERIFIED", "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "starttime_ticks": 365907677}
{"event": "runtime_generation_bump", "new_generation": 2, "new_lease_state": "REVOKED", "old_generation": 1, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "shutdown", "prior_lease": "VERIFIED", "prior_lease_revoked": true, "reason": "reload", "starttime_ticks": 365907677}
{"event": "runtime_generation_bump", "new_generation": 3, "new_lease_state": "UNVERIFIED", "old_generation": 2, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "start", "prior_lease": "REVOKED", "prior_lease_revoked": true, "reason": "reload", "starttime_ticks": 365907677}
{"event": "runtime_generation_bump", "new_generation": 4, "new_lease_state": "REVOKED", "old_generation": 3, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "shutdown", "prior_lease": "UNVERIFIED", "prior_lease_revoked": true, "reason": "fork", "starttime_ticks": 365907677}
{"event": "runtime_generation_bump", "new_generation": 5, "new_lease_state": "UNVERIFIED", "old_generation": 4, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "start", "prior_lease": "REVOKED", "prior_lease_revoked": true, "reason": "fork", "starttime_ticks": 365907677}
{"event": "runtime_generation_bump", "new_generation": 6, "new_lease_state": "UNVERIFIED", "old_generation": 5, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "start", "prior_lease": "UNVERIFIED", "prior_lease_revoked": true, "reason": "fork", "starttime_ticks": 365907677}
{"event": "runtime_generation_bump", "new_generation": 7, "new_lease_state": "REVOKED", "old_generation": 6, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "shutdown", "prior_lease": "UNVERIFIED", "prior_lease_revoked": true, "reason": "new", "starttime_ticks": 365907677}
{"event": "runtime_generation_bump", "new_generation": 8, "new_lease_state": "UNVERIFIED", "old_generation": 7, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "start", "prior_lease": "REVOKED", "prior_lease_revoked": true, "reason": "new", "starttime_ticks": 365907677}
{"event": "runtime_generation_bump", "new_generation": 9, "new_lease_state": "UNVERIFIED", "old_generation": 8, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "start", "prior_lease": "UNVERIFIED", "prior_lease_revoked": true, "reason": "new", "starttime_ticks": 365907677}
{"event": "runtime_generation_bump", "new_generation": 10, "new_lease_state": "REVOKED", "old_generation": 9, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "shutdown", "prior_lease": "UNVERIFIED", "prior_lease_revoked": true, "reason": "resume", "starttime_ticks": 365907677}
{"event": "runtime_generation_bump", "new_generation": 11, "new_lease_state": "UNVERIFIED", "old_generation": 10, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "start", "prior_lease": "REVOKED", "prior_lease_revoked": true, "reason": "resume", "starttime_ticks": 365907677}
{"event": "runtime_generation_bump", "new_generation": 12, "new_lease_state": "UNVERIFIED", "old_generation": 11, "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "phase": "start", "prior_lease": "UNVERIFIED", "prior_lease_revoked": true, "reason": "resume", "starttime_ticks": 365907677}
P5_SOURCE_INVALIDATION:
{"blockLength": 108, "blockSha256": "99c3dce194b16405dfb555f126ee5ccc014fdc184d0882aee1a903cbc700a0dd", "event": "context_return", "injectionDecision": "REFUSED", "inputCount": 5, "lastPosition": true, "outputCount": 5, "pid": 4004545, "prefixHashAfter": "4f339e3e45989486374b75d8a40abad22a3f3091f1099e3b4112f3afd1c60eb0", "prefixHashBefore": "4f339e3e45989486374b75d8a40abad22a3f3091f1099e3b4112f3afd1c60eb0", "prefixPreservedByReturn": true, "promotion": false, "requestNonce": "24ef5352-bcc6-4418-b65f-c2763453cc46", "seq": 12, "sourceBroker": {"event": "source_invalidation_revoke", "generation": 12, "new_lease_state": "REVOKED", "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "prior_lease": "UNVERIFIED", "promotion": false, "source_reason": "missing", "starttime_ticks": 365907677}, "sourceValidation": {"fragment": "/tmp/gate0-pi-g_3gsk34/absent-fragment.md", "ok": false, "reason": "missing"}, "starttime_ticks": 365907677}
{"allowed": false, "event": "tool_call", "mapping": {"nonce": "24ef5352-bcc6-4418-b65f-c2763453cc46", "sourceReason": "missing", "verified": false}, "pid": 4004545, "reason": "unverified-source:missing", "seq": 15, "starttime_ticks": 365907677, "toolCallId": "call_XBwBkiv5tZx2ayuDxHKD5vSB|fc_0fb3d12b5404a73c016a5ac9dc341c819bb6ed3e00ca2cf1a5", "toolName": "gate0_nonce_probe"}
{"blockLength": 108, "blockSha256": "99c3dce194b16405dfb555f126ee5ccc014fdc184d0882aee1a903cbc700a0dd", "event": "context_return", "injectionDecision": "REFUSED", "inputCount": 9, "lastPosition": true, "outputCount": 9, "pid": 4004545, "prefixHashAfter": "1a39911018caefe8f5b5acb652cece9f92d937e7384109f5c1559266349480b7", "prefixHashBefore": "1a39911018caefe8f5b5acb652cece9f92d937e7384109f5c1559266349480b7", "prefixPreservedByReturn": true, "promotion": false, "requestNonce": "319646e8-59e2-4021-9b27-de1376b13c32", "seq": 22, "sourceBroker": {"event": "source_invalidation_revoke", "generation": 12, "new_lease_state": "REVOKED", "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "prior_lease": "REVOKED", "promotion": false, "source_reason": "oversize", "starttime_ticks": 365907677}, "sourceValidation": {"fragment": "/tmp/gate0-pi-g_3gsk34/oversize.md", "ok": false, "reason": "oversize"}, "starttime_ticks": 365907677}
{"allowed": false, "event": "tool_call", "mapping": {"nonce": "319646e8-59e2-4021-9b27-de1376b13c32", "sourceReason": "oversize", "verified": false}, "pid": 4004545, "reason": "unverified-source:oversize", "seq": 25, "starttime_ticks": 365907677, "toolCallId": "call_P9d0wR5TSXSqZHydVHclh6Cg|fc_0fb3d12b5404a73c016a5ac9e02a28819bb39df373b7c9e23b", "toolName": "gate0_nonce_probe"}
{"blockLength": 108, "blockSha256": "99c3dce194b16405dfb555f126ee5ccc014fdc184d0882aee1a903cbc700a0dd", "event": "context_return", "injectionDecision": "REFUSED", "inputCount": 13, "lastPosition": true, "outputCount": 13, "pid": 4004545, "prefixHashAfter": "224c8777dd0cd5fcf1ae02f0fc46198548b48647dbfd044ed131533d72086f16", "prefixHashBefore": "224c8777dd0cd5fcf1ae02f0fc46198548b48647dbfd044ed131533d72086f16", "prefixPreservedByReturn": true, "promotion": false, "requestNonce": "23f3125f-6e62-4f3c-aa60-3eaed705ddc1", "seq": 32, "sourceBroker": {"event": "source_invalidation_revoke", "generation": 12, "new_lease_state": "REVOKED", "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "prior_lease": "REVOKED", "promotion": false, "source_reason": "hash-mismatch", "starttime_ticks": 365907677}, "sourceValidation": {"fragment": "/tmp/gate0-pi-g_3gsk34/mismatch.md", "ok": false, "reason": "hash-mismatch"}, "starttime_ticks": 365907677}
{"allowed": false, "event": "tool_call", "mapping": {"nonce": "23f3125f-6e62-4f3c-aa60-3eaed705ddc1", "sourceReason": "hash-mismatch", "verified": false}, "pid": 4004545, "reason": "unverified-source:hash-mismatch", "seq": 35, "starttime_ticks": 365907677, "toolCallId": "call_QUMvqBRnzv6HNqEd37jU5NUw|fc_0fb3d12b5404a73c016a5ac9e37510819b8506879faf287aa3", "toolName": "gate0_nonce_probe"}
{"event": "source_invalidation_revoke", "generation": 12, "new_lease_state": "REVOKED", "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "prior_lease": "UNVERIFIED", "promotion": false, "source_reason": "missing", "starttime_ticks": 365907677}
{"event": "source_invalidation_revoke", "generation": 12, "new_lease_state": "REVOKED", "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "prior_lease": "REVOKED", "promotion": false, "source_reason": "oversize", "starttime_ticks": 365907677}
{"event": "source_invalidation_revoke", "generation": 12, "new_lease_state": "REVOKED", "peercred": {"gid": 1001, "pid": 4004545, "uid": 1001}, "prior_lease": "REVOKED", "promotion": false, "source_reason": "hash-mismatch", "starttime_ticks": 365907677}
P6_PI_CONTEXT_ATOMIC_OBSERVATION:
{"blockLength": 108, "blockSha256": "99c3dce194b16405dfb555f126ee5ccc014fdc184d0882aee1a903cbc700a0dd", "event": "context_return", "injectionDecision": "ONE_ATOMIC_AGENT_MESSAGE", "inputCount": 1, "lastPosition": true, "outputCount": 2, "pid": 4004545, "prefixHashAfter": "095a5415879b0d4006d1485dba3398fee6bf39850711ba0dc2e9cfa312e865dc", "prefixHashBefore": "095a5415879b0d4006d1485dba3398fee6bf39850711ba0dc2e9cfa312e865dc", "prefixPreservedByReturn": true, "promotion": false, "requestNonce": "e5a82358-a6c9-490b-a0de-2e1f1d9b8d79", "seq": 3, "sourceBroker": {"action": "none", "reason": "source-valid"}, "sourceValidation": {"ok": true, "reason": "all-fragments-valid"}, "starttime_ticks": 365907677}
{"event": "before_provider_request", "finalPayloadValid": true, "inFlightDepth": 1, "markerOccurrences": 1, "markerPaths": ["$.input[1].content[0].text"], "pid": 4004545, "requestNonce": "e5a82358-a6c9-490b-a0de-2e1f1d9b8d79", "seq": 4, "starttime_ticks": 365907677}
{"blockLength": 108, "blockSha256": "99c3dce194b16405dfb555f126ee5ccc014fdc184d0882aee1a903cbc700a0dd", "event": "context_return", "injectionDecision": "ONE_ATOMIC_AGENT_MESSAGE", "inputCount": 3, "lastPosition": true, "outputCount": 4, "pid": 4004545, "prefixHashAfter": "7c40cce3664af7581b21ea007a4a44764237fce1e4f16b031e96d60df2229855", "prefixHashBefore": "7c40cce3664af7581b21ea007a4a44764237fce1e4f16b031e96d60df2229855", "prefixPreservedByReturn": true, "promotion": false, "requestNonce": "cca4b1e3-296a-4e4c-9805-a395c270c01f", "seq": 9, "sourceBroker": {"action": "none", "reason": "source-valid"}, "sourceValidation": {"ok": true, "reason": "all-fragments-valid"}, "starttime_ticks": 365907677}
{"event": "before_provider_request", "finalPayloadValid": true, "inFlightDepth": 1, "markerOccurrences": 1, "markerPaths": ["$.input[5].content[0].text"], "pid": 4004545, "requestNonce": "cca4b1e3-296a-4e4c-9805-a395c270c01f", "seq": 10, "starttime_ticks": 365907677}
{"assistantContentObserved": true, "assistantTextSha256": "99c3dce194b16405dfb555f126ee5ccc014fdc184d0882aee1a903cbc700a0dd", "event": "message_end", "exactContextBlockCopied": true, "inFlightDepthAfter": 0, "nonceMappings": [], "pid": 4004545, "requestNonce": "cca4b1e3-296a-4e4c-9805-a395c270c01f", "role": "assistant", "seq": 11, "starttime_ticks": 365907677, "toolCallIds": []}
RPC_EVENT_COUNTS:
{"agent_end": 4, "agent_settled": 4, "agent_start": 4, "extension_ui_request": 8, "message_end": 16, "message_start": 16, "message_update": 105, "response": 9, "tool_execution_end": 4, "tool_execution_start": 4, "turn_end": 8, "turn_start": 8}
stderr_nonempty=False

View File

@@ -1,9 +0,0 @@
$ python3 docs/compaction-refresh/probes/p2_provider_timing_run.py
local_http_endpoint=http://127.0.0.1:42823/v1/chat/completions
{"event": "before_provider_request", "finalPayloadValid": true, "inFlightDepth": 1, "markerOccurrences": 1, "markerPaths": ["$.messages[2].content[0].text"], "pid": 4008778, "requestNonce": "056b82c4-36eb-420b-94cf-b2c73813ef79", "seq": 4, "starttime_ticks": 365916346}
{"assistantContentAvailableAtThisHook": false, "event": "after_provider_response", "pid": 4008778, "requestNonce": "056b82c4-36eb-420b-94cf-b2c73813ef79", "seq": 5, "starttime_ticks": 365916346, "status": 200, "timing": "headers/status before stream consumption"}
{"assistantContentObserved": true, "assistantTextSha256": "fb4ebaab26d63661040dc15925a99e22dc07ee2b33df5c6b2ca93a5b34f08b1d", "event": "message_end", "exactContextBlockCopied": false, "inFlightDepthAfter": 0, "nonceMappings": [], "pid": 4008778, "requestNonce": "056b82c4-36eb-420b-94cf-b2c73813ef79", "role": "assistant", "seq": 6, "starttime_ticks": 365916346, "toolCallIds": []}
machine_assertions=PASS
after_provider_response_seq=5
message_end_seq=6
headers_hook_precedes_completed_message=True

View File

@@ -1,23 +0,0 @@
$ python3 docs/compaction-refresh/probes/p4_peercred_probe.py
machine_assertions=PASS
server_pid=4013762 server_uid=1001 server_gid=1001
socket_path=/tmp/gate0-p4-nl1_8ap2/broker.sock
directory_mode=0700 socket_mode=0600
SO_PEERCRED pid=4013768 uid=1001 gid=1001
client_claim={"exe": "/usr/bin/python3.11", "pid": 4013768, "ppid": 4013762, "starttime_ticks": 365927069, "uid": 1001}
proc_observed={"exe": "/usr/bin/python3.11", "pid": 4013768, "ppid": 4013762, "starttime_ticks": 365927069, "uid": 1001}
pid_match=True
uid_match=True
starttime_match=True
client_exit_status=0
same_principal_socket=true
posture=0700 parent + 0600 socket excludes other UIDs, but does not prevent the same UID from unlinking/rebinding; distinct-principal system service remains required for a claim stronger than T-C against same-UID counterfeit replacement
$ id
uid=1001(hermes) gid=1001(hermes) groups=1001(hermes),40(src),100(users),996(docker)
$ uname -srmo
Linux 6.1.0-48-amd64 x86_64 GNU/Linux
$ getconf CLK_TCK
100

View File

@@ -1,21 +0,0 @@
$ python3 docs/compaction-refresh/probes/p6_claude_run.py
machine_assertions=PASS
command=mosaic yolo claude --settings <isolated> --model haiku --print --output-format stream-json --verbose --include-hook-events <prompt>
claude_version=2.1.205 (Claude Code)
mosaic_version=0.0.48
exit_code=0
hook_process_log={"block_length": 116, "block_sha256": "ef6377d63552af075f4f4adec00165988418c5f46a992f4dce8e678b56fd34ac", "emission": "one hookSpecificOutput.additionalContext string field", "hook_event_name": "SessionStart", "pid": 4015703, "ppid": 4015701, "starttime_ticks": 365930489}
hook_stream_event={"hook_event": "SessionStart", "hook_id": "557d613e-574f-4523-8bfb-8c6e51946035", "hook_name": "SessionStart:startup", "session_id": "f821d0db-1177-4237-8ff5-83b2a46996a6", "subtype": "hook_started", "type": "system", "uuid": "59449134-e2d4-43a9-9d34-b59e74622c08"}
hook_stream_event={"exit_code": 0, "hook_event": "SessionStart", "hook_id": "557d613e-574f-4523-8bfb-8c6e51946035", "hook_name": "SessionStart:startup", "outcome": "success", "output": "{\"hookSpecificOutput\": {\"hookEventName\": \"SessionStart\", \"additionalContext\": \"GATE0_CLAUDE_ATOMIC_BEGIN\\nsegment-01=alpha-2d11\\nsegment-02=middle-8e22\\nsegment-03=omega-4f33\\nGATE0_CLAUDE_ATOMIC_END\"}}\n", "session_id": "f821d0db-1177-4237-8ff5-83b2a46996a6", "stderr": "", "stdout": "{\"hookSpecificOutput\": {\"hookEventName\": \"SessionStart\", \"additionalContext\": \"GATE0_CLAUDE_ATOMIC_BEGIN\\nsegment-01=alpha-2d11\\nsegment-02=middle-8e22\\nsegment-03=omega-4f33\\nGATE0_CLAUDE_ATOMIC_END\"}}\n", "subtype": "hook_response", "type": "system", "uuid": "e05842c1-813a-41dd-93c9-768eb834f260"}
block_length=116
block_sha256=ef6377d63552af075f4f4adec00165988418c5f46a992f4dce8e678b56fd34ac
stream_fields_containing_full_block=3
stream_fields_exactly_equal_block=2
assistant_copy_length=449
assistant_copy_sha256=a65febbb3ad8fa4952894d94406a83a520ef712c0ecb9ab43be3212094b21ba1
assistant_copy_exact=False
assistant_copy="The user is asking me to return the exact GATE0_CLAUDE_ATOMIC block that was injected by SessionStart. This block was provided in the system-reminder at the beginning of the conversation:\n\n```\nGATE0_CLAUDE_ATOMIC_BEGIN\nsegment-01=alpha-2d11\nsegment-02=middle-8e22\nsegment-03=omega-4f33\nGATE0_CLAUDE_ATOMIC_END\n```\n\nThe user wants me to return ONLY this exact block, with no code fence or commentary. So I should just output it exactly as it appears."
assistant_copy_length=116
assistant_copy_sha256=ef6377d63552af075f4f4adec00165988418c5f46a992f4dce8e678b56fd34ac
assistant_copy_exact=True
assistant_copy="GATE0_CLAUDE_ATOMIC_BEGIN\nsegment-01=alpha-2d11\nsegment-02=middle-8e22\nsegment-03=omega-4f33\nGATE0_CLAUDE_ATOMIC_END"

View File

@@ -1,47 +0,0 @@
$ rg -n -i "atomic|prefix-preserv" <Pi extensions docs> <Claude hook docs>
NO MATCH: neither installed runtime document states an atomic/prefix-preserving transport guarantee.
$ rg -n -C 3 "#### context|event.messages - deep copy|return \{ messages" <Pi extensions docs>
638-});
639-```
640-
641:#### context
642-
643-Fired before each LLM call. Modify messages non-destructively. See [Session Format](session-format.md) for message types.
644-
645-```typescript
646-pi.on("context", async (event, ctx) => {
647: // event.messages - deep copy, safe to modify
648- const filtered = event.messages.filter(m => !shouldPrune(m));
649: return { messages: filtered };
650-});
651-```
652-
$ rg -n -C 3 "additionalContext|add to the default system prompt" <Claude installed docs>
/home/hermes/.config/mosaic/runtime/claude/RUNTIME.md-58- tiered models via the Task `model` param).
/home/hermes/.config/mosaic/runtime/claude/RUNTIME.md-59-
/home/hermes/.config/mosaic/runtime/claude/RUNTIME.md-60-Note: PostToolUse hook plain stdout on exit 0 goes to the debug log, not model context — only
/home/hermes/.config/mosaic/runtime/claude/RUNTIME.md:61:`hookSpecificOutput.additionalContext` (or exit-2 stderr) enters context.
--
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/README.md-59-expressed as
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/README.md-60-[subagents](https://docs.claude.com/en/docs/claude-code/sub-agents), not as
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/README.md-61-SessionStart hooks. Subagents change the system prompt while SessionStart hooks
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/README.md:62:add to the default system prompt.
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/README.md-63-
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/README.md-64-## Managing changes
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/README.md-65-
--
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh-1-#!/usr/bin/env bash
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh-2-
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh:3:# Output the explanatory mode instructions as additionalContext
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh-4-# This mimics the deprecated Explanatory output style
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh-5-
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh-6-cat << 'EOF'
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh-7-{
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh-8- "hookSpecificOutput": {
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh-9- "hookEventName": "SessionStart",
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh:10: "additionalContext": "You are in 'explanatory' output style mode, where you should provide educational insights about the codebase as you help with the user's task.\n\nYou should be clear and educational, providing helpful explanations while remaining focused on the task. Balance educational content with task completion. When providing insights, you may exceed typical length constraints, but remain focused and relevant.\n\n## Insights\nIn order to encourage learning, before and after writing code, always provide brief educational explanations about implementation choices using (with backticks):\n\"`★ Insight ─────────────────────────────────────`\n[2-3 key educational points]\n`─────────────────────────────────────────────────`\"\n\nThese insights should be included in the conversation, not in the codebase. You should generally focus on interesting insights that are specific to the codebase or the code you just wrote, rather than general programming concepts. Do not wait until the end to provide insights. Provide them as you write code."
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh-11- }
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh-12-}
/home/hermes/.claude/plugins/marketplaces/claude-plugins-official/plugins/explanatory-output-style/hooks-handlers/session-start.sh-13-EOF

View File

@@ -1,9 +0,0 @@
$ sha256sum ~/agent-work/reviews/compaction-refresh-BUILD-BRIEF.md ~/agent-work/reviews/compaction-refresh-SPEC-v5.md ~/agent-work/reviews/compaction-refresh-SPEC-RATIFICATION.md
89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b /home/hermes/agent-work/reviews/compaction-refresh-BUILD-BRIEF.md
a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433 /home/hermes/agent-work/reviews/compaction-refresh-SPEC-v5.md
bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67 /home/hermes/agent-work/reviews/compaction-refresh-SPEC-RATIFICATION.md
Expected:
89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b BUILD-BRIEF
a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433 SPEC-v5
bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67 RATIFICATION

View File

@@ -1,2 +0,0 @@
__pycache__/
*.pyc

View File

@@ -1,51 +0,0 @@
#!/usr/bin/env python3
"""Register this PID as anchor, then exec the real `mosaic yolo` launcher."""
from __future__ import annotations
import argparse
import json
import os
import socket
import sys
def request(socket_path: str, payload: dict[str, object]) -> dict[str, object]:
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
conn.connect(socket_path)
conn.sendall((json.dumps(payload) + "\n").encode())
response = json.loads(conn.makefile("r", encoding="utf-8").readline())
conn.close()
return response
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--socket", required=True)
parser.add_argument("runtime", choices=["pi", "claude"])
parser.add_argument("args", nargs=argparse.REMAINDER)
ns = parser.parse_args()
response = request(ns.socket, {"action": "register-anchor", "runtime": ns.runtime})
if response.get("decision") != "ACCEPT":
raise SystemExit("anchor registration refused")
os.environ["GATE0_SESSION_ID"] = str(response["session_id"])
argv = ["mosaic", "yolo", ns.runtime, *ns.args]
print(
json.dumps(
{
"event": "anchor-exec",
"pid": os.getpid(),
"argv": ["mosaic", "yolo", ns.runtime, f"<{len(ns.args)} runtime args>"],
"note": "os.execvpe retains pid and /proc starttime",
},
sort_keys=True,
),
file=sys.stderr,
flush=True,
)
os.execvpe("mosaic", argv, os.environ)
if __name__ == "__main__":
main()

View File

@@ -1,180 +0,0 @@
#!/usr/bin/env python3
"""Gate0 P1 broker prototype: peercred anchor minting and /proc ancestry checks."""
from __future__ import annotations
import argparse
import json
import os
import secrets
import socket
import stat
import struct
import sys
from pathlib import Path
from typing import Any
def proc_node(pid: int) -> dict[str, Any]:
text = Path(f"/proc/{pid}/stat").read_text()
close = text.rfind(")")
comm = text[text.find("(") + 1 : close]
fields = text[close + 2 :].split()
cmdline = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
return {
"pid": pid,
"ppid": int(fields[1]),
"starttime_ticks": int(fields[19]),
"comm": comm,
"exe": os.readlink(f"/proc/{pid}/exe"),
"argv0": cmdline[0].decode(errors="replace") if cmdline and cmdline[0] else "",
"argc": len([part for part in cmdline if part]),
}
def ancestry(peer_pid: int, anchor: dict[str, Any] | None) -> tuple[list[dict[str, Any]], bool, str]:
chain: list[dict[str, Any]] = []
pid = peer_pid
seen: set[int] = set()
try:
while pid > 0 and pid not in seen:
seen.add(pid)
node = proc_node(pid)
chain.append(node)
if anchor and pid == anchor["pid"]:
if node["starttime_ticks"] != anchor["starttime_ticks"]:
return chain, False, "anchor-starttime-mismatch"
break
pid = node["ppid"]
else:
return chain, False, "anchor-not-reached"
if not anchor or chain[-1]["pid"] != anchor["pid"]:
return chain, False, "anchor-not-reached"
# Re-read every node after the walk. A disappearing PID or changed
# starttime invalidates the complete chain (PID-reuse/race closure).
for original in chain:
again = proc_node(original["pid"])
if again["starttime_ticks"] != original["starttime_ticks"]:
return chain, False, f"starttime-race:{original['pid']}"
return chain, True, "ancestry-reaches-registered-anchor"
except (FileNotFoundError, ProcessLookupError, PermissionError) as exc:
return chain, False, f"proc-walk-failed:{type(exc).__name__}"
def emit(log_file: Path, record: dict[str, Any]) -> None:
line = json.dumps(record, sort_keys=True)
with log_file.open("a", encoding="utf-8") as out:
out.write(line + "\n")
print(line, flush=True)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--socket", required=True)
parser.add_argument("--log", required=True)
parser.add_argument("--state", required=True)
args = parser.parse_args()
socket_path = Path(args.socket)
log_file = Path(args.log)
state_file = Path(args.state)
socket_path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(socket_path.parent, 0o700)
socket_path.unlink(missing_ok=True)
log_file.unlink(missing_ok=True)
state_file.unlink(missing_ok=True)
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(socket_path))
os.chmod(socket_path, 0o600)
server.listen(8)
anchor: dict[str, Any] | None = None
session_id: str | None = None
emit(
log_file,
{
"event": "broker-listen",
"pid": os.getpid(),
"socket": str(socket_path),
"directory_mode": f"{stat.S_IMODE(socket_path.parent.stat().st_mode):04o}",
"socket_mode": f"{stat.S_IMODE(socket_path.stat().st_mode):04o}",
},
)
while True:
conn, _ = server.accept()
with conn:
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
peer_pid, peer_uid, peer_gid = struct.unpack("3i", raw)
request = json.loads(conn.makefile("r", encoding="utf-8").readline())
action = request.get("action")
if action == "register-anchor" and anchor is None:
anchor = proc_node(peer_pid)
session_id = secrets.token_hex(16)
state = {"session_id": session_id, "anchor": anchor}
state_file.write_text(json.dumps(state, sort_keys=True) + "\n")
record = {
"event": "anchor-minted",
"decision": "ACCEPT",
"peercred": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid},
"anchor": anchor,
"session_id": session_id,
}
emit(log_file, record)
conn.sendall((json.dumps(record) + "\n").encode())
continue
if action in {"resolve-hook", "claim-session"}:
chain, reaches, reason = ancestry(peer_pid, anchor)
claimed = request.get("session_id")
claim_ok = action == "resolve-hook" or claimed == session_id
accepted = bool(anchor and session_id and reaches and claim_ok)
if action == "claim-session" and claimed != session_id:
reason = "unknown-session-id"
elif action == "claim-session" and claimed == session_id and not reaches:
reason = "victim-id-known-but-ancestry-mismatch"
record = {
"event": action,
"decision": "ACCEPT" if accepted else "REJECT",
"reason": reason,
"peercred": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid},
"claimed_session_id": claimed,
"resolved_session_id": session_id if accepted else None,
"anchor": anchor,
"ancestry": chain,
"starttimes_rechecked": reaches,
}
emit(log_file, record)
conn.sendall((json.dumps(record) + "\n").encode())
continue
if action == "shutdown":
record = {
"event": "broker-shutdown",
"peercred": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid},
}
emit(log_file, record)
conn.sendall((json.dumps(record) + "\n").encode())
break
record = {
"event": "invalid-request",
"decision": "REJECT",
"peercred": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid},
}
emit(log_file, record)
conn.sendall((json.dumps(record) + "\n").encode())
server.close()
socket_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as exc:
print(f"P1 broker fatal: {type(exc).__name__}: {exc}", file=sys.stderr)
raise

View File

@@ -1,38 +0,0 @@
#!/usr/bin/env python3
"""Claude SessionStart hook client for P1 ancestry evidence."""
from __future__ import annotations
import json
import os
import socket
import sys
def main() -> None:
# Consume the real Claude hook payload without recording transcript paths or
# prompt content in the evidence artifact.
hook_input = json.load(sys.stdin)
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
conn.connect(os.environ["GATE0_BROKER_SOCKET"])
conn.sendall((json.dumps({"action": "resolve-hook"}) + "\n").encode())
response = json.loads(conn.makefile("r", encoding="utf-8").readline())
conn.close()
event_name = hook_input.get("hook_event_name")
if response.get("decision") != "ACCEPT":
print(f"Gate0 broker rejected {event_name} hook ancestry", file=sys.stderr)
raise SystemExit(2)
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": event_name,
"additionalContext": "GATE0_P1_SUPPORTED_HOOK_ANCESTRY_ACCEPTED",
}
}
)
)
if __name__ == "__main__":
main()

View File

@@ -1,35 +0,0 @@
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
import net from 'node:net';
async function brokerRequest(payload: Record<string, unknown>): Promise<Record<string, unknown>> {
const socketPath = process.env['GATE0_BROKER_SOCKET'];
if (!socketPath) throw new Error('GATE0_BROKER_SOCKET missing');
return await new Promise((resolve, reject) => {
const socket = net.createConnection(socketPath);
let buffer = '';
socket.setEncoding('utf8');
socket.on('connect', () => socket.write(`${JSON.stringify(payload)}\n`));
socket.on('data', (chunk) => {
buffer += chunk;
const newline = buffer.indexOf('\n');
if (newline < 0) return;
socket.end();
resolve(JSON.parse(buffer.slice(0, newline)) as Record<string, unknown>);
});
socket.on('error', reject);
});
}
export default function register(pi: ExtensionAPI) {
pi.on('session_start', async () => {
const response = await brokerRequest({ action: 'resolve-hook', runtime: 'pi-extension' });
if (response['decision'] !== 'ACCEPT') {
throw new Error(`P1 broker rejected Pi extension ancestry: ${response['reason']}`);
}
});
pi.registerCommand('gate0-p1-ready', {
description: 'Return only after the P1 session_start ancestry hook completed',
handler: async () => undefined,
});
}

View File

@@ -1,274 +0,0 @@
#!/usr/bin/env python3
"""Run P1 against the real installed Mosaic→Pi and Mosaic→Claude chains."""
from __future__ import annotations
import argparse
import json
import os
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
HERE = Path(__file__).resolve().parent
def wait_for(predicate, description: str, timeout: float = 30.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.05)
raise TimeoutError(f"timed out waiting for {description}")
def read_records(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
return [json.loads(line) for line in path.read_text().splitlines() if line]
def socket_request(path: Path, payload: dict[str, object]) -> dict[str, object]:
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
conn.connect(str(path))
conn.sendall((json.dumps(payload) + "\n").encode())
response = json.loads(conn.makefile("r", encoding="utf-8").readline())
conn.close()
return response
def start_broker(root: Path) -> tuple[subprocess.Popen[str], Path, Path, Path]:
socket_path = root / "broker.sock"
log_path = root / "broker.jsonl"
state_path = root / "state.json"
broker = subprocess.Popen(
[
sys.executable,
str(HERE / "p1_broker.py"),
"--socket",
str(socket_path),
"--log",
str(log_path),
"--state",
str(state_path),
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
wait_for(socket_path.exists, "broker socket")
return broker, socket_path, log_path, state_path
def print_ps(record: dict[str, Any]) -> None:
chain = record.get("ancestry", [])
pids = [str(node["pid"]) for node in chain if Path(f"/proc/{node['pid']}").exists()]
if not pids:
print("ps_snapshot=<hook chain exited; broker /proc snapshot above is authoritative>")
return
command = [
"ps",
"-o",
"pid=,ppid=,lstart=,uid=,gid=,comm=",
"-p",
",".join(pids),
]
print("$ " + " ".join(command))
print(subprocess.check_output(command, text=True).rstrip())
def run_runtime(runtime: str) -> None:
with tempfile.TemporaryDirectory(prefix=f"gate0-p1-{runtime}-") as temp:
root = Path(temp)
workspace = root / "workspace"
workspace.mkdir()
broker, socket_path, log_path, state_path = start_broker(root)
env = os.environ.copy()
env.update(
{
"GATE0_BROKER_SOCKET": str(socket_path),
"MOSAIC_PI_FORCE_SKILLS": "",
"PI_SKIP_VERSION_CHECK": "1",
}
)
stdout_path = root / f"{runtime}.stdout"
stderr_path = root / f"{runtime}.stderr"
if runtime == "pi":
runtime_args = [
"--mode",
"rpc",
"--no-session",
"--no-extensions",
"--no-context-files",
"--no-prompt-templates",
"--extension",
str(HERE / "p1_pi_extension.ts"),
]
else:
settings = root / "claude-settings.json"
settings.write_text(
json.dumps(
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": f'python3 "{HERE / "p1_hook_client.py"}"',
"timeout": 20,
}
]
}
]
}
}
)
)
runtime_args = [
"--settings",
str(settings),
"--model",
"haiku",
"--print",
"--output-format",
"stream-json",
"--verbose",
"--include-hook-events",
"--max-budget-usd",
"0.03",
"Reply exactly: OK",
]
out = stdout_path.open("w", encoding="utf-8")
err = stderr_path.open("w", encoding="utf-8")
anchor = subprocess.Popen(
[
sys.executable,
str(HERE / "p1_anchor_exec.py"),
"--socket",
str(socket_path),
runtime,
*runtime_args,
],
cwd=workspace,
env=env,
stdin=subprocess.PIPE if runtime == "pi" else subprocess.DEVNULL,
stdout=out,
stderr=err,
text=True,
start_new_session=True,
)
try:
wait_for(state_path.exists, "anchor registration")
attacker = subprocess.run(
[
sys.executable,
str(HERE / "p1_sibling_attacker.py"),
"--socket",
str(socket_path),
"--state",
str(state_path),
],
text=True,
capture_output=True,
check=False,
)
if runtime == "pi":
assert anchor.stdin is not None
anchor.stdin.write('{"id":"state","type":"get_state"}\n')
anchor.stdin.flush()
wait_for(
lambda: any(r.get("event") == "resolve-hook" for r in read_records(log_path)),
f"{runtime} supported hook/extension broker contact",
timeout=60,
)
if runtime == "claude":
try:
anchor.wait(timeout=90)
except subprocess.TimeoutExpired:
pass
records = read_records(log_path)
state = json.loads(state_path.read_text())
resolve = next(r for r in records if r.get("event") == "resolve-hook")
reject = next(r for r in records if r.get("event") == "claim-session")
if resolve.get("decision") != "ACCEPT":
raise AssertionError(f"{runtime} hook ancestry was not accepted: {resolve}")
if reject.get("decision") != "REJECT":
raise AssertionError(f"{runtime} sibling substitution was not rejected: {reject}")
if attacker.returncode != 0:
raise AssertionError(f"{runtime} sibling probe did not observe rejection: {attacker.stderr}")
print(f"=== P1 {runtime.upper()} REAL LAUNCH ===")
print("machine_assertions=PASS")
print(
"$ python3 docs/compaction-refresh/probes/p1_anchor_exec.py "
f"--socket <protected-socket> {runtime} <runtime args>"
)
print("registered_anchor=" + json.dumps(state["anchor"], sort_keys=True))
print("broker_minted_session_id=" + state["session_id"])
print("hook_or_extension_record=" + json.dumps(resolve, sort_keys=True))
print("sibling_attack_record=" + json.dumps(reject, sort_keys=True))
print("sibling_process_stdout=" + attacker.stdout.strip())
print(f"sibling_process_exit={attacker.returncode}")
print_ps(resolve)
print("launcher_stderr_excerpt:")
for line in stderr_path.read_text(errors="replace").splitlines()[:12]:
print(" " + line[:500])
runtime_lines = stdout_path.read_text(errors="replace").splitlines()
print("runtime_stdout_excerpt:")
for line in runtime_lines[:8]:
print(" " + line[:500])
hook_lines = [
line
for line in runtime_lines
if "hook" in line.lower() or "GATE0_P1_SUPPORTED_HOOK" in line
]
print("runtime_hook_event_excerpt:")
for line in hook_lines[:8]:
print(" " + line[:1000])
print()
finally:
if anchor.poll() is None:
try:
os.killpg(anchor.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
anchor.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(anchor.pid, signal.SIGKILL)
anchor.wait(timeout=5)
out.close()
err.close()
try:
socket_request(socket_path, {"action": "shutdown"})
except OSError:
pass
try:
broker.wait(timeout=5)
except subprocess.TimeoutExpired:
broker.kill()
broker.wait()
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--runtime", choices=["pi", "claude", "both"], default="both")
ns = parser.parse_args()
if ns.runtime in {"pi", "both"}:
run_runtime("pi")
if ns.runtime in {"claude", "both"}:
run_runtime("claude")
if __name__ == "__main__":
main()

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""Same-UID sibling that attempts to claim the anchor's broker-minted id."""
from __future__ import annotations
import argparse
import json
import os
import socket
import time
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--socket", required=True)
parser.add_argument("--state", required=True)
ns = parser.parse_args()
state_path = Path(ns.state)
for _ in range(200):
if state_path.exists():
break
time.sleep(0.025)
state = json.loads(state_path.read_text())
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
conn.connect(ns.socket)
conn.sendall(
(
json.dumps(
{"action": "claim-session", "session_id": state["session_id"]},
sort_keys=True,
)
+ "\n"
).encode()
)
response = json.loads(conn.makefile("r", encoding="utf-8").readline())
conn.close()
print(
json.dumps(
{
"attacker_pid": os.getpid(),
"attacker_uid": os.getuid(),
"victim_session_id_known": True,
"broker_decision": response.get("decision"),
"broker_reason": response.get("reason"),
},
sort_keys=True,
)
)
raise SystemExit(0 if response.get("decision") == "REJECT" else 1)
if __name__ == "__main__":
main()

View File

@@ -1,135 +0,0 @@
#!/usr/bin/env python3
"""Force a real Pi HTTP provider response to prove response-hook timing."""
from __future__ import annotations
import json
import os
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from pi_gate0_run import PiRpc, jsonl
HERE = Path(__file__).resolve().parent
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, _format: str, *_args: object) -> None:
return
def do_POST(self) -> None: # noqa: N802
length = int(self.headers.get("content-length", "0"))
self.rfile.read(length)
chunks = [
{
"id": "gate0-response",
"object": "chat.completion.chunk",
"created": 1,
"model": "gate0-model",
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
},
{
"id": "gate0-response",
"object": "chat.completion.chunk",
"created": 1,
"model": "gate0-model",
"choices": [
{"index": 0, "delta": {"content": "TIMING_OK"}, "finish_reason": None}
],
},
{
"id": "gate0-response",
"object": "chat.completion.chunk",
"created": 1,
"model": "gate0-model",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
},
]
body = "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + "data: [DONE]\n\n"
encoded = body.encode()
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Content-Length", str(len(encoded)))
self.send_header("X-Gate0-Response", "headers-before-stream")
self.end_headers()
self.wfile.write(encoded)
self.wfile.flush()
def main() -> None:
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
port = server.server_address[1]
with tempfile.TemporaryDirectory(prefix="gate0-p2-timing-") as temp:
root = Path(temp)
workspace = root / "workspace"
workspace.mkdir()
log = root / "hooks.jsonl"
env = os.environ.copy()
env.update(
{
"GATE0_PI_LOG": str(log),
"GATE0_LOCAL_PROVIDER_URL": f"http://127.0.0.1:{port}/v1",
"MOSAIC_PI_FORCE_SKILLS": "",
"PI_SKIP_VERSION_CHECK": "1",
}
)
command = [
"mosaic",
"yolo",
"pi",
"--mode",
"rpc",
"--no-session",
"--no-extensions",
"--no-context-files",
"--no-prompt-templates",
"--provider",
"gate0-local",
"--model",
"gate0-model",
"--extension",
str(HERE / "pi_gate0_extension.ts"),
]
pi = PiRpc(command, workspace, env)
try:
pi.prompt_and_settle("timing", "Reply with TIMING_OK")
records = jsonl(log)
selected = [
record
for record in records
if record["event"] in {"before_provider_request", "after_provider_response", "message_end"}
and (record["event"] != "message_end" or record.get("role") == "assistant")
]
print("$ python3 docs/compaction-refresh/probes/p2_provider_timing_run.py")
print(f"local_http_endpoint=http://127.0.0.1:{port}/v1/chat/completions")
for record in selected:
print(json.dumps(record, sort_keys=True))
after = next(record for record in selected if record["event"] == "after_provider_response")
message = next(record for record in selected if record["event"] == "message_end")
if not (
after["seq"] < message["seq"]
and after["assistantContentAvailableAtThisHook"] is False
and message["assistantContentObserved"] is True
):
raise AssertionError("provider response/content observation ordering failed")
print("machine_assertions=PASS")
print(f"after_provider_response_seq={after['seq']}")
print(f"message_end_seq={message['seq']}")
print(f"headers_hook_precedes_completed_message={after['seq'] < message['seq']}")
finally:
pi.close()
server.shutdown()
server.server_close()
if __name__ == "__main__":
main()

View File

@@ -1,849 +0,0 @@
#!/usr/bin/env python3
"""D4-only same-PID runtime-generation revocation harness.
AUTHORING NOTE: this file is intentionally not executed until the separately
ratified FIRE authorization. When run later, every invocation creates its own
/tmp fixture and launches the real Pi RPC runtime with only the D4 extension
and ``p3_generation_broker.py``. It does not use the broader Gate0 runner.
"""
from __future__ import annotations
import argparse
import ast
import hashlib
import json
import os
import queue
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
HERE = Path(__file__).resolve().parent
# WI-3 remains in a reviewed worktree until the release package contains the
# gated launcher. The probe resolves that worktree portably and never falls
# back to the released `mosaic` binary.
GATED_WI_ROOT_OVERRIDE = os.environ.get("GATED_WI_ROOT")
GATED_WI_BRANCH = "refs/heads/feat/830-compaction-revoke"
GATED_WI_HEAD = "f400830738998db105107a2a4c69c7f2a2a6fd5d"
GATED_WI_ANCESTOR = "66b1e0a0"
GATED_BROKER_HEAD = "23c0caca9b5d44002e6184cd7f2b6c837e8795b2"
LEASE_BROKER_DIRECTORY = "packages/mosaic/framework/tools/lease-broker"
BROKER_RELATIVE_PATH = "docs/compaction-refresh/probes/p3_generation_broker.py"
GATED_LAUNCHER_SHA256 = "e950e4224e280f16979d90cabb89aa1896c5ee28bed2df957e14d018d43cda82"
GATED_GENERATION_SHA256 = "061625402f08488eac47acd23272904e71fd1a71fd15b3bdab158632c801be4c"
GATED_BROKER_SHA256 = "4db4fef1ac6658a8ca79ad5091cefc901d2aa26003265c3d6726c294cf895cad"
class PiRpc:
"""Small JSON-RPC client for an isolated real Pi process."""
def __init__(self, command: list[str], cwd: Path, env: dict[str, str]) -> None:
self.process = subprocess.Popen(
command,
cwd=cwd,
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
start_new_session=True,
)
self.events: queue.Queue[dict[str, Any]] = queue.Queue()
self.stderr_lines: list[str] = []
threading.Thread(target=self._read_stdout, daemon=True).start()
threading.Thread(target=self._read_stderr, daemon=True).start()
def _read_stdout(self) -> None:
if self.process.stdout is None:
raise RuntimeError("Pi stdout pipe is unavailable")
for line in self.process.stdout:
try:
self.events.put(json.loads(line))
except json.JSONDecodeError:
continue
def _read_stderr(self) -> None:
if self.process.stderr is None:
raise RuntimeError("Pi stderr pipe is unavailable")
for line in self.process.stderr:
self.stderr_lines.append(line.rstrip("\n"))
def send(self, payload: dict[str, object]) -> None:
if self.process.stdin is None:
raise RuntimeError("Pi stdin pipe is unavailable")
self.process.stdin.write(json.dumps(payload) + "\n")
self.process.stdin.flush()
def wait(
self,
predicate: Callable[[dict[str, Any]], bool],
description: str,
timeout: float = 180,
) -> dict[str, Any]:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.process.poll() is not None and self.events.empty():
detail = " | ".join(self.stderr_lines[-5:])
raise RuntimeError(
f"Pi exited {self.process.returncode} while waiting for {description}: {detail}"
)
try:
event = self.events.get(timeout=0.2)
except queue.Empty:
continue
if predicate(event):
return event
raise TimeoutError(f"timed out waiting for {description}")
def response(self, request_id: str, timeout: float = 180) -> dict[str, Any]:
return self.wait(
lambda event: event.get("type") == "response" and event.get("id") == request_id,
f"response {request_id}",
timeout,
)
def prompt_and_settle(self, request_id: str, message: str) -> None:
self.send({"id": request_id, "type": "prompt", "message": message})
response = self.response(request_id)
if not response.get("success"):
raise RuntimeError(f"prompt rejected: {response}")
self.wait(
lambda event: event.get("type") == "agent_settled",
f"agent_settled {request_id}",
)
def close(self) -> None:
if self.process.poll() is None:
try:
os.killpg(self.process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
self.process.wait(timeout=8)
except subprocess.TimeoutExpired:
os.killpg(self.process.pid, signal.SIGKILL)
self.process.wait(timeout=5)
def wait_path(path: Path, timeout: float = 20) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if path.exists():
return
time.sleep(0.05)
raise TimeoutError(f"timed out waiting for {path}")
def request(path: Path, payload: dict[str, object]) -> dict[str, Any]:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn:
conn.connect(str(path))
conn.sendall((json.dumps(payload) + "\n").encode())
reply = conn.makefile("r", encoding="utf-8").readline()
return json.loads(reply)
def jsonl(path: Path) -> list[dict[str, Any]]:
return [json.loads(line) for line in path.read_text().splitlines() if line]
def write_extension(path: Path) -> None:
"""Write the minimal Pi lifecycle bridge into the isolated fixture only."""
path.write_text(
"""import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
import { Type } from 'typebox';
import { appendFileSync, readFileSync } from 'node:fs';
import net from 'node:net';
const socketPath = process.env['D4_GENERATION_SOCKET'];
const logPath = process.env['D4_PI_LOG'];
function starttime(): number {
const text = readFileSync(`/proc/${process.pid}/stat`, 'utf8');
const close = text.lastIndexOf(')');
return Number(text.slice(close + 2).trim().split(/\\s+/)[19]);
}
function log(event: string, details: Record<string, unknown> = {}): void {
if (!logPath) return;
appendFileSync(logPath, `${JSON.stringify({ event, pid: process.pid, starttime_ticks: starttime(), ...details })}\\n`);
}
function broker(payload: Record<string, unknown>): Promise<Record<string, unknown>> {
if (!socketPath) return Promise.reject(new Error('D4_GENERATION_SOCKET is required'));
return new Promise((resolve, reject) => {
const connection = net.createConnection(socketPath);
let buffer = '';
connection.setEncoding('utf8');
connection.on('connect', () => connection.write(`${JSON.stringify(payload)}\\n`));
connection.on('data', (chunk) => {
buffer += chunk;
const newline = buffer.indexOf('\\n');
if (newline < 0) return;
connection.end();
resolve(JSON.parse(buffer.slice(0, newline)) as Record<string, unknown>);
});
connection.on('error', reject);
});
}
export default function register(pi: ExtensionAPI): void {
let initialStartup = true;
async function lifecycle(
phase: 'start' | 'shutdown',
reason: string,
): Promise<Record<string, unknown>> {
if (!(phase === 'start' && reason === 'startup' && initialStartup)) {
const bump = await broker({ action: 'bump-generation' });
log('generation_state_bump', { phase, reason, bump });
}
initialStartup = false;
return broker({ action: 'lifecycle', phase, reason });
}
pi.on('session_start', async (event) => {
const lifecycleResult = await lifecycle('start', event.reason);
log('session_start', { reason: event.reason, lifecycle: lifecycleResult });
if (event.reason === 'reload') {
const generation = lifecycleResult['new_generation'];
if (typeof generation !== 'number') throw new Error('broker did not return new_generation');
const current = await broker({ action: 'authorize-probe', generation });
const superseded = await broker({ action: 'authorize-probe', generation: generation - 1 });
log('d4_generation_authorization', { generation, current, superseded });
}
});
pi.on('session_shutdown', async (event) => {
const lifecycleResult = await lifecycle('shutdown', event.reason);
log('session_shutdown', { reason: event.reason, lifecycle: lifecycleResult });
});
pi.registerTool({
name: 'd4_fixture_promote',
label: 'D4 Fixture Promotion',
description: 'Promotes only the fixture lease needed for the D4 revocation check.',
parameters: Type.Object({}),
async execute() {
// the promotion step is a D4 test fixture, not a P2 evidence-gathering authorization.
const promotion = await broker({ action: 'promote-probe' });
log('fixture_promotion', { promotion });
return { content: [{ type: 'text', text: 'D4 fixture promotion complete' }] };
},
});
pi.registerCommand('d4-reload', {
description: 'D4-only same-PID reload boundary.',
handler: async (_args, context) => {
await context.reload();
},
});
}
""",
encoding="utf-8",
)
def repository_root() -> Path:
for candidate in HERE.parents:
if (candidate / ".git").exists():
return candidate
raise RuntimeError("D4 precondition: probe repository root is unavailable")
def resolve_gated_wi_root() -> Path:
"""Resolve an explicit override or the unique checked-out WI-3 branch."""
if GATED_WI_ROOT_OVERRIDE:
candidate = Path(GATED_WI_ROOT_OVERRIDE).expanduser()
candidates = [candidate]
else:
try:
listing = subprocess.check_output(
["git", "-C", str(repository_root()), "worktree", "list", "--porcelain"],
text=True,
)
except (OSError, subprocess.CalledProcessError) as error:
raise RuntimeError("D4 precondition: cannot enumerate WI-3 worktrees") from error
candidates = []
worktree: Path | None = None
head: str | None = None
branch: str | None = None
for line in [*listing.splitlines(), ""]:
if line.startswith("worktree "):
worktree = Path(line.removeprefix("worktree "))
head = None
branch = None
elif line.startswith("HEAD "):
head = line.removeprefix("HEAD ")
elif line.startswith("branch "):
branch = line.removeprefix("branch ")
elif not line and worktree is not None:
if head == GATED_WI_HEAD and branch == GATED_WI_BRANCH:
candidates.append(worktree)
worktree = None
if len(candidates) != 1:
raise RuntimeError("D4 precondition: WI-3 worktree is ambiguous or unavailable")
gated_root = candidates[0]
try:
if not gated_root.is_dir():
raise RuntimeError("D4 precondition: GATED_WI_ROOT is not a directory")
is_worktree = subprocess.check_output(
["git", "-C", str(gated_root), "rev-parse", "--is-inside-work-tree"],
text=True,
).strip()
head = subprocess.check_output(
["git", "-C", str(gated_root), "rev-parse", "HEAD"], text=True
).strip()
except (OSError, subprocess.CalledProcessError) as error:
raise RuntimeError("D4 precondition: GATED_WI_ROOT is not a git worktree") from error
if is_worktree != "true":
raise RuntimeError("D4 precondition: GATED_WI_ROOT is not a git worktree")
if head != GATED_WI_HEAD:
raise RuntimeError(f"D4 precondition: gated WI head mismatch: {head}")
try:
forward_contains = subprocess.run(
[
"git",
"-C",
str(gated_root),
"merge-base",
"--is-ancestor",
GATED_WI_ANCESTOR,
GATED_WI_HEAD,
],
check=False,
).returncode == 0
except OSError as error:
raise RuntimeError("D4 precondition: cannot verify WI-3 ancestry") from error
if not forward_contains:
raise RuntimeError("D4 precondition: gated WI lacks required ancestor")
return gated_root
@dataclass(frozen=True)
class PinnedClosure:
launcher: Path
generation: Path
broker: Path
def git_object_bytes(git_root: Path, commit: str, relative_path: str) -> bytes:
try:
return subprocess.check_output(
["git", "-C", str(git_root), "show", f"{commit}:{relative_path}"]
)
except (OSError, subprocess.CalledProcessError) as error:
raise RuntimeError(f"D4 precondition: missing pinned source {relative_path}") from error
def closure_import_guard(member_sources: dict[str, str]) -> None:
"""Refuse an incomplete project-code closure before materializing it."""
allowed_nonstdlib = {"lease_generation"}
stdlib = getattr(sys, "stdlib_module_names", frozenset())
for name, source in member_sources.items():
try:
tree = ast.parse(source, filename=name)
except SyntaxError as error:
raise RuntimeError(f"D4 precondition: pinned {name} does not parse") from error
for node in ast.walk(tree):
module: str | None = None
if isinstance(node, ast.Import):
for alias in node.names:
module = alias.name.split(".", maxsplit=1)[0]
if module not in stdlib and module not in allowed_nonstdlib:
raise RuntimeError(f"D4 precondition: unpinned import {module} in {name}")
elif isinstance(node, ast.ImportFrom):
if node.level:
raise RuntimeError(f"D4 precondition: relative import in {name}")
if node.module:
module = node.module.split(".", maxsplit=1)[0]
if module not in stdlib and module not in allowed_nonstdlib:
raise RuntimeError(f"D4 precondition: unpinned import {module} in {name}")
def write_pinned_file(path: Path, data: bytes) -> None:
descriptor = os.open(
path,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC,
0o600,
)
try:
remaining = memoryview(data)
while remaining:
written = os.write(descriptor, remaining)
if written <= 0:
raise OSError("pinned write made no progress")
remaining = remaining[written:]
finally:
os.close(descriptor)
def materialize_closure(root: Path, gated_root: Path, gate0_root: Path) -> PinnedClosure:
"""Pin the complete project-authored runtime closure inside this fixture."""
launcher_relative = f"{LEASE_BROKER_DIRECTORY}/launch-runtime.py"
generation_relative = f"{LEASE_BROKER_DIRECTORY}/lease_generation.py"
members = (
(
"launch-runtime.py",
gated_root,
GATED_WI_HEAD,
launcher_relative,
GATED_LAUNCHER_SHA256,
),
(
"lease_generation.py",
gated_root,
GATED_WI_HEAD,
generation_relative,
GATED_GENERATION_SHA256,
),
(
"p3_generation_broker.py",
gate0_root,
GATED_BROKER_HEAD,
BROKER_RELATIVE_PATH,
GATED_BROKER_SHA256,
),
)
member_bytes: dict[str, bytes] = {}
member_sources: dict[str, str] = {}
for name, git_root, commit, relative_path, digest in members:
data = git_object_bytes(git_root, commit, relative_path)
if hashlib.sha256(data).hexdigest() != digest:
raise RuntimeError(f"D4 precondition: {name} hash mismatch")
try:
member_sources[name] = data.decode("utf-8")
except UnicodeDecodeError as error:
raise RuntimeError(f"D4 precondition: pinned {name} is not UTF-8") from error
member_bytes[name] = data
closure_import_guard(member_sources)
pinned = root / "pinned"
pinned.mkdir(mode=0o700)
paths = {name: pinned / name for name, *_ in members}
for name, path in paths.items():
write_pinned_file(path, member_bytes[name])
return PinnedClosure(
launcher=paths["launch-runtime.py"],
generation=paths["lease_generation.py"],
broker=paths["p3_generation_broker.py"],
)
def gated_launcher_precondition(
root: Path, socket_path: Path, environment: dict[str, str]
) -> PinnedClosure:
"""Verify and materialize the full WI-3/probe closure before execution."""
if environment.get("MOSAIC_LEASE_BROKER_SOCKET") != str(socket_path):
raise RuntimeError("D4 precondition: lease broker socket is not this fixture")
if environment.get("MOSAIC_LEASE_GENERATION_FILE"):
raise RuntimeError("D4 precondition: inherited generation file is forbidden")
fixture_path_vars = (
"HOME",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_STATE_HOME",
"XDG_RUNTIME_DIR",
"TMPDIR",
"D4_PI_LOG",
"MOSAIC_AGENT_WORKDIR",
"MOSAIC_HEARTBEAT_RUN_DIR",
"MOSAIC_HOME",
)
if any(
not (value := environment.get(name)) or not Path(value).is_relative_to(root)
for name in fixture_path_vars
):
raise RuntimeError("D4 precondition: child write path escapes fixture root")
if socket_path.parent != root or root.parent != Path(tempfile.gettempdir()):
raise RuntimeError("D4 precondition: fixture socket is outside this run's temporary root")
gated_root = resolve_gated_wi_root()
closure = materialize_closure(root, gated_root, repository_root())
launcher_source = closure.launcher.read_text(encoding="utf-8")
generation_source = closure.generation.read_text(encoding="utf-8")
# Exact hashes in materialize_closure are the trust anchor. These marker
# checks are belt-and-suspenders diagnostics only.
behavior_markers = (
'"action": "register_anchor"',
"initialize_runtime_generation(generation_file, generation)",
"execute(command[0], command, environment)",
'source_environment["MOSAIC_LEASE_BROKER_SOCKET"]',
'socket_path.parent / f"generation-{session_id}.state"',
'environment["MOSAIC_LEASE_GENERATION_FILE"]',
)
if not all(marker in launcher_source for marker in behavior_markers) or not (
"def read_runtime_generation" in generation_source
and "def bump_runtime_generation" in generation_source
):
raise RuntimeError("D4 precondition: pinned launcher lacks file-generation markers")
return closure
def reject_pinned_bytecode(pinned_directory: Path) -> None:
cache_directory = pinned_directory / "__pycache__"
if cache_directory.exists() or any(pinned_directory.rglob("*.pyc")):
raise RuntimeError("D4 precondition: pinned bytecode cache is forbidden")
def launch_verified_pi(
launcher: Path,
workspace: Path,
sessions: Path,
extension: Path,
environment: dict[str, str],
) -> PiRpc:
command = [
sys.executable,
# -s preserves sys.path[0]=pinned/ for the launcher's sibling helper.
"-s",
"-S",
"-B",
str(launcher),
"--runtime",
"pi",
"--",
"pi",
"--mode",
"rpc",
"--session-dir",
str(sessions),
"--no-extensions",
"--no-context-files",
"--no-prompt-templates",
"--model",
"openai-codex/gpt-5.6-sol",
"--thinking",
"medium",
"--extension",
str(extension),
]
reject_pinned_bytecode(launcher.parent)
# This is deliberately the statement immediately before Popen (inside
# PiRpc): the fixture-pinned launcher bytes are re-hashed then executed.
if hashlib.sha256(launcher.read_bytes()).hexdigest() != GATED_LAUNCHER_SHA256:
raise RuntimeError("D4 precondition: adjacent launcher hash mismatch")
return PiRpc(command, workspace, environment)
def launch_verified_broker(
broker_path: Path,
generation_path: Path,
socket_path: Path,
log_path: Path,
environment: dict[str, str],
) -> subprocess.Popen[str]:
command = [
sys.executable,
"-I",
"-S",
"-B",
str(broker_path),
"--socket",
str(socket_path),
"--log",
str(log_path),
"--generation-module",
str(generation_path),
]
reject_pinned_bytecode(broker_path.parent)
if hashlib.sha256(broker_path.read_bytes()).hexdigest() != GATED_BROKER_SHA256:
raise RuntimeError("D4 precondition: pinned broker hash mismatch")
# The final helper re-hash is immediately adjacent to the broker Popen.
if hashlib.sha256(generation_path.read_bytes()).hexdigest() != GATED_GENERATION_SHA256:
raise RuntimeError("D4 precondition: pinned helper hash mismatch")
return subprocess.Popen(
command,
env=environment,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
def assert_d4(records: list[dict[str, Any]]) -> dict[str, object]:
def record_where(description: str, candidates: list[dict[str, Any]]) -> dict[str, Any]:
if not candidates:
raise AssertionError(f"missing D4 evidence record: {description}")
return candidates[0]
fixture_listen = record_where(
"fixture listen", [r for r in records if r.get("event") == "listen"]
)
fixture_root = Path(fixture_listen["socket"]).parent
lifecycle = [record for record in records if record.get("event") == "runtime_generation_bump"]
state_bumps = [record for record in records if record.get("event") == "generation_state_bumped"]
promotion = record_where(
"fixture promotion", [r for r in records if r.get("event") == "probe_lease_promoted"]
)
launcher_registration = record_where(
"lease anchor", [r for r in records if r.get("event") == "lease_anchor_registered"]
)
reload_revoke = record_where(
"reload shutdown",
[
r
for r in lifecycle
if r.get("reason") == "reload" and r.get("phase") == "shutdown"
],
)
reload_start = record_where(
"reload start",
[
r
for r in lifecycle
if r.get("reason") == "reload" and r.get("phase") == "start"
],
)
authorization = [
record for record in records if record.get("event") == "generation_authorization"
]
current_generation = reload_start["new_generation"]
current_authorization = record_where(
"current-generation authorization",
[r for r in authorization if r.get("requested_generation") == current_generation],
)
superseded_authorization = record_where(
"superseded-generation authorization",
[r for r in authorization if r.get("requested_generation") == current_generation - 1],
)
identities = {
(record["peercred"]["pid"], record["starttime_ticks"])
for record in [*lifecycle, *state_bumps, promotion, launcher_registration, *authorization]
}
generations = [record["new_generation"] for record in lifecycle]
file_records = [*lifecycle, *state_bumps, promotion, *authorization]
observed_reasons = {record.get("reason") for record in lifecycle}
checks = {
"same_pid_starttime": len(identities) == 1,
"strictly_increasing_generation": all(
previous < current for previous, current in zip(generations, generations[1:])
),
"state_file_drives_lifecycle": [record["generation"] for record in state_bumps]
== generations[1:],
"state_file_source": all(
record.get("generation_source") == "state-file" for record in file_records
),
"state_file_in_fixture_root": all(
Path(record["generation_file"]).parent == fixture_root for record in file_records
)
and Path(launcher_registration["generation_file"]).parent == fixture_root,
"all_lifecycle_boundaries": {"startup", "reload", "fork", "new", "resume"}
<= observed_reasons,
"lease_anchor_fixture": launcher_registration.get("session_id_shape") == "hex-256",
"verified_revoked_on_reload": reload_revoke.get("prior_lease") == "VERIFIED"
and reload_revoke.get("prior_lease_revoked") is True,
"new_generation_unverified": current_authorization.get("code") == "MUTATOR_UNVERIFIED",
"prior_generation_stale": superseded_authorization.get("code") == "STALE_GENERATION",
}
failed = [name for name, passed in checks.items() if not passed]
if failed:
raise AssertionError(f"D4 checks failed: {', '.join(failed)}")
passed = all(checks.values())
if not passed:
raise AssertionError("D4 PASS derivation failed")
return {
"machine_assertions": "PASS" if passed else "FAIL",
"checks": checks,
"same_pid_starttime": next(iter(identities)),
"generations": generations,
"reload_revoke_verified": checks["verified_revoked_on_reload"],
"lease_anchor_fixture": checks["lease_anchor_fixture"],
"file_backed_generation": checks["state_file_drives_lifecycle"],
"new_generation_code": current_authorization["code"],
"superseded_generation_code": superseded_authorization["code"],
}
def isolated_environment(
root: Path, index: int, workspace: Path, socket_path: Path, pi_log: Path
) -> dict[str, str]:
"""Build a write-confined child environment; no inherited path variable survives."""
fixture_home = root / "home"
fixture_config = root / "config"
fixture_cache = root / "cache"
fixture_state = root / "state"
fixture_runtime = root / "runtime"
fixture_tmp = root / "tmp"
fixture_heartbeat = root / "heartbeat"
fixture_mosaic_home = root / "mosaic-home"
for directory in (
fixture_home,
fixture_config,
fixture_cache,
fixture_state,
fixture_runtime,
fixture_tmp,
fixture_heartbeat,
fixture_mosaic_home,
):
directory.mkdir(mode=0o700)
# Authentication/settings are copied into fixture HOME so Pi never writes
# under the operator's HOME. They are not emitted or modified in place.
source_agent = Path.home() / ".pi" / "agent"
target_agent = fixture_home / ".pi" / "agent"
target_agent.mkdir(parents=True, mode=0o700)
for name in ("settings.json", "auth.json", "bin/fd"):
source = source_agent / name
target = target_agent / name
if source.is_file():
target.parent.mkdir(parents=True, mode=0o700)
shutil.copy2(source, target)
environment = {
"HOME": str(fixture_home),
"XDG_CONFIG_HOME": str(fixture_config),
"XDG_CACHE_HOME": str(fixture_cache),
"XDG_STATE_HOME": str(fixture_state),
"XDG_RUNTIME_DIR": str(fixture_runtime),
"TMPDIR": str(fixture_tmp),
"PATH": os.environ.get("PATH", ""),
"LANG": os.environ.get("LANG", "C.UTF-8"),
"TERM": os.environ.get("TERM", "dumb"),
"D4_GENERATION_SOCKET": str(socket_path),
"MOSAIC_LEASE_BROKER_SOCKET": str(socket_path),
"D4_PI_LOG": str(pi_log),
"MOSAIC_AGENT_NAME": f"d4-fixture-{index}",
"MOSAIC_AGENT_WORKDIR": str(workspace),
"MOSAIC_HEARTBEAT_RUN_DIR": str(fixture_heartbeat),
"MOSAIC_HOME": str(fixture_mosaic_home),
"MOSAIC_PI_FORCE_SKILLS": "",
"PI_SKIP_VERSION_CHECK": "1",
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONNOUSERSITE": "1",
}
if "PI_CODING_AGENT" in os.environ:
environment["PI_CODING_AGENT"] = os.environ["PI_CODING_AGENT"]
return environment
def scrub_fixture_credentials(root: Path) -> None:
"""Remove the copied Pi credential/config subtree before retaining evidence."""
copied_agent = root / "home" / ".pi" / "agent"
if copied_agent.exists():
shutil.rmtree(copied_agent)
if copied_agent.exists():
raise RuntimeError("D4 credential scrub failed")
def run_once(index: int) -> Path:
root = Path(tempfile.mkdtemp(prefix=f"gate0-d4-{index}-"))
workspace = root / "workspace"
sessions = root / "sessions"
workspace.mkdir(mode=0o700)
sessions.mkdir(mode=0o700)
socket_path = root / "generation.sock"
generation_log = root / "generation.jsonl"
pi_log = root / "pi.jsonl"
extension = root / "d4_extension.ts"
write_extension(extension)
broker: subprocess.Popen[str] | None = None
pi: PiRpc | None = None
try:
environment = isolated_environment(root, index, workspace, socket_path, pi_log)
# Must run before the fixture broker or Pi process is launched. It proves
# the launcher registers before exec and can only read this fixture socket.
closure = gated_launcher_precondition(root, socket_path, environment)
broker = launch_verified_broker(
closure.broker, closure.generation, socket_path, generation_log, environment
)
wait_path(socket_path)
pi = launch_verified_pi(closure.launcher, workspace, sessions, extension, environment)
pi.send({"id": "state", "type": "get_state"})
state = pi.response("state")
original_session = state["data"]["sessionFile"]
pi.prompt_and_settle(
"fixture-promote",
"Call d4_fixture_promote exactly once, then stop.",
)
pi.send({"id": "reload", "type": "prompt", "message": "/d4-reload"})
reload_response = pi.response("reload")
if not reload_response.get("success"):
raise RuntimeError(f"reload failed: {reload_response}")
for request_id, request_payload in [
("clone", {"id": "clone", "type": "clone"}),
("new", {"id": "new", "type": "new_session"}),
(
"resume",
{"id": "resume", "type": "switch_session", "sessionPath": original_session},
),
]:
pi.send(request_payload)
response = pi.response(request_id)
if not response.get("success") or response.get("data", {}).get("cancelled"):
raise RuntimeError(f"{request_id} failed: {response}")
results = assert_d4(jsonl(generation_log))
verdict = results.get("machine_assertions")
if verdict != "PASS":
raise RuntimeError(f"D4 checks did not derive PASS: {verdict}")
(root / "machine-assertions.json").write_text(
json.dumps(results, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
print(f"run={index} evidence_dir={root}")
print(f"machine_assertions={verdict}")
print(json.dumps(results, sort_keys=True))
except Exception as error:
(root / "machine-assertions.json").write_text(
json.dumps({"error": f"{type(error).__name__}: {error}"}, sort_keys=True, indent=2)
+ "\n",
encoding="utf-8",
)
print(f"run={index} evidence_dir={root}")
print("machine_assertions=FAIL")
print(f"error={type(error).__name__}: {error}")
raise
finally:
try:
try:
if pi is not None:
pi.close()
finally:
if broker is not None:
try:
request(socket_path, {"action": "shutdown-broker"})
except OSError:
pass
try:
broker.wait(timeout=5)
except subprocess.TimeoutExpired:
broker.kill()
broker.wait()
finally:
scrub_fixture_credentials(root)
return root
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runs", type=int, default=3, choices=(3,))
args = parser.parse_args()
roots: list[Path] = []
for index in range(1, args.runs + 1):
roots.append(run_once(index))
print("d4_isolation_runs=" + ",".join(str(root) for root in roots))
if __name__ == "__main__":
main()

View File

@@ -1,215 +0,0 @@
#!/usr/bin/env python3
"""P3 broker prototype: peercred-keyed runtime_generation and lease revocation."""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import secrets
import socket
import struct
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any
def proc_starttime(pid: int) -> int:
text = Path(f"/proc/{pid}/stat").read_text()
close = text.rfind(")")
return int(text[close + 2 :].split()[19])
def emit(log: Path, value: dict[str, Any]) -> None:
with log.open("a", encoding="utf-8") as out:
out.write(json.dumps(value, sort_keys=True) + "\n")
def load_generation_functions(
path: Path,
) -> tuple[Callable[[Mapping[str, str]], int], Callable[[Mapping[str, str]], int]]:
spec = importlib.util.spec_from_file_location("d4_lease_generation", path)
if spec is None or spec.loader is None:
raise ValueError("generation module is unavailable")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
reader = getattr(module, "read_runtime_generation", None)
bumper = getattr(module, "bump_runtime_generation", None)
if not callable(reader) or not callable(bumper):
raise ValueError("generation module has no read/bump functions")
return reader, bumper
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--socket", required=True)
parser.add_argument("--log", required=True)
parser.add_argument("--generation-module", required=True, type=Path)
ns = parser.parse_args()
socket_path = Path(ns.socket)
log_path = Path(ns.log)
read_runtime_generation, bump_runtime_generation = load_generation_functions(
ns.generation_module
)
socket_path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(socket_path.parent, 0o700)
socket_path.unlink(missing_ok=True)
log_path.unlink(missing_ok=True)
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(socket_path))
os.chmod(socket_path, 0o600)
server.listen(8)
generations: dict[tuple[int, int], int] = {}
lease_state: dict[tuple[int, int], str] = {}
# The gated launcher registers its own exec-preserved PID here. This is
# deliberately volatile fixture state; nothing is written outside root.
launcher_sessions: dict[tuple[int, int], str] = {}
generation_files: dict[tuple[int, int], Path] = {}
def generation_environment(identity: tuple[int, int]) -> dict[str, str]:
state_path = generation_files.get(identity)
if state_path is None or state_path.parent != socket_path.parent:
raise ValueError("generation file is outside the fixture root")
return {"MOSAIC_LEASE_GENERATION_FILE": str(state_path)}
def file_generation(identity: tuple[int, int]) -> int:
return read_runtime_generation(generation_environment(identity))
emit(log_path, {"event": "listen", "pid": os.getpid(), "socket": str(socket_path)})
while True:
conn, _ = server.accept()
with conn:
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
pid, uid, gid = struct.unpack("3i", raw)
starttime = proc_starttime(pid)
request = json.loads(conn.makefile("r", encoding="utf-8").readline())
if request.get("action") == "shutdown-broker":
conn.sendall(b'{"ok":true}\n')
break
identity = (pid, starttime)
if request.get("action") == "register_anchor":
generation = request.get("runtime_generation")
if type(generation) is not int or generation < 0:
conn.sendall(b'{"ok":false,"code":"INVALID_GENERATION"}\n')
continue
session_id = launcher_sessions.setdefault(identity, secrets.token_hex(32))
generation_file = socket_path.parent / f"generation-{session_id}.state"
generation_files[identity] = generation_file
record = {
"event": "lease_anchor_registered",
"peercred": {"pid": pid, "uid": uid, "gid": gid},
"starttime_ticks": starttime,
"runtime_generation": generation,
"session_id_shape": "hex-256",
"generation_file": str(generation_file),
}
emit(log_path, record)
reply = {
"ok": True,
"session_id": session_id,
"peer": {"pid": pid, "uid": uid, "gid": gid, "starttime": str(starttime)},
}
conn.sendall((json.dumps(reply, sort_keys=True) + "\n").encode())
continue
# The D4 extension requests this at each post-start lifecycle
# boundary; the exact WI-3 helper mutates the launcher-created file.
if request.get("action") == "bump-generation":
generation = bump_runtime_generation(generation_environment(identity))
record = {
"event": "generation_state_bumped",
"peercred": {"pid": pid, "uid": uid, "gid": gid},
"starttime_ticks": starttime,
"generation": generation,
"generation_file": str(generation_files[identity]),
"generation_source": "state-file",
}
emit(log_path, record)
conn.sendall((json.dumps(record, sort_keys=True) + "\n").encode())
continue
if request.get("action") == "promote-probe":
generation = file_generation(identity)
lease_state[identity] = "VERIFIED"
record = {
"event": "probe_lease_promoted",
"peercred": {"pid": pid, "uid": uid, "gid": gid},
"starttime_ticks": starttime,
"generation": generation,
"generation_file": str(generation_files[identity]),
"generation_source": "state-file",
"new_lease_state": "VERIFIED",
}
emit(log_path, record)
conn.sendall((json.dumps(record, sort_keys=True) + "\n").encode())
continue
# D4 fixture-only authorization observation. It exposes the broker's
# current versus superseded generation disposition without changing it.
if request.get("action") == "authorize-probe":
generation = request.get("generation")
if type(generation) is not int or generation < 0:
conn.sendall(b'{"ok":false,"code":"INVALID_GENERATION"}\n')
continue
current_generation = file_generation(identity)
current_lease = lease_state.get(identity, "NONE")
if generation < current_generation:
code = "STALE_GENERATION"
elif generation > current_generation:
code = "FUTURE_GENERATION"
elif current_lease != "VERIFIED":
code = "MUTATOR_UNVERIFIED"
else:
code = "ALLOW"
record = {
"event": "generation_authorization",
"peercred": {"pid": pid, "uid": uid, "gid": gid},
"starttime_ticks": starttime,
"requested_generation": generation,
"current_generation": current_generation,
"generation_file": str(generation_files[identity]),
"generation_source": "state-file",
"lease_state": current_lease,
"ok": code == "ALLOW",
"code": code,
}
emit(log_path, record)
conn.sendall((json.dumps(record, sort_keys=True) + "\n").encode())
continue
if request.get("action") != "lifecycle":
conn.sendall(b'{"ok":false,"reason":"invalid-action"}\n')
continue
old_generation = generations.get(identity, 0)
old_lease = lease_state.get(identity, "NONE")
new_generation = file_generation(identity)
if new_generation <= old_generation:
conn.sendall(b'{"ok":false,"code":"NON_MONOTONIC_STATE_FILE"}\n')
continue
generations[identity] = new_generation
# Every lifecycle boundary revokes first. A start establishes a new
# UNVERIFIED incarnation; it never inherits prior VERIFIED state.
lease_state[identity] = "UNVERIFIED" if request.get("phase") == "start" else "REVOKED"
record = {
"event": "runtime_generation_bump",
"peercred": {"pid": pid, "uid": uid, "gid": gid},
"starttime_ticks": starttime,
"phase": request.get("phase"),
"reason": request.get("reason"),
"old_generation": old_generation,
"new_generation": new_generation,
"generation_file": str(generation_files[identity]),
"generation_source": "state-file",
"prior_lease": old_lease,
"prior_lease_revoked": True,
"new_lease_state": lease_state[identity],
}
emit(log_path, record)
conn.sendall((json.dumps(record, sort_keys=True) + "\n").encode())
server.close()
socket_path.unlink(missing_ok=True)
if __name__ == "__main__":
main()

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""Gate0 P4: exercise Linux SO_PEERCRED and correlate it to /proc."""
from __future__ import annotations
import json
import os
import socket
import stat
import tempfile
from pathlib import Path
def proc_identity(pid: int) -> dict[str, int | str]:
stat_text = Path(f"/proc/{pid}/stat").read_text()
close = stat_text.rfind(")")
fields = stat_text[close + 2 :].split()
# fields[0] is field 3 (state); ppid is field 4 and starttime is field 22.
return {
"pid": pid,
"ppid": int(fields[1]),
"starttime_ticks": int(fields[19]),
"uid": int(Path(f"/proc/{pid}/status").read_text().split("Uid:", 1)[1].split()[0]),
"exe": os.readlink(f"/proc/{pid}/exe"),
}
def main() -> None:
with tempfile.TemporaryDirectory(prefix="gate0-p4-") as tmp:
root = Path(tmp)
os.chmod(root, 0o700)
socket_path = root / "broker.sock"
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(socket_path))
os.chmod(socket_path, 0o600)
server.listen(1)
child = os.fork()
if child == 0:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.connect(str(socket_path))
identity = proc_identity(os.getpid())
client.sendall((json.dumps(identity, sort_keys=True) + "\n").encode())
# Keep /proc/<pid> alive until the server has correlated peercred.
if client.recv(2) != b"OK":
os._exit(2)
client.close()
os._exit(0)
conn, _ = server.accept()
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
peer_pid = int.from_bytes(raw[0:4], byteorder="little", signed=True)
peer_uid = int.from_bytes(raw[4:8], byteorder="little", signed=True)
peer_gid = int.from_bytes(raw[8:12], byteorder="little", signed=True)
claimed = json.loads(conn.makefile("r", encoding="utf-8").readline())
observed = proc_identity(peer_pid)
conn.sendall(b"OK")
_, status = os.waitpid(child, 0)
root_mode = stat.S_IMODE(root.stat().st_mode)
socket_mode = stat.S_IMODE(socket_path.stat().st_mode)
if not (
peer_pid == claimed["pid"] == observed["pid"]
and peer_uid == claimed["uid"] == observed["uid"]
and claimed["starttime_ticks"] == observed["starttime_ticks"]
and root_mode == 0o700
and socket_mode == 0o600
and os.waitstatus_to_exitcode(status) == 0
):
raise AssertionError("SO_PEERCRED, /proc identity, or socket-mode correlation failed")
print("machine_assertions=PASS")
print(f"server_pid={os.getpid()} server_uid={os.getuid()} server_gid={os.getgid()}")
print(f"socket_path={socket_path}")
print(f"directory_mode={root_mode:04o} socket_mode={socket_mode:04o}")
print(f"SO_PEERCRED pid={peer_pid} uid={peer_uid} gid={peer_gid}")
print("client_claim=" + json.dumps(claimed, sort_keys=True))
print("proc_observed=" + json.dumps(observed, sort_keys=True))
print(f"pid_match={peer_pid == claimed['pid'] == observed['pid']}")
print(f"uid_match={peer_uid == claimed['uid'] == observed['uid']}")
print(
"starttime_match="
+ str(claimed["starttime_ticks"] == observed["starttime_ticks"])
)
print(f"client_exit_status={os.waitstatus_to_exitcode(status)}")
print("same_principal_socket=true")
print(
"posture=0700 parent + 0600 socket excludes other UIDs, but does not prevent "
"the same UID from unlinking/rebinding; distinct-principal system service remains "
"required for a claim stronger than T-C against same-UID counterfeit replacement"
)
conn.close()
server.close()
if __name__ == "__main__":
main()

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""Claude SessionStart additionalContext producer for P6 observation."""
from __future__ import annotations
import hashlib
import json
import os
import sys
from pathlib import Path
BLOCK = "\n".join(
[
"GATE0_CLAUDE_ATOMIC_BEGIN",
"segment-01=alpha-2d11",
"segment-02=middle-8e22",
"segment-03=omega-4f33",
"GATE0_CLAUDE_ATOMIC_END",
]
)
def starttime(pid: int) -> int:
text = Path(f"/proc/{pid}/stat").read_text()
return int(text[text.rfind(")") + 2 :].split()[19])
def main() -> None:
hook_input = json.load(sys.stdin)
log = Path(os.environ["GATE0_CLAUDE_HOOK_LOG"])
record = {
"hook_event_name": hook_input.get("hook_event_name"),
"pid": os.getpid(),
"ppid": os.getppid(),
"starttime_ticks": starttime(os.getpid()),
"block_length": len(BLOCK.encode()),
"block_sha256": hashlib.sha256(BLOCK.encode()).hexdigest(),
"emission": "one hookSpecificOutput.additionalContext string field",
}
log.write_text(json.dumps(record, sort_keys=True) + "\n")
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": BLOCK,
}
}
)
)
if __name__ == "__main__":
main()

View File

@@ -1,145 +0,0 @@
#!/usr/bin/env python3
"""Run real Claude 2.1.x through `mosaic yolo` for P6 observation."""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
HERE = Path(__file__).resolve().parent
BLOCK = "\n".join(
[
"GATE0_CLAUDE_ATOMIC_BEGIN",
"segment-01=alpha-2d11",
"segment-02=middle-8e22",
"segment-03=omega-4f33",
"GATE0_CLAUDE_ATOMIC_END",
]
)
def strings(value: Any):
if isinstance(value, str):
yield value
elif isinstance(value, list):
for item in value:
yield from strings(item)
elif isinstance(value, dict):
for item in value.values():
yield from strings(item)
def main() -> None:
with tempfile.TemporaryDirectory(prefix="gate0-p6-claude-") as temp:
root = Path(temp)
workspace = root / "workspace"
workspace.mkdir()
settings = root / "settings.json"
hook_log = root / "hook.jsonl"
settings.write_text(
json.dumps(
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": f'python3 "{HERE / "p6_claude_hook.py"}"',
"timeout": 20,
}
]
}
]
}
}
)
)
env = os.environ.copy()
env["GATE0_CLAUDE_HOOK_LOG"] = str(hook_log)
command = [
"mosaic",
"yolo",
"claude",
"--settings",
str(settings),
"--model",
"haiku",
"--print",
"--output-format",
"stream-json",
"--verbose",
"--include-hook-events",
"--max-budget-usd",
"0.10",
"Return only the exact full GATE0_CLAUDE_ATOMIC_BEGIN through GATE0_CLAUDE_ATOMIC_END block injected by SessionStart, with no code fence or commentary.",
]
result = subprocess.run(
command,
cwd=workspace,
env=env,
stdin=subprocess.DEVNULL,
text=True,
capture_output=True,
timeout=150,
check=False,
)
events: list[dict[str, Any]] = []
for line in result.stdout.splitlines():
try:
events.append(json.loads(line))
except json.JSONDecodeError:
continue
hook_events = [
event
for event in events
if event.get("type") == "system"
and event.get("subtype") in {"hook_started", "hook_response"}
]
full_matches = [text for event in events for text in strings(event) if BLOCK in text]
exact_matches = [text for event in events for text in strings(event) if text == BLOCK]
assistant_texts: list[str] = []
for event in events:
if event.get("type") != "assistant":
continue
for text in strings(event.get("message", {})):
if "GATE0_CLAUDE_ATOMIC_BEGIN" in text:
assistant_texts.append(text)
if result.returncode != 0:
raise AssertionError(f"Claude probe exited {result.returncode}")
if not any(event.get("subtype") == "hook_response" and event.get("outcome") == "success" for event in hook_events):
raise AssertionError("Claude SessionStart hook did not complete successfully")
if BLOCK not in exact_matches:
raise AssertionError("Claude did not return an exact full-block field")
print("$ python3 docs/compaction-refresh/probes/p6_claude_run.py")
print("machine_assertions=PASS")
print("command=mosaic yolo claude --settings <isolated> --model haiku --print --output-format stream-json --verbose --include-hook-events <prompt>")
print("claude_version=" + subprocess.check_output(["claude", "--version"], text=True).strip())
print("mosaic_version=" + subprocess.check_output(["mosaic", "--version"], text=True).strip())
print(f"exit_code={result.returncode}")
print("hook_process_log=" + hook_log.read_text().strip())
for event in hook_events:
print("hook_stream_event=" + json.dumps(event, sort_keys=True))
print(f"block_length={len(BLOCK.encode())}")
print(f"block_sha256={hashlib.sha256(BLOCK.encode()).hexdigest()}")
print(f"stream_fields_containing_full_block={len(full_matches)}")
print(f"stream_fields_exactly_equal_block={len(exact_matches)}")
for text in assistant_texts:
print(f"assistant_copy_length={len(text.encode())}")
print(f"assistant_copy_sha256={hashlib.sha256(text.encode()).hexdigest()}")
print(f"assistant_copy_exact={text == BLOCK}")
print("assistant_copy=" + json.dumps(text))
if result.stderr.strip():
print("stderr_excerpt=" + json.dumps(result.stderr.splitlines()[:10]))
if __name__ == "__main__":
main()

View File

@@ -1,350 +0,0 @@
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
import { Type } from 'typebox';
import { createHash, randomUUID } from 'node:crypto';
import { readFileSync, appendFileSync, statSync } from 'node:fs';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
const SELF = resolve(fileURLToPath(import.meta.url).split('?')[0]!);
const LOG = process.env['GATE0_PI_LOG'];
const CONTEXT_BLOCK =
process.env['GATE0_PI_CONTEXT_BLOCK'] ??
[
'GATE0_PI_ATOMIC_BEGIN',
'segment-01=alpha-7e31',
'segment-02=middle-9c42',
'segment-03=omega-5b83',
'GATE0_PI_ATOMIC_END',
].join('\n');
let sequence = 0;
function sha(value: string | Buffer): string {
return createHash('sha256').update(value).digest('hex');
}
function procStarttime(): number {
const text = readFileSync(`/proc/${process.pid}/stat`, 'utf8');
const close = text.lastIndexOf(')');
const fields = text.slice(close + 2).trim().split(/\s+/);
return Number(fields[19]);
}
function log(event: string, details: Record<string, unknown> = {}): void {
if (!LOG) return;
sequence += 1;
appendFileSync(
LOG,
`${JSON.stringify({ seq: sequence, event, pid: process.pid, starttime_ticks: procStarttime(), ...details })}\n`,
);
}
function argvExtensions(): string[] {
const result: string[] = [];
for (let i = 0; i < process.argv.length; i += 1) {
if (process.argv[i] === '--extension' || process.argv[i] === '-e') {
const candidate = process.argv[i + 1];
if (candidate) result.push(resolve(candidate));
}
}
return result;
}
interface SourceValidation {
ok: boolean;
reason: string;
fragment?: string;
}
function validateSources(): SourceValidation {
const manifestPath = process.env['GATE0_SOURCE_MANIFEST'];
if (!manifestPath) return { ok: true, reason: 'no-manifest-probe-disabled' };
try {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
maxBytes: number;
fragments: Array<{ path: string; sha256: string }>;
};
for (const fragment of manifest.fragments) {
let fileStat;
try {
fileStat = statSync(fragment.path);
} catch {
return { ok: false, reason: 'missing', fragment: fragment.path };
}
if (!fileStat.isFile()) {
return { ok: false, reason: 'not-regular-file', fragment: fragment.path };
}
if (fileStat.size > manifest.maxBytes) {
return { ok: false, reason: 'oversize', fragment: fragment.path };
}
const bytes = readFileSync(fragment.path);
if (sha(bytes) !== fragment.sha256) {
return { ok: false, reason: 'hash-mismatch', fragment: fragment.path };
}
}
return { ok: true, reason: 'all-fragments-valid' };
} catch (error) {
return { ok: false, reason: `manifest-error:${error instanceof Error ? error.name : 'unknown'}` };
}
}
function brokerRequest(payload: Record<string, unknown>): Promise<Record<string, unknown>> {
const socketPath = process.env['GATE0_GENERATION_SOCKET'];
if (!socketPath) return Promise.resolve({ skipped: true });
return new Promise((resolvePromise, reject) => {
const socket = net.createConnection(socketPath);
let buffer = '';
socket.setEncoding('utf8');
socket.on('connect', () => socket.write(`${JSON.stringify(payload)}\n`));
socket.on('data', (chunk) => {
buffer += chunk;
const newline = buffer.indexOf('\n');
if (newline < 0) return;
socket.end();
resolvePromise(JSON.parse(buffer.slice(0, newline)) as Record<string, unknown>);
});
socket.on('error', reject);
});
}
function markerPaths(value: unknown, path = '$'): string[] {
const matches: string[] = [];
if (typeof value === 'string') {
if (value.includes(CONTEXT_BLOCK)) matches.push(path);
return matches;
}
if (Array.isArray(value)) {
value.forEach((item, index) => matches.push(...markerPaths(item, `${path}[${index}]`)));
return matches;
}
if (value && typeof value === 'object') {
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
matches.push(...markerPaths(item, `${path}.${key}`));
}
}
return matches;
}
function assistantToolIds(message: unknown): string[] {
if (!message || typeof message !== 'object') return [];
const candidate = message as { role?: string; content?: unknown };
if (candidate.role !== 'assistant' || !Array.isArray(candidate.content)) return [];
return candidate.content
.filter(
(block): block is { type: 'toolCall'; id: string } =>
Boolean(
block &&
typeof block === 'object' &&
(block as { type?: string }).type === 'toolCall' &&
typeof (block as { id?: unknown }).id === 'string',
),
)
.map((block) => block.id);
}
function assistantText(message: unknown): string {
if (!message || typeof message !== 'object') return '';
const candidate = message as { role?: string; content?: unknown };
if (candidate.role !== 'assistant' || !Array.isArray(candidate.content)) return '';
return candidate.content
.filter(
(block): block is { type: 'text'; text: string } =>
Boolean(
block &&
typeof block === 'object' &&
(block as { type?: string }).type === 'text' &&
typeof (block as { text?: unknown }).text === 'string',
),
)
.map((block) => block.text)
.join('');
}
export default function register(pi: ExtensionAPI) {
const localProviderUrl = process.env['GATE0_LOCAL_PROVIDER_URL'];
if (localProviderUrl) {
pi.registerProvider('gate0-local', {
baseUrl: localProviderUrl,
apiKey: 'gate0-probe-not-a-secret',
api: 'openai-completions',
models: [
{
id: 'gate0-model',
name: 'Gate0 deterministic local model',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_000,
maxTokens: 1_024,
},
],
});
}
const extensions = argvExtensions();
const lastPosition = extensions.length > 0 && extensions.at(-1) === SELF;
interface RequestCycle {
nonce: string;
verified: boolean;
sourceReason: string;
}
let buildingCycle: RequestCycle | undefined;
const inFlightCycles: RequestCycle[] = [];
const toolNonce = new Map<string, { nonce: string; verified: boolean; sourceReason: string }>();
pi.on('session_start', async (event) => {
const broker = await brokerRequest({ action: 'lifecycle', phase: 'start', reason: event.reason });
log('session_start', {
reason: event.reason,
extensions,
self: SELF,
lastPosition,
gateState: lastPosition ? 'UNVERIFIED_READY' : 'CLOSED_NOT_LAST',
broker,
});
});
pi.on('session_shutdown', async (event) => {
const broker = await brokerRequest({ action: 'lifecycle', phase: 'shutdown', reason: event.reason });
log('session_shutdown', { reason: event.reason, broker });
});
pi.on('context', async (event) => {
const validation = validateSources();
buildingCycle = {
nonce: randomUUID(),
sourceReason: validation.reason,
verified: lastPosition && validation.ok,
};
const inputJson = JSON.stringify(event.messages);
const injected = {
role: 'custom' as const,
customType: 'gate0-context',
content: CONTEXT_BLOCK,
display: false,
timestamp: Date.now(),
};
const outputMessages = buildingCycle.verified
? [...event.messages, injected]
: [...event.messages];
const outputPrefix = outputMessages.slice(0, event.messages.length);
const sourceBroker = validation.ok
? { action: 'none', reason: 'source-valid' }
: await brokerRequest({ action: 'source-invalid', reason: validation.reason });
log('context_return', {
requestNonce: buildingCycle.nonce,
sourceValidation: validation,
sourceBroker,
lastPosition,
promotion: false,
injectionDecision: buildingCycle.verified ? 'ONE_ATOMIC_AGENT_MESSAGE' : 'REFUSED',
inputCount: event.messages.length,
outputCount: outputMessages.length,
prefixHashBefore: sha(inputJson),
prefixHashAfter: sha(JSON.stringify(outputPrefix)),
prefixPreservedByReturn: sha(inputJson) === sha(JSON.stringify(outputPrefix)),
blockLength: CONTEXT_BLOCK.length,
blockSha256: sha(CONTEXT_BLOCK),
});
return { messages: outputMessages };
});
pi.on('before_provider_request', async (event) => {
const paths = markerPaths(event.payload);
const cycle = buildingCycle;
buildingCycle = undefined;
if (cycle) inFlightCycles.push(cycle);
log('before_provider_request', {
requestNonce: cycle?.nonce,
inFlightDepth: inFlightCycles.length,
markerOccurrences: paths.length,
markerPaths: paths,
finalPayloadValid: Boolean(cycle?.verified && paths.length === 1),
});
});
pi.on('after_provider_response', async (event) => {
const cycle = inFlightCycles[0];
log('after_provider_response', {
requestNonce: cycle?.nonce,
status: event.status,
assistantContentAvailableAtThisHook: false,
timing: 'headers/status before stream consumption',
});
});
pi.on('message_end', async (event) => {
const role = (event.message as { role?: string }).role;
const ids = assistantToolIds(event.message);
const text = assistantText(event.message);
const cycle = role === 'assistant' ? inFlightCycles.shift() : undefined;
if (ids.length > 0 && cycle) {
for (const id of ids) {
toolNonce.set(id, {
nonce: cycle.nonce,
verified: cycle.verified,
sourceReason: cycle.sourceReason,
});
}
}
log('message_end', {
role,
assistantContentObserved: role === 'assistant',
requestNonce: cycle?.nonce,
inFlightDepthAfter: inFlightCycles.length,
toolCallIds: ids,
nonceMappings: ids.map((id) => ({ toolCallId: id, requestNonce: cycle?.nonce })),
exactContextBlockCopied: text.includes(CONTEXT_BLOCK),
assistantTextSha256: text ? sha(text) : null,
});
});
pi.on('tool_call', async (event) => {
const mapping = toolNonce.get(event.toolCallId);
const allowed = Boolean(lastPosition && mapping?.verified);
log('tool_call', {
toolCallId: event.toolCallId,
toolName: event.toolName,
mapping: mapping ?? null,
allowed,
reason: !lastPosition
? 'closed-not-last'
: !mapping
? 'unknown-tool-call-id'
: !mapping.verified
? `unverified-source:${mapping.sourceReason}`
: 'exact-tool-call-id-mapped-to-verified-request-nonce',
});
if (!allowed) return { block: true, reason: 'Gate0 probe refused unverified tool batch' };
});
pi.on('agent_settled', async () => {
log('agent_settled', { retainedNonceMappingsBeforeClear: toolNonce.size });
toolNonce.clear();
});
pi.registerTool({
name: 'gate0_nonce_probe',
label: 'Gate0 Nonce Probe',
description: 'Gate0-only harmless tool used to prove toolCallId to request-nonce correlation.',
parameters: Type.Object({ label: Type.String() }),
async execute(toolCallId, params) {
const broker = await brokerRequest({ action: 'promote-probe' });
log('tool_execute', { toolCallId, label: params.label, broker });
return {
content: [{ type: 'text', text: `gate0_nonce_probe executed for ${params.label}` }],
details: { harmless: true },
};
},
});
pi.registerCommand('gate0-reload', {
description: 'Trigger a real same-PID Pi extension/runtime reload.',
handler: async (_args, ctx) => {
log('reload_command_before');
await ctx.reload();
return;
},
});
}

View File

@@ -1,450 +0,0 @@
#!/usr/bin/env python3
"""Drive real Pi 0.80.x RPC for P2/P3/P5/P6 runtime evidence."""
from __future__ import annotations
import hashlib
import json
import os
import queue
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
from typing import Any, Callable
HERE = Path(__file__).resolve().parent
BLOCK = "\n".join(
[
"GATE0_PI_ATOMIC_BEGIN",
"segment-01=alpha-7e31",
"segment-02=middle-9c42",
"segment-03=omega-5b83",
"GATE0_PI_ATOMIC_END",
]
)
def wait_path(path: Path, timeout: float = 20) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if path.exists():
return
time.sleep(0.05)
raise TimeoutError(f"timed out waiting for {path}")
def socket_request(path: Path, payload: dict[str, object]) -> None:
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
conn.connect(str(path))
conn.sendall((json.dumps(payload) + "\n").encode())
conn.makefile("r", encoding="utf-8").readline()
conn.close()
def jsonl(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
return [json.loads(line) for line in path.read_text().splitlines() if line]
class PiRpc:
def __init__(self, command: list[str], cwd: Path, env: dict[str, str]):
self.process = subprocess.Popen(
command,
cwd=cwd,
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
start_new_session=True,
)
self.events: queue.Queue[dict[str, Any]] = queue.Queue()
self.raw_lines: list[str] = []
self.stderr_lines: list[str] = []
threading.Thread(target=self._read_stdout, daemon=True).start()
threading.Thread(target=self._read_stderr, daemon=True).start()
def _read_stdout(self) -> None:
assert self.process.stdout is not None
for line in self.process.stdout:
stripped = line.rstrip("\n")
self.raw_lines.append(stripped)
try:
event = json.loads(stripped)
except json.JSONDecodeError:
continue
self.events.put(event)
def _read_stderr(self) -> None:
assert self.process.stderr is not None
for line in self.process.stderr:
self.stderr_lines.append(line.rstrip("\n"))
def send(self, payload: dict[str, object]) -> None:
assert self.process.stdin is not None
self.process.stdin.write(json.dumps(payload) + "\n")
self.process.stdin.flush()
def wait(self, predicate: Callable[[dict[str, Any]], bool], description: str, timeout: float = 180) -> dict[str, Any]:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.process.poll() is not None and self.events.empty():
raise RuntimeError(
f"Pi exited {self.process.returncode} while waiting for {description}: "
+ " | ".join(self.stderr_lines[-5:])
)
try:
event = self.events.get(timeout=0.2)
except queue.Empty:
continue
if predicate(event):
return event
raise TimeoutError(f"timed out waiting for {description}")
def response(self, request_id: str, timeout: float = 180) -> dict[str, Any]:
return self.wait(
lambda event: event.get("type") == "response" and event.get("id") == request_id,
f"response {request_id}",
timeout,
)
def prompt_and_settle(self, request_id: str, message: str) -> None:
self.send({"id": request_id, "type": "prompt", "message": message})
response = self.response(request_id)
if not response.get("success"):
raise RuntimeError(f"prompt rejected: {response}")
self.wait(lambda event: event.get("type") == "agent_settled", f"agent_settled {request_id}")
def close(self) -> None:
if self.process.poll() is None:
try:
os.killpg(self.process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
self.process.wait(timeout=8)
except subprocess.TimeoutExpired:
os.killpg(self.process.pid, signal.SIGKILL)
self.process.wait(timeout=5)
def manifest(path: Path, fragment: Path, expected_hash: str, max_bytes: int = 64) -> None:
path.write_text(
json.dumps(
{
"maxBytes": max_bytes,
"fragments": [{"path": str(fragment), "sha256": expected_hash}],
},
sort_keys=True,
)
)
def run_open(root: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str], list[str]]:
workspace = root / "workspace"
workspace.mkdir()
session_dir = root / "sessions"
session_dir.mkdir()
pi_log = root / "pi-hooks.jsonl"
generation_log = root / "generation.jsonl"
generation_socket = root / "generation.sock"
source_manifest = root / "manifest.json"
valid_fragment = root / "fragment.md"
valid_fragment.write_text("NORMATIVE-FRAGMENT-v1\n")
expected = hashlib.sha256(valid_fragment.read_bytes()).hexdigest()
manifest(source_manifest, valid_fragment, expected)
broker = subprocess.Popen(
[
sys.executable,
str(HERE / "p3_generation_broker.py"),
"--socket",
str(generation_socket),
"--log",
str(generation_log),
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
wait_path(generation_socket)
env = os.environ.copy()
env.update(
{
"GATE0_PI_LOG": str(pi_log),
"GATE0_GENERATION_SOCKET": str(generation_socket),
"GATE0_SOURCE_MANIFEST": str(source_manifest),
"GATE0_PI_CONTEXT_BLOCK": BLOCK,
"MOSAIC_PI_FORCE_SKILLS": "",
"PI_SKIP_VERSION_CHECK": "1",
}
)
command = [
"mosaic",
"yolo",
"pi",
"--mode",
"rpc",
"--session-dir",
str(session_dir),
"--no-extensions",
"--no-context-files",
"--no-prompt-templates",
"--model",
"openai-codex/gpt-5.6-sol",
"--thinking",
"medium",
"--extension",
str(HERE / "pi_gate0_extension.ts"),
]
pi = PiRpc(command, workspace, env)
try:
pi.send({"id": "state-0", "type": "get_state"})
state0 = pi.response("state-0")
original_session = state0["data"]["sessionFile"]
pi.prompt_and_settle(
"p2",
"Call gate0_nonce_probe exactly once with label p2. After the tool finishes, copy the exact full GATE0_PI_ATOMIC_BEGIN through GATE0_PI_ATOMIC_END block from context, with no commentary.",
)
# P3 immediately follows the valid P2 promotion so reload must revoke a
# genuinely VERIFIED prior generation, not an already-invalid source run.
pi.send({"id": "reload", "type": "prompt", "message": "/gate0-reload"})
reload_response = pi.response("reload")
if not reload_response.get("success"):
raise RuntimeError(f"reload command failed: {reload_response}")
pi.send({"id": "clone", "type": "clone"})
clone_response = pi.response("clone")
if not clone_response.get("success") or clone_response.get("data", {}).get("cancelled"):
raise RuntimeError(f"clone failed: {clone_response}")
pi.send({"id": "new", "type": "new_session"})
new_response = pi.response("new")
if not new_response.get("success") or new_response.get("data", {}).get("cancelled"):
raise RuntimeError(f"new session failed: {new_response}")
pi.send(
{
"id": "resume",
"type": "switch_session",
"sessionPath": original_session,
}
)
resume_response = pi.response("resume")
if not resume_response.get("success") or resume_response.get("data", {}).get("cancelled"):
raise RuntimeError(f"resume failed: {resume_response}")
# P5 missing fragment: action-time source validation must revoke/refuse.
manifest(source_manifest, root / "absent-fragment.md", expected)
pi.prompt_and_settle(
"p5-missing",
"Call gate0_nonce_probe exactly once with label p5-missing, then stop.",
)
# P5 oversize fragment: expected hash is correct, size limit is not.
oversize = root / "oversize.md"
oversize.write_text("X" * 65)
manifest(source_manifest, oversize, hashlib.sha256(oversize.read_bytes()).hexdigest(), 64)
pi.prompt_and_settle(
"p5-oversize",
"Call gate0_nonce_probe exactly once with label p5-oversize, then stop.",
)
# P5 hash mismatch: size is valid but bytes differ from expected.
mismatch = root / "mismatch.md"
mismatch.write_text("tampered\n")
manifest(source_manifest, mismatch, expected, 64)
pi.prompt_and_settle(
"p5-hash",
"Call gate0_nonce_probe exactly once with label p5-hash-mismatch, then stop.",
)
time.sleep(1)
return jsonl(pi_log), jsonl(generation_log), list(pi.raw_lines), list(pi.stderr_lines)
finally:
pi.close()
try:
socket_request(generation_socket, {"action": "shutdown-broker"})
except OSError:
pass
try:
broker.wait(timeout=5)
except subprocess.TimeoutExpired:
broker.kill()
broker.wait()
def run_closed(root: Path) -> list[dict[str, Any]]:
workspace = root / "closed-workspace"
workspace.mkdir()
pi_log = root / "closed-hooks.jsonl"
env = os.environ.copy()
env.update(
{
"GATE0_PI_LOG": str(pi_log),
"MOSAIC_PI_FORCE_SKILLS": "",
"PI_SKIP_VERSION_CHECK": "1",
}
)
command = [
"mosaic",
"yolo",
"pi",
"--mode",
"rpc",
"--no-session",
"--no-extensions",
"--no-context-files",
"--no-prompt-templates",
"--extension",
str(HERE / "pi_gate0_extension.ts"),
"--extension",
str(HERE / "pi_later_extension.ts"),
]
pi = PiRpc(command, workspace, env)
try:
pi.send({"id": "closed-state", "type": "get_state"})
pi.response("closed-state")
time.sleep(0.5)
return jsonl(pi_log)
finally:
pi.close()
def main() -> None:
with tempfile.TemporaryDirectory(prefix="gate0-pi-") as temp:
root = Path(temp)
records, generations, rpc_lines, stderr_lines = run_open(root)
closed = run_closed(root)
p2_message = next(
r for r in records if r["event"] == "message_end" and r.get("nonceMappings")
)
p2_tool = next(r for r in records if r["event"] == "tool_call" and r.get("allowed"))
mapped = p2_message["nonceMappings"][0]
assert mapped["toolCallId"] == p2_tool["toolCallId"]
assert mapped["requestNonce"] == p2_tool["mapping"]["nonce"]
assert next(r for r in records if r["event"] == "session_start")["lastPosition"] is True
assert next(r for r in closed if r["event"] == "session_start")["gateState"] == "CLOSED_NOT_LAST"
reload_revoke = next(
r
for r in generations
if r["event"] == "runtime_generation_bump"
and r.get("reason") == "reload"
and r.get("phase") == "shutdown"
)
assert reload_revoke["prior_lease"] == "VERIFIED"
assert reload_revoke["prior_lease_revoked"] is True
for reason in {"missing", "oversize", "hash-mismatch"}:
assert any(
r["event"] == "context_return"
and r.get("sourceValidation", {}).get("reason") == reason
and r.get("injectionDecision") == "REFUSED"
and r.get("promotion") is False
for r in records
)
assert any(
r["event"] == "tool_call"
and r.get("mapping", {}).get("sourceReason") == reason
and r.get("allowed") is False
for r in records
)
assert any(
r["event"] == "message_end" and r.get("exactContextBlockCopied") is True
for r in records
)
print("$ python3 docs/compaction-refresh/probes/pi_gate0_run.py")
print("machine_assertions=PASS")
print("runtime_versions:")
print(" " + subprocess.check_output(["pi", "--version"], text=True).strip())
print(" " + subprocess.check_output(["mosaic", "--version"], text=True).strip())
print("\nP2_EVENT_ORDER_AND_NONCE_MAP:")
for record in records:
if record["seq"] <= 12 and record["event"] in {
"after_provider_response",
"message_end",
"tool_call",
"tool_execute",
} and (
record["event"] != "message_end"
or record.get("role") == "assistant"
):
print(json.dumps(record, sort_keys=True))
print("\nP2_LAST_OR_CLOSED:")
print(json.dumps(next(r for r in records if r["event"] == "session_start"), sort_keys=True))
print(json.dumps(next(r for r in closed if r["event"] == "session_start"), sort_keys=True))
print("\nP3_GENERATION_BROKER:")
for record in generations:
if record["event"] in {"probe_lease_promoted", "runtime_generation_bump"}:
print(json.dumps(record, sort_keys=True))
print("\nP5_SOURCE_INVALIDATION:")
fault_reasons = {"missing", "oversize", "hash-mismatch"}
emitted_context: set[str] = set()
emitted_tool: set[str] = set()
for record in records:
source_reason = record.get("sourceValidation", {}).get("reason")
if (
record["event"] == "context_return"
and source_reason in fault_reasons
and source_reason not in emitted_context
):
print(json.dumps(record, sort_keys=True))
emitted_context.add(source_reason)
mapping_reason = record.get("mapping", {}).get("sourceReason")
if (
record["event"] == "tool_call"
and not record.get("allowed")
and mapping_reason in fault_reasons
and mapping_reason not in emitted_tool
):
print(json.dumps(record, sort_keys=True))
emitted_tool.add(mapping_reason)
emitted_broker: set[str] = set()
for record in generations:
reason = record.get("source_reason")
if record["event"] == "source_invalidation_revoke" and reason not in emitted_broker:
print(json.dumps(record, sort_keys=True))
emitted_broker.add(str(reason))
print("\nP6_PI_CONTEXT_ATOMIC_OBSERVATION:")
for record in records:
include = (
(record["event"] == "context_return" and record.get("injectionDecision") == "ONE_ATOMIC_AGENT_MESSAGE")
or (record["event"] == "before_provider_request" and record.get("finalPayloadValid"))
or (record["event"] == "message_end" and record.get("exactContextBlockCopied"))
)
if include and record["seq"] <= 12:
print(json.dumps(record, sort_keys=True))
print("\nRPC_EVENT_COUNTS:")
counts: dict[str, int] = {}
for line in rpc_lines:
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
key = str(event.get("type"))
counts[key] = counts.get(key, 0) + 1
print(json.dumps(counts, sort_keys=True))
print("stderr_nonempty=" + str(bool(stderr_lines)))
for line in stderr_lines[:10]:
print("stderr: " + line[:500])
if __name__ == "__main__":
main()

View File

@@ -1,8 +0,0 @@
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
// Deliberately loaded after pi_gate0_extension.ts. The Gate0 extension must
// observe its argv position and remain CLOSED rather than claiming finality.
export default function register(pi: ExtensionAPI) {
pi.on('context', async (event) => ({ messages: [...event.messages] }));
pi.on('before_provider_request', async () => undefined);
}

View File

@@ -1,65 +0,0 @@
# Gate0 Probe-3 (D4) Class-B — §3-Conformance Review v3
**Verdict: ✅ PASS**
## Pin (G1 — reviewed object, mandatory)
- **Reviewed object = `ace6066762c088f4b9729860da71b4c84451a7c3`** (harness commit, branch `feat/827-gate0-probe`, `mosaicstack/stack` @ git.mosaicstack.dev).
- **Reviewed file:** `docs/compaction-refresh/probes/p3_d4_focused_run.py`
- **Harness sha256 (pushed provider bytes, fetched `-o FILE`, FULL-40 ref, verified before trust):**
`2f11c9391c0eef203f26b1206bee8bc4cd106e8c1192399c5e7b71f41a3f6b75` (17162 bytes; no not-found sentinel).
- **§3 amendment authority read at pin:** `GATE0-PROBE3-EXEC-AMENDMENT.md` @ ref `571f239154c6793fb1a5eac0d1cd4182f286a3ac`,
sha256 `9ac9ff873fad41a6e15763cc89cb94d0bc4a6b0cf9b6770561d1781b03f63276` (7699 bytes). MUST-HAVE/MUST-BE-ABSENT
confirmed against the actual fetched §3 text, not a paraphrase.
## Independence (G2)
Distinct Opus §3-conformance reviewer (Gate-16 author≠reviewer). I did **not** build this harness (author =
ms-rev-826); I am not Mos. This verdict is my own; the author did not author or edit it. Byte review only — **ran
nothing** (no harness, no broker, no sockets/state). Reviewed across v1 (FAIL, live-broker launch path) → v2 (PASS,
later found runtime-dead producer) → this v3 (closes the live-path review-gap).
## Why v3 (the review-gap closed)
v2 PASS @`839d156f` credited the static presence of `lease_anchor_registered` as isolation proof. At FIRE the
producing path was **dead**: the harness drove the *released* `mosaic` binary, which launched Pi **ungated**, so
`register_anchor` never ran. Static presence of an assertion ≠ its producing path executing. v3 requires the
producing path to be **live at runtime**.
## Surface-by-surface
| Surface | Result | Evidence (file:line) |
|---|---|---|
| Pushed bytes fetched + sha-verified | ✅ | sha256==`2f11c939…`, 17162B, no sentinel |
| (a) LIVE-PATH — drives the **gated** launcher, producer in the exec chain, NOT released `mosaic`/plain `execRuntime` | ✅ | launch = `python3 <GATED_LAUNCHER> --runtime pi -- pi …` :357-364; `GATED_LAUNCHER=…/launch-runtime.py` :32, pinned `GATED_WI_HEAD=abd2791f…` :31; `mosaic yolo`/`execRuntime` = 0 hits. `launch-runtime.py` unconditionally `register_anchor`s before `execvpe`, so the producer is in the invoked chain |
| (b) Fail-closed precondition present + correct (gated + fixture-socket, refuses otherwise), invoked before all launches | ✅ | `gated_launcher_precondition` :229-251, called :342 **before** broker Popen :343 and Pi launch :357. Verifies (ii) `MOSAIC_LEASE_BROKER_SOCKET==fixture` :232 + fixture in tempdir :234; (i) launcher HEAD==`abd2791f` :243 + source has `register_anchor` **before** `execute(command[0]…)` and reads `MOSAIC_LEASE_BROKER_SOCKET` :246-250. Raises `RuntimeError` (no run) on any miss :233/:235/:242/:244/:250 |
| (c) Fixture-socket isolation (no live/default broker reachable, single p3 fixture, non-destructive) | ✅ | `pop("MOSAIC_LEASE_BROKER_SOCKET")` :330 + set to fixture socket :334; harness invokes `launch-runtime.py` directly so it reads `MOSAIC_LEASE_BROKER_SOCKET`=fixture with **no** `defaultLeaseBrokerSocket`/XDG/`/run/user` fallback in the path; `register_anchor` served by the single p3 fixture; `p3_generation_broker.py` **zero diff** vs `839d156f` (in-memory volatile hex-256 session `secrets.token_hex(32)`, nothing durable outside tempdir) |
| (d) Assertion INTACT (`lease_anchor_registered` + `session_id_shape=="hex-256"`, not softened/optional/repointed) | ✅ | :256-258 (event), :298 (`hex-256`), folded into single-PID/starttime identity set :286. Not Case C |
| spawns ONLY p3_generation_broker.py | ✅ | broker Popen = `HERE/p3_generation_broker.py` :343-346; the pinned launcher is a register-before-exec launch wrapper, not a 2nd broker |
| promotion = fixture-only (not P2-banked) | ✅ | `d4_fixture_promote` "not a P2 evidence-gathering authorization"; `promote-probe` in-memory; precondition target only; no P2 import |
| D4 assertions complete | ✅ | same-PID+starttime persist (incl. launcher registration) :284-288; gen strictly increases :290-293; reload revokes genuinely-VERIFIED prior :299-300; new→`MUTATOR_UNVERIFIED` :301; prior→`STALE_GENERATION` :302; lifecycle boundaries :294-297 |
| P5 / P6 / P2-bank / retry-launder / live-effect / mechanism-change / scope-widen ABSENT | ✅ | 0 hits: `source-invalid`/`run_open`/`atomic`/`p2_provider`/`p5`/`p6`/`pi_gate0_run`/`retry`; no real-broker path; the only mechanism change is the required launch-routing fix (release→pinned gated launcher), which narrows scope, not widens |
| non-destructive | ✅ | per-run `tempfile.mkdtemp` fixtures; p3 in-memory + tempdir socket/log only; reads `/proc/<pid>/stat` (read-only) |
| deterministic | ✅ | isolated tempdir per run; deterministic assertions; session-id randomness is **shape**-asserted only (`hex-256`), `setdefault` idempotent |
| hidden exec-at-import | ✅ absent | only `if __name__ == "__main__": main()`; docstring: not executed until FIRE |
## Verdict
**PASS @ `ace60667`** — (a) LIVE-PATH, (b) fail-closed precondition, (c) fixture-socket isolation, and
(d) intact assertion all hold, with zero out-of-scope surface. The v2 review-gap (runtime-dead producer via the
released ungated `mosaic`) is structurally closed: the harness no longer invokes `mosaic` at all — it invokes the
pinned `abd2791f` `launch-runtime.py` directly (register-before-exec), and refuses to launch unless the launcher is
that pinned gated register-before-exec binary bound to this run's fixture socket.
**Findings: none.**
## Scope reminder (not a finding)
Per §3/§5 of the amendment, producing this evidence **executes** the Gate0 mechanism (launches processes, creates
socket/state artifacts, exercises revocation). This review clears the **bytes**; **FIRE remains separately gated on
Mos's explicit post-clear GO** and is not authorized by this review.
---
**Reviewer:** independent Opus §3-conformance reviewer (Gate-16 author≠reviewer). Byte review only; ran nothing.
**Reviewed object (pin):** `ace6066762c088f4b9729860da71b4c84451a7c3` · harness sha256 `2f11c9391c0eef203f26b1206bee8bc4cd106e8c1192399c5e7b71f41a3f6b75`.

View File

@@ -1,95 +0,0 @@
# Gate0 Probe-3 (D4) Observation-Fidelity — §3-Conformance Review v4
**Verdict: ✅ PASS**
## Pin (G1 — reviewed object, mandatory)
- **Reviewed object = `484849387006ab5561798506fd6042ddbd5617de`** (harness commit, branch `feat/827-gate0-probe`, `mosaicstack/stack` @ git.mosaicstack.dev).
- **Reviewed file:** `docs/compaction-refresh/probes/p3_d4_focused_run.py` — sha256 (pushed provider bytes, `-o FILE`, FULL-40 ref, verified before trust): `9095eab7a4ddf11bb92bb5971d49e1facad12f4692ce2081665b0af47cbe5098` (23698 bytes, no not-found sentinel). Worktree bytes at `48484938` re-hashed identical.
- **Co-reviewed fixture broker:** `docs/compaction-refresh/probes/p3_generation_broker.py` (sha256 `fd5327d2e9a2808282cbc9c4a4ccef42d2a806482b27cc48035191b9b11607c8`).
- **Traceability anchor (independently recomputed):** `GATED_LAUNCHER_SHA256 = e950e4224e280f16979d90cabb89aa1896c5ee28bed2df957e14d018d43cda82` equals the sha256 of `packages/mosaic/framework/tools/lease-broker/launch-runtime.py` at WI-3 #830 reviewed head `f400830738998db105107a2a4c69c7f2a2a6fd5d` (computed from two independent worktrees).
## Independence (G2)
Distinct Opus §3-conformance reviewer (Gate-16 author≠reviewer). I did not build this harness (author = ms-rev-826);
I am not Mos. This verdict is my own; the author did not author or edit it. Byte review only — **ran nothing** (no
harness, no broker, no sockets/state). Prior v3 PASS (`ace60667`, pinned `abd2791f`) is correctly **superseded**:
pinning a pre-`66b1e0a0` ancestor made D4 an in-memory socket simulation (hollow gate). v4 requires
mechanism-fidelity within isolation (Jason R1).
## BAR1 — Traceability (evidence attests the SHIPPED WI-3 D4 mechanism)
| Check | Result | Evidence |
|---|---|---|
| `GATED_WI_HEAD == f4008307` (not `abd2791f`) | ✅ | :33 |
| Launcher pinned by git-HEAD **and** sha256 | ✅ | precondition :285-288 (`head != GATED_WI_HEAD` raise; `sha256(launcher) != GATED_LAUNCHER_SHA256` raise); sha256 independently == f4008307's `launch-runtime.py` |
| Launcher bytes contain the file-backed mechanism | ✅ | precondition requires `register_anchor` :290, `initialize_runtime_generation(generation_file, generation)` :291, `generation-{session_id}.state` :294, `MOSAIC_LEASE_GENERATION_FILE` :295, `read_runtime_generation`+`bump_runtime_generation` in `lease_generation.py` :296-299; order `register < initialize < execute` :302-303 |
## BAR2 — Fidelity (file-backed generation, not in-memory simulation)
| # | Requirement | Result | Evidence |
|---|---|---|---|
| i | Extension bumps `generation-{sid}.state` via the real helper, not in-mem | ✅ | ext `lifecycle()` calls `broker({action:'bump-generation'})` at every post-start boundary (harness :200-205); broker `bump-generation``bump_runtime_generation(generation_environment(identity))` (broker :119-120) |
| ii | Broker reads generation via `read_runtime_generation`, not an in-mem counter | ✅ | broker loads the pinned module (`--generation-module`, :52-54); `file_generation()` = `read_runtime_generation(...)` (:77-78); authorize-probe reads `current_generation = file_generation(identity)` (:154); lifecycle `new_generation = file_generation(identity)` (:201). The in-mem `generations` dict is only an old-value cache for the monotonic guard (:199-204), never the authorization authority |
| iii | `assert_d4` observes the FILE-BACKED transition | ✅ | `state_file_source` = all records `generation_source=="state-file"` (harness :370-372); `state_file_drives_lifecycle` = state-bumps == lifecycle generations[1:] (:368-369); `new_generation_unverified→MUTATOR_UNVERIFIED` :382; `prior_generation_stale→STALE_GENERATION` :383; `verified_revoked_on_reload` :380-381 — not a socket-only bump |
| iv | `.state` bound to per-run fixture temp root | ✅ | broker `generation_environment` **raises if `state_path.parent != socket_path.parent`** (:73-74); `state_file_in_fixture_root` (:373-376); precondition forbids inherited `MOSAIC_LEASE_GENERATION_FILE` (:255-256) and requires socket in `gettempdir()` (:274-275); launcher/broker agree on `socket_path.parent / generation-{sid}.state` |
## BAR3 — Carry-over
| # | Result | Evidence |
|---|---|---|
| a LIVE-PATH (gated launcher @f4008307 at runtime, not released/plain execRuntime) | ✅ | Pi launched via `python3 <GATED_LAUNCHER> --runtime pi -- pi …` :502-505; `mosaic yolo`/`execRuntime` = 0 hits; register-before-exec producer in the invoked chain (precondition order gate) |
| b Fail-closed precondition present+correct | ✅ | `gated_launcher_precondition` :250-306, invoked :485 **before** broker/Pi; raises on socket≠fixture / gen-file-inherited / write-path-escape / head-mismatch / hash-mismatch / not-register-before-exec-file-bound |
| c Fixture-socket isolation, single p3 broker | ✅ | one broker Popen :486-500; `MOSAIC_LEASE_BROKER_SOCKET=socket_path` (fixture); direct launcher invocation ⇒ no default/XDG/`/run/user` fallback in path |
| d `lease_anchor_registered` INTACT | ✅ | broker emits event + `session_id_shape=="hex-256"` (:102-107); `record_where` requires it (:324-326); `lease_anchor_fixture` check (:379) — not deleted/softened/optional/repointed (not Case-C) |
## BAR4 — Homelab Gate-B carry-forward findings
| # | Result | Evidence |
|---|---|---|
| b4-1 gated launcher @f4008307, not released/plain execRuntime | ✅ | :502-505; 0 `mosaic yolo`/`execRuntime` |
| b4-2 **affirmative no-escape** (allow-list base, not deny-list) | ✅ | `isolated_environment` builds the child env from a **literal allow-list dict** (:442-461), NOT `os.environ.copy()`; only PATH/LANG/TERM/PI_CODING_AGENT (non-write-bearing) pass through; every write-bearing var (HOME/XDG*/TMPDIR/MOSAIC_AGENT_WORKDIR/HEARTBEAT_RUN_DIR/MOSAIC_HOME/D4_PI_LOG/socket) redirected under `root`; precondition double-checks each is `is_relative_to(root)` (:257-273). No unnamed/future inherited var survives |
| b4-3 `--runs` exactly 3, fail-closed otherwise | ✅ | `add_argument("--runs", type=int, default=3, choices=(3,))` :591 (argparse rejects any other value) |
| b4-4 cleanup try/finally spans the whole launch | ✅ | `broker=pi=None` :479-480; `try` opens **before** precondition/broker/PiRpc :482; nested `finally` always closes pi then broker+socket even on early `wait_path`/`PiRpc` failure :571-585 |
| b4-5 `-O`-safe integrity + derived PASS | ✅ | load-bearing checks in a `checks` dict; `if failed: raise AssertionError` :385-387 and `if not passed: raise` :388-390 (NO bare `assert` anywhere — grep-confirmed); PASS = `"PASS" if passed else "FAIL"` derived from `all(checks.values())` :393, re-derived+checked in `run_once` :550-553 |
## MUST-BE-ABSENT sweep
`P5` / `P6` / `P2-bank` / `retry-launder` / `mosaic yolo` / `execRuntime` / `run_open` / `atomic-observation` /
`pi_gate0_run` = **0 hits** (both files). Extension invokes only `bump-generation` / `lifecycle` /
`authorize-probe` / `promote-probe`. No live/prod/real-broker path (single fixture broker; allow-list env; launcher
pinned to fixture socket). No `.state`/gen-file path outside the fixture temp root (broker `generation_environment`
raises otherwise). §4 live effect: none. No extra broker/socket beyond the single p3. No exec-at-import (both files
`__main__`-guarded). Mechanism change is confined to the mandated R1 observation-fidelity deepening + BAR4 hardening;
no scope-widen of what the probe touches.
## Observations (transparency — not findings)
1. The fixture broker retains a **dormant `source-invalid` action** (:179-194, P5-adjacent, in-mem). It is
**never invoked** by the harness or its embedded extension (verified: extension actions are only
bump/lifecycle/authorize/promote), and `assert_d4` never observes it — so the probe does **not** exercise or bank
P5. Pre-existing shared-fixture code, unchanged. Surfaced so Mos may, if desired, apply a stricter
purge-dormant-P5-from-the-fixture standard; under the "what the probe TOUCHES/does" framing it is not a violation.
2. Fixture `HOME` receives a **read-only copy** of the operator's `~/.pi/agent` `settings.json`/`auth.json`/`bin/fd`
(:430-440, `shutil.copy2` into the fixture) so real Pi can authenticate to the model provider. It reads operator
state; it does not write/mutate operator HOME and does not emit/log credential material. Confined to the fixture.
## Verdict
**PASS @ `48484938`** — BAR1 (traceability to shipped f4008307 mechanism) + BAR2 (genuine file-backed generation,
iiv) + BAR3 (live-path / fail-closed precondition / isolation / intact assertion) + BAR4 (b4-1..b4-5) all hold,
with zero out-of-scope surface exercised. The v3 hollow-gate (ancestor pin, in-mem simulation) is structurally
closed: evidence now attests the shipped WI-3 D4 file-backed generation mechanism, launcher pinned by head+sha256,
child env write-confined by allow-list, integrity `-O`-safe with a derived PASS.
**Findings: none.**
## Scope reminder (not a finding)
Per §3/§5 of the amendment, producing this evidence **executes** the Gate0 mechanism. This review clears the
**bytes**; **FIRE remains separately gated on Mos's explicit post-clear GO** and is not authorized by this review.
---
**Reviewer:** independent Opus §3-conformance reviewer (Gate-16 author≠reviewer). Byte review only; ran nothing.
**Reviewed object (pin):** `484849387006ab5561798506fd6042ddbd5617de` · harness sha256 `9095eab7a4ddf11bb92bb5971d49e1facad12f4692ce2081665b0af47cbe5098`.

View File

@@ -1,80 +0,0 @@
# Gate0 Probe-3 (D4) Hygiene-Delta — §3-Conformance Review v5
**Verdict: ❌ FAIL** (hygiene delta (a)+(b) landed correctly and (c)+(d) hold, but homelab findings **NEW-5** and **NEW-6** are present in these bytes; both must close for PASS).
## Pin (G1 — reviewed object, mandatory)
- **Reviewed object = `7f975b95ad39096463a7548bd6be0dbb387cb61b`** (harness commit, branch `feat/827-gate0-probe`).
- **Reviewed file:** `docs/compaction-refresh/probes/p3_d4_focused_run.py` — sha256 (pushed provider bytes, `-o FILE`, FULL-40 ref, verified before trust): `c3a09a342a4b367184d44472ec6fc11f8a3aabb7e90d5a72aa6b7044b1d9b91e` (24174 bytes, no not-found sentinel).
- **Co-reviewed fixture broker:** `p3_generation_broker.py` sha256 `4db4fef1ac6658a8ca79ad5091cefc901d2aa26003265c3d6726c294cf895cad`.
## Reviewer identity / lane (independence — on the record)
This review is produced by a **distinct independent Opus §3-conformance / SECREV session** (Gate-16 author≠reviewer),
**byte review only, ran nothing**, that **did not build** this harness (author = ms-rev-826) and **is not Mos**. The
PROCESS/LANE separation (build lane ≠ review lane) holds and is attested here. Homelab's separate observation — that
the published PASS commits and the repair commits share the `ms-lead-reviewer` **Git signer identity** — is a
git-identity-signer question I do **not** self-resolve; per instruction it is routed to Mos. My lane attestation is
independent of the git signer.
## Hygiene delta (v4 `48484938` → v5 `7f975b95`) — items (a)+(b): CLOSED
Full `diff` of both files shows the delta touches **only** these:
- **(a) Creds scrubbed — CLOSED.** New `scrub_fixture_credentials(root)` (:467-475): `shutil.rmtree(root/"home"/".pi"/"agent")` then a fail-closed re-check `if copied_agent.exists(): raise` (:473-474). Invoked in the **outermost `finally`** (:598), **after** pi/broker cleanup and **before** `return root` (:600) — so it runs on the **success path too**. The scrub target exactly matches the only copy site (`target_agent = fixture_home/".pi"/"agent"`, :433). `isolated_environment` was moved inside the `try` (:485) so the scrub finally also covers a partial env-setup failure. No copied agent credential/config subtree (incl. `auth.json`) survives in any retained root. ✅
- **(b) `source-invalid` handler REMOVED — CLOSED.** `grep source-invalid / source_invalidation_revoke == 0` across **both** files. Broker delta is solely the deletion of the old `:179-194` block. ✅
## (c) Everything-else byte-stable vs `48484938` — HOLDS
Diff confirms **no other hunks**. Re-swept intact: traceability `GATED_WI_HEAD=f4008307` (:33) + `GATED_LAUNCHER_SHA256=e950e4224e…` (:35); fidelity `read_runtime_generation`/`bump_runtime_generation` + `generation_source=="state-file"` + `new→MUTATOR_UNVERIFIED`/`prior→STALE_GENERATION`; `lease_anchor_registered` + `hex-256`; `-O`-safe (0 bare `assert`, PASS derived `all(checks.values())`); allow-list literal env (0 `os.environ.copy`); `--runs choices=(3,)`; fail-closed precondition; single p3 broker. Blockers 1-4 (inherited env / `--runs`≠3 / cleanup-misses-setup-failure / `-O`-strips-checks) remain closed.
## (d) ABSENT sweep — 0
`P5`/`P6`/`P2-bank`/`retry-launder`/`mosaic yolo`/`execRuntime`/`run_open`/`atomic`/`pi_gate0` = 0 (both files);
extension invokes only `bump-generation`/`lifecycle`/`authorize-probe`/`promote-probe`; no exec-at-import.
## Homelab carry-forward — NEW-5 / NEW-6: **PRESENT → FAIL**
### 🔴 NEW-6 — GATED_WI_ROOT resolves wrong / non-portably (CONFIRMED)
`GATED_WI_ROOT = HERE.parents[3].parent / "stack-cr-wi3-revoke"` (:32). For the harness at
`…/agent-work/stack-cr-wi0-gate0/docs/compaction-refresh/probes/`, this computes
**`/home/hermes/stack-cr-wi3-revoke` — which does not exist**. The actual f4008307 worktree is
`/home/hermes/agent-work/stack-cr-wi3-revoke` = `HERE.parents[3] / "stack-cr-wi3-revoke"`. The traversal is
**off-by-one** (`.parents[3].parent` should be `.parents[3]`), and it additionally hardcodes the worktree name — a
non-portable relative assumption. On this host the precondition therefore fail-closes ("gated WI launcher is
unavailable") and the probe cannot locate/execute the pinned launcher at all. **Real resolution defect** (independently
computed by path arithmetic; harness not run). **file:line — :32.**
*Fix:* derive `GATED_WI_ROOT` from a portable, explicit anchor (e.g. an env-provided path validated to be the
f4008307 worktree, or `HERE.parents[3] / "stack-cr-wi3-revoke"` with existence+HEAD assertion), not `.parents[3].parent`.
### 🔴 NEW-5 — launcher precondition is check-then-exec, not pinned-executed-bytes (CONFIRMED)
The precondition hashes `launcher_bytes = GATED_LAUNCHER.read_bytes()` (:280) against `GATED_LAUNCHER_SHA256` (:287),
but the launcher is **executed separately** via `PiRpc([sys.executable, str(GATED_LAUNCHER), …])` (:514-515), which
opens and **re-reads the file at exec time**. There is **no fd-handoff and no exec-from-verified-copy**, so the
verified snapshot does **not** bind the executed bytes. The window between check (:287) and exec (:514-515) spans the
broker `Popen` + `wait_path` (≤20 s) — a genuine **check-then-exec TOCTOU / mutable-path trust**; the `git rev-parse
HEAD` check (:285-286) is likewise on a mutable HEAD, not the executed bytes. Per the bar this is a real gap.
**file:line — hash :280/:287 vs exec :514-515.**
*Fix:* execute the exact verified bytes with no window — e.g. read once, verify, and exec from a fixture-private
copy of the verified bytes (or `python3 /proc/self/fd/<verified-fd>`), so the hashed bytes == executed bytes.
## Verdict
**FAIL @ `7f975b95`.** The hygiene delta itself is correct — (a) creds scrub (fail-closed finally, success path,
every retained root) and (b) `source-invalid` removal both landed cleanly, (c) everything else is byte-stable vs
`48484938`, and (d) the absent sweep is 0. **However**, homelab's NEW-5 (check-then-exec launcher TOCTOU / not
pinned-executed-bytes) and NEW-6 (GATED_WI_ROOT off-by-one/non-portable resolution) are **present in these bytes**;
the addendum requires both **closed** for PASS. Not softened. Returns to author (ms-rev-826) — not to a builder
re-review, no PASS-launder.
**Findings:** NEW-6 (`p3_d4_focused_run.py:32`); NEW-5 (`p3_d4_focused_run.py:280/:287` vs `:514-515`).
## Scope reminder (not a finding)
Producing this evidence **executes** the Gate0 mechanism (§3/§5). This review clears **bytes** only; FIRE remains
separately gated on Mos's explicit post-clear GO — and is moot until this FAIL is remediated.
---
**Reviewer:** independent Opus §3-conformance/SECREV reviewer (Gate-16 author≠reviewer). Byte review only; ran nothing.
**Reviewed object (pin):** `7f975b95ad39096463a7548bd6be0dbb387cb61b` · harness sha256 `c3a09a342a4b367184d44472ec6fc11f8a3aabb7e90d5a72aa6b7044b1d9b91e`.

View File

@@ -1,116 +0,0 @@
# GATE0 Probe-3 (#827) — Mos byte-scope-verify CO-ATTESTATION (v-final)
**Principal:** Mos (orchestrator, merge authority for the mosaic-stack governance lane).
**Committed under a DISTINCT git identity** (`mos-orchestrator@mosaic.local`) — deliberately NOT the
`ms-lead-reviewer@mosaic.local` lane signer — so this record stands as a *distinct-identity*
co-attestation, not a same-signer duplicate. See "Independence" below.
**Verify class:** independent provider-byte read (guarded `git show <full-40>:path | sha256sum` from a
read-only clone of `mosaicstack/stack`). Not a re-build, not a re-run — a byte/scope/hygiene audit of
the exact committed objects on the provider branch.
## Package under attestation
| Artifact | Ref |
|---|---|
| Branch | `feat/827-gate0-probe` |
| Harness commit-40 | `2d54a9dd14cb924701b2ae4ed72dae4df760c4e3` |
| Harness `p3_d4_focused_run.py` sha256 | `15a154df55273f51301763a984485fd63813f6d1f05d2728abb9fb8b9c040b1a` (27366 B) |
| §3-review-v6 commit-40 | `23c0caca9b5d44002e6184cd7f2b6c837e8795b2` |
| Review path | `docs/compaction-refresh/reviews/GATE0-PROBE3-NEW56-S3-REVIEW-v6.md` |
sha256 re-confirmed against the checked-out object at `HEAD:docs/compaction-refresh/probes/p3_d4_focused_run.py`
(git object id `8c68cd07…`) — matches the relayed value byte-for-byte.
## Findings — VERDICT: byte-scope + mechanism + hygiene **PASS**
**Anchors.** Harness sha256 matches (27366 B). review-v6 (`23c0caca`) parent == harness commit
`2d54a9dd`; review touches only the review `.md` (+82 lines, 1 file). Broker
(`p3_generation_broker.py`) delta vs `48484938…` = **exactly** the 16-line `action=="source-invalid"`
handler purge, byte-stable otherwise.
**NEW-6 (GATED_WI_ROOT off-by-one) — CLOSED.** `resolve_gated_wi_root()` selects the worktree by
`git worktree list --porcelain` enumeration, requires a UNIQUE match on `HEAD==GATED_WI_HEAD`
(`f400830738998db105107a2a4c69c7f2a2a6fd5d`) AND `branch==refs/heads/feat/830-compaction-revoke`,
then fail-closed re-validates (`is-inside-work-tree==true`, `rev-parse HEAD==GATED_WI_HEAD`);
`RuntimeError` on ambiguity/mismatch. The `HERE.parents[3].parent / "stack-cr-wi3-revoke"` off-by-one
and the hardcoded `/home/hermes/...` literal are **gone** — portable, zero hardcoded path.
**NEW-5 (TOCTOU / pinned-executed-bytes) — CLOSED via approach (i), as mandated.** The `git`-object
sha256 pin (`GATED_LAUNCHER_SHA256 = e950e422…`) is the trust anchor. Ordering/marker `.find()`
heuristics are downgraded to explicitly diagnostic-only ("never a substitute for the pin"). In
`launch_verified_pi()` the executed working-tree file is re-hashed against the pin **in the statement
immediately before `Popen`** (no interleaved yield/IO), and the launcher is executed **in place at the
pinned worktree path** — the higher-risk approach (ii) copy-to-fixture (previously at `7ff63cd5` /
`6164dc07`) is **reverted** (the only remaining `shutil.copy2` is the legitimate credential copy, not a
launcher copy). Residual sub-statement TOCTOU window on a local file inside a non-adversarial operator
fixture is within this probe's threat model; the gross precondition→much-later-exec gap homelab flagged
is closed.
**Hygiene — CLOSED.** `scrub_fixture_credentials(root)` removes the entire `.pi/agent` subtree in a
`finally` block (nested try/finally, after `pi.close()` + broker shutdown, before `return root`) and
`RuntimeError`s if the scrub fails — credentials are removed from retained evidence; logs retained.
**Invariants byte-stable (all INTACT):** assertion `lease_anchor_registered`; file-backed fidelity
checks (`generation_source=="state-file"`, `state_file_in_fixture_root`, `MUTATOR_UNVERIFIED`,
`STALE_GENERATION`); `-O`-safe (0 bare `assert`); allow-list env (0 `os.environ.copy`); single broker;
`--runs choices=(3,)`; ABSENT-sweep (`yolo`/`execRuntime`/`p3_bank`/`promote_p2`/`retry`) = 0.
**R1 mechanism-fidelity boundary — HELD.** D4 exercises #830's real file-backed revocation
(`generation-{sid}.state` / `MOSAIC_LEASE_GENERATION_FILE`, from `66b1e0a0`); `.state` stays inside the
fixture temp root; fixture-socket / child-write-escape / gettempdir isolation preconditions preserved;
launcher exec is in place at the pinned worktree (same surface as R1, not a new production/live
surface). **No path escapes the fixture root.** BEYOND-R1 tripwire NOT tripped.
## Independence
- **Substantive principal-independence** of review-v6 is satisfied by an orchestrator-dispatched,
builder-distinct Opus SECREV (`ms-secrev-828`, byte-only, non-builder) — that IS the substance of
Gate-16.
- The shared `ms-lead-reviewer` git signer on harness+review commits is **evidentiary, not
substantive**. It is resolved by (1) this Mos co-attestation committed under a **distinct** identity
(`mos-orchestrator`), and (2) a homelab third-principal verify under its own distinct identity —
i.e. three distinct-identity principals of record.
- The shared signer is a tracked **fleet-infra tooling-gap** (durable fix = per-lane distinct signers),
**not a blocker**.
## Scope of this record — byte-clear, NOT fire-authorization
Producing probe evidence **executes** the Gate0 mechanism; a byte-clear is not a fire-authorization.
This co-attestation clears the **bytes/scope/hygiene**. FIRE remains gated on: **homelab
third-principal verify** + **Mos transparency-to-Jason** (real-Pi consumes operator model creds inside
the isolated fixture, scrubbed post-run, never emitted) + **Mos explicit FIRE GO**. Until then: nothing
banked, WI-3 #830 held at `f4008307` (unmoved), C-hatch armed (if 3× isolation still no-fire /
wrong-value / isolation-FAIL → possible Case-C → STOP + escalate to Jason).
**Mos verdict: byte-scope + mechanism + hygiene PASS. Co-attestation of record — committed.**
---
## ⚠️ SUPERSEDED — homelab third-principal FAIL raised a stricter bar (evidence-integrity note)
This co-attestation was **byte-clear on the v6 bar ONLY** and self-limited above to *"byte-clear
ONLY, NOT fire-authorization; FIRE remains gated on homelab third-principal verify."* Homelab (the
required third principal) subsequently returned **FAIL @2d54a9dd**, and Mos **UPHELD** it — so the v6
byte-clear this document records is **SUPERSEDED** and does **NOT** authorize FIRE.
Homelab's substantively-correct deepening (accepted as gate-**strengthening**, not softening):
1. `launch_verified_pi` hashes the launcher then `Popen`/execve **reopens the path** → statement
adjacency shrinks but does not eliminate TOCTOU; hashed-snapshot ≠ executed-bytes.
2. `lease_generation.py` helper is unpinned, loaded from the mutable worktree → HEAD + launcher-pin
do not bind the helper bytes.
3. `p3_generation_broker.py` executes from the mutable worktree unhashed → reviewed broker bytes need
not be the evidence-producing bytes.
For a fail-closed DO-178C evidence gate, **hashed==executed must hold on the FULL executed closure**
(launcher + helper + broker), which v6 (approach (i) adjacency) does not meet. Mos therefore
**authorized approach (ii) full-closure materialization** (SHA-pin + materialize the full closure into
a fixture-private 0700/0600 dir or held verified fds, exec from there, launcher+broker consume the same
pinned helper; re-hash==f4008307 pins immediately before exec, fail-closed). This rides the existing R1
authorization + Mos adjudication authority (it deepens isolation of already-authorized touch and stays
inside the fixture temp root → R1 owner tripwire not tripped; no fresh owner window).
**Live target = v7** (materialized-closure harness, forthcoming). `2d54a9dd` / `23c0caca` / this
co-attestation (`12914d8`) are **SUPERSEDED**. A fresh Mos co-attestation will be committed on v7
byte-verify PASS. WI-3 #830 remains HELD at `f4008307`; nothing banked; C-hatch armed.

View File

@@ -1,97 +0,0 @@
# GATE0 Probe-3 (#827) — Mos byte-scope-verify CO-ATTESTATION (v10 no-site startup closure)
**Principal:** Mos (orchestrator, merge authority for the mosaic-stack governance lane).
**Committed under a DISTINCT git identity** (`mos-orchestrator@mosaic.local`) — deliberately NOT the
`ms-lead-reviewer@mosaic.local` lane signer that authored the harness and the §3 review — so this
record stands as a *distinct-identity* co-attestation. See "Independence".
**Verify class:** independent provider-byte read (`git show <full-40>:path | sha256sum`) plus a
git-diff byte-comparison of the v9→v10 delta and source inspection of the executed command lines. Not
a re-build, not a re-run. This SUPERSEDES the v9 co-attestation `f320d075` (byte-clear on the v9
env-iso + bytecode-pin bar), which was overtaken by homelab's 4th-round FAIL @1c34e3cb
(site-startup-closure hole) and the resulting B9 bar.
## Package under attestation
| Artifact | Ref |
|---|---|
| Branch | `feat/827-gate0-probe` (mosaicstack/stack) |
| Harness commit-40 | `ce5ba762051354338889959bfce2b0381f4a4e2a` |
| Harness `p3_d4_focused_run.py` sha256 | `7e14ead89a7b2a297fcc17e7653291b3bcace1d2002a8f90a989db74f6985b6f` (32753 B) |
| §3-review-v10 commit-40 | `ffc3b573a8258a86e3ece9ef92bac925fb5a7a5b` (parent == harness `ce5ba762`, byte-stable; adds only the review `.md`, +86; ms-secrev-828 authored) |
| Review path | `docs/compaction-refresh/reviews/GATE0-PROBE3-V10-NOSITE-S3-REVIEW-v10.md` |
**Pinned closure (all 3 sha256 INDEPENDENTLY recomputed by Mos from git objects == pins == embedded harness constants; UNCHANGED from v9):**
| Member | Source (commit:path) | Pin sha256 | Size |
|---|---|---|---|
| launcher `launch-runtime.py` | `f4008307:packages/mosaic/framework/tools/lease-broker/launch-runtime.py` | `e950e422…` | 4237 B |
| helper `lease_generation.py` | `f4008307:packages/mosaic/framework/tools/lease-broker/lease_generation.py` | `061625402f…` | 3769 B |
| broker `p3_generation_broker.py` | `23c0caca:docs/compaction-refresh/probes/p3_generation_broker.py` | `4db4fef1…` | — |
> `f4008307` (held WI-3 #830 gated head) is **not on origin** — recomputed launcher+helper from the
> local `stack-cr-wi3-revoke` worktree (HEAD == `f4008307`); broker from origin `23c0caca`.
## Findings — VERDICT: byte-scope + mechanism + hygiene **PASS** (v10 = v9 + B9 no-site)
The v9→v10 delta is **exactly 2 insertions / 0 deletions** (git-diff byte-compared), a `+28 B`
harness growth **fully accounted** (2 × ` "-S",\n` = 14 B each). Every prior invariant is
byte-stable — the diff touches nothing outside the two command lists.
**B9 — no-site startup closure (homelab 4th-round finding CLOSED).** Both child command lists now
carry `-S`, which disables the `site` module entirely (no `.pth` executable lines, no
`sitecustomize`/`usercustomize`) so no unpinned startup code runs before the exact launcher/broker or
outside `closure_import_guard`:
- launcher `launch_verified_pi` (`:515`): `-s` + **`-S`** + `-B`, **no `-I`** (`-I` appears exactly
once in the harness, `:553`, the broker only).
- broker `launch_verified_broker` (`:554`): `-I` + **`-S`** + `-B`.
Homelab's finding was **independently reproduced by Mos** and the fix **empirically verified** on host
Py3.11.2 (throwaway, not the harness): `[-s -B]``no_site=0, site imported` (the v9 hole); `[-I -B]`
`no_site=0, site imported` (proves `-I` does **not** imply `-S`); `[-s -S -B]``no_site=1`, `site` not
in `sys.modules`, **and the launcher's bare `from lease_generation import` still resolves** (`sys.path[0]`
untouched by `-S` → no B6c regression); `[-I -S -B]``no_site=1`. The launcher deliberately omits `-I`
(B6c: on 3.11+ `-I` implies `-P`, dropping the script dir from `sys.path[0]` → sibling import breaks);
its env isolation comes from the `PiRpc` `env=` allow-list, not `-I`.
**All priors — byte-stable (outside the 2-line delta, re-confirmed from the v9 verify):**
B5 conjunction (materialize-from-pin / `mkdir(0o700)`+`O_EXCL` no-writer-window / re-hash==pin
immediately-before-exec); B6 (single pinned helper bound; `closure_import_guard` AST-reject); B6c
(launcher no `-I`); B7 (broker `env=environment` strict allow-list `:570`); B8 (`reject_pinned_bytecode`
fail-closed `:498-501` before each consumer `:535/:562` + `PYTHONDONTWRITEBYTECODE=1` `:729` + `-B` on
both); fidelity asserts (`generation_source=='state-file'`, `state_file_in_fixture_root`,
`MUTATOR_UNVERIFIED`, `STALE_GENERATION`); `lease_anchor_registered`; BAR1 `GATED_WI_HEAD==f4008307`
(`:36`) + `merge-base --is-ancestor 66b1e0a0 f4008307` = **YES**; `--runs choices=(3,)`; `-O`-safe
(0 bare `assert`); allow-list env (0 `os.environ.copy`); single broker (1 def + 1 call); the only
`shutil.copy2` is the `.pi/agent` credential copy.
**Closure = exactly 3 files, materialized inside the fixture root.** No path escapes the fixture temp
root; no live/default broker; `.state` fixture-bound. **R1 owner tripwire NOT tripped**`-S`
deepens startup-closure isolation of an already-authorized touch; it does not widen the touched surface.
## Independence
Substantive principal-independence of review-v10 is satisfied by an orchestrator-dispatched,
builder-distinct Opus SECREV (`ms-secrev-828`, byte-only, non-builder, non-Mos). The shared
`ms-lead-reviewer` git signer on harness+review commits is evidentiary, not substantive — resolved by
(1) this Mos co-attestation under a **distinct** identity (`mos-orchestrator`) and (2) a homelab
third-principal verify under its own distinct identity = three distinct-identity principals of record.
The prior 2-of-3 (`ms-secrev-828` v9 §3 PASS + `f320d075`) does **not** carry — all three re-verify
this v10 SHA. Shared signer = tracked fleet-infra tooling-gap, not a blocker.
## Scope of this record — byte-clear, NOT fire-authorization
Producing probe evidence **executes** the Gate0 mechanism; a byte-clear is not a fire-authorization.
This clears **bytes / scope / mechanism / hygiene on the v10 (v9 + B9 no-site) bar**. FIRE remains
gated on: **homelab third-principal re-verify** (5th round, own distinct identity) + **Mos
transparency-to-Jason** + **Mos explicit FIRE GO**. The FIRE GO additionally carries an
**execution-procedure constraint**: the 3× isolation dispatch must launch the runner under
externally-enforced **`python -I -S -B p3_d4_focused_run.py`** — a self-reexec is too late, the
harness's own `site` runs before it could re-add `-S` to itself. Until FIRE GO: nothing banked, WI-3
#830 held at `f4008307` (unmoved), C-hatch armed (fired-rig only: no-fire / wrong-value /
assertion-FAIL / isolation-FAIL → possible Case-C → STOP + escalate to Jason).
Prior v10-superseded set: `1c34e3cb` / `e1c9a468` / `f320d075` (and transitively the v7 chain).
**Mos verdict: v10 (v9 + B9 no-site) byte-scope + mechanism + hygiene PASS. Co-attestation of
record — committed.**

View File

@@ -1,131 +0,0 @@
# GATE0 Probe-3 (#827) — Mos byte-scope-verify CO-ATTESTATION (v7 full-closure)
**Principal:** Mos (orchestrator, merge authority for the mosaic-stack governance lane).
**Committed under a DISTINCT git identity** (`mos-orchestrator@mosaic.local`) — deliberately NOT the
`ms-lead-reviewer@mosaic.local` lane signer that authored both the harness and the §3 review — so this
record stands as a *distinct-identity* co-attestation. See "Independence" below.
**Verify class:** independent provider-byte read (guarded `git show <full-40>:path | sha256sum`) plus
source-level inspection of the executed mechanism. Not a re-build, not a re-run. This SUPERSEDES the v6
co-attestation `12914d8` (and its SUPERSEDED-note `b6bd0cd`), which was byte-clear on the v6 bar only
and was overtaken by homelab's third-principal FAIL @2d54a9dd + the resulting stricter full-closure bar.
## Package under attestation
| Artifact | Ref |
|---|---|
| Branch | `feat/827-gate0-probe` |
| Harness commit-40 | `f609a44953f5ae61916805fcb45ca337de00b0b0` |
| Harness `p3_d4_focused_run.py` sha256 | `0f1bd1b39399b32f243d901230e2d840794a2144edd723a095dab716833a7a9b` (32071 B) |
| §3-review-v7 commit-40 | `2bba933f67c821899d320a938a9473a73a136422` (adds only the review `.md`; harness parent byte-stable) |
| Review path | `docs/compaction-refresh/reviews/GATE0-PROBE3-V7-FULLCLOSURE-S3-REVIEW-v7.md` |
**Pinned closure (all 3 sha256 INDEPENDENTLY recomputed by Mos from git objects == pins):**
| Member | Source (commit:path) | Pin sha256 | Size |
|---|---|---|---|
| launcher `launch-runtime.py` | `f4008307:packages/mosaic/framework/tools/lease-broker/launch-runtime.py` | `e950e422…` | 4237 B |
| helper `lease_generation.py` | `f4008307:packages/mosaic/framework/tools/lease-broker/lease_generation.py` | `061625402f…` | 3769 B |
| broker `p3_generation_broker.py` | `23c0caca:docs/compaction-refresh/probes/p3_generation_broker.py` | `4db4fef1…` | — |
> Note: `f4008307` (the held WI-3 #830 gated head) is **not on origin** — it exists only as a local
> `git worktree` on the build host. Mos recomputed the launcher+helper pins from that worktree
> (`stack-cr-wi3-revoke`, HEAD == `f4008307`) rather than passing over a clone-completeness gap. The
> broker pin was recomputed from origin `23c0caca`.
## Findings — VERDICT: byte-scope + mechanism + hygiene **PASS** (v7 full-closure bar)
Homelab's stricter bar — **hashed==executed on the FULL executed closure (launcher + helper + broker)**
— is met. Verified at the source, not accepted on the review's assertion:
**B5 conjunction (the load-bearing repair) — HELD, all three legs:**
- **(a) materialized from pinned git-object bytes, NOT the mutable worktree.** `materialize_closure`
fetches each member via `git_object_bytes` (`git show {commit}:{path}`), then
`sha256(data) == pin` **fail-closed** (`RuntimeError` on mismatch) before use.
- **(b) no writable window hash→consume.** `pinned/` is `mkdir(mode=0o700)`; each file is written with
`os.open(O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC, 0o600)``O_EXCL` refuses a pre-planted file. No `chmod`,
no `os.rename/replace`, no `symlink`, and nothing re-opens a pinned file for write (grep = 0). The
pinned bytes are immutable within the fixture threat model between hash and exec.
- **(c) re-hash == pin IMMEDIATELY before each exec, no interleaved yield.** Launcher: re-hash then
`return PiRpc(command,…)` whose `__init__` **first statement** is `subprocess.Popen(command,…)`
zero IO/yield/reopen between. Broker + helper: both re-hashed then `subprocess.Popen` on the next
line. Adjacency-alone-without-materialization (the v6 defect) is **absent** — all three exec from
`pinned/`.
**B6 — helper pinned AND bound to the SAME single copy.** The broker receives
`--generation-module {closure.generation}` (the pinned helper); the launcher runs from `pinned/` so its
`import lease_generation` resolves to the sibling pinned copy via `sys.path[0]`. `closure_import_guard`
AST-parses every member and raises on any non-stdlib import other than the allowed `lease_generation`
— proving the dependency closure is complete and no unpinned module can enter at runtime.
**Closure = exactly 3 files, materialized inside the fixture root** (`root / "pinned"`). No path escapes
the fixture temp root; no live/default broker; `.state` remains fixture-bound. **R1 owner tripwire NOT
tripped** — this deepened isolation of an already-authorized touch, it did not widen the touched surface.
**Invariants (all INTACT):** BAR1 `GATED_WI_HEAD == f4008307` and `merge-base --is-ancestor 66b1e0a0
f4008307` = YES (file-backed `.state` revocation fidelity present); BAR2 `.state` =
`socket_path.parent / generation-{sid}.state`, `state_file_in_fixture_root` + `generation_source ==
"state-file"` checks present; BAR3 `lease_anchor_registered` / live-path / fixture-socket isolation
intact. `-O`-safe (0 bare `assert`); ABSENT-sweep (`yolo`/`execRuntime`/`p3_bank`/`promote_p2`/`retry`)
= 0; single broker (1 def + 1 call site); `--runs choices=(3,)`; allow-list env (0 `os.environ.copy`);
the only `shutil.copy2` is the legitimate `.pi/agent` credential copy (settings/auth/fd), **not** a
launcher/helper/broker copy — the v6 copy-to-fixture concern is gone.
## Independence
- **Substantive principal-independence** of review-v7 is satisfied by an orchestrator-dispatched,
builder-distinct Opus SECREV (`ms-secrev-828`, byte-only, non-builder) — that IS the substance of
Gate-16.
- The shared `ms-lead-reviewer` git signer on both the harness (`ms-rev-826` build) and the review
commit is **evidentiary, not substantive**. It is resolved by (1) this Mos co-attestation under a
**distinct** identity (`mos-orchestrator`), and (2) a homelab third-principal verify under its own
distinct identity — three distinct-identity principals of record. Tracked fleet-infra tooling-gap
(durable fix = per-lane distinct signers), **not a blocker**.
## Scope of this record — byte-clear, NOT fire-authorization
Producing probe evidence **executes** the Gate0 mechanism; a byte-clear is not a fire-authorization.
This co-attestation clears the **bytes / scope / mechanism / hygiene on the v7 full-closure bar**. FIRE
remains gated on: **homelab third-principal re-verify** (under its own distinct identity) + **Mos
transparency-to-Jason** + **Mos explicit FIRE GO**. Until then: nothing banked, WI-3 #830 held at
`f4008307` (unmoved), C-hatch armed (if the materialized-closure rig still no-fire / wrong-value /
assertion-FAIL / isolation-FAIL → possible Case-C → STOP + escalate to Jason).
**Mos verdict: v7 full-closure byte-scope + mechanism + hygiene PASS. Co-attestation of record —
committed.**
---
## ⚠️ SUPERSEDED — homelab v7 third-principal FAIL @f609a449 raised a stricter bar (evidence-integrity note)
This co-attestation was **byte-clear on the v7 full-closure bar ONLY** and self-limited above to
*"byte-clear NOT fire-authorization; FIRE remains gated on homelab third-principal re-verify."*
Homelab (the required third principal) subsequently returned **FAIL @f609a449** (static verify, no
code run), and Mos **UPHELD** it after independently confirming both findings in source — so the v7
byte-clear this document records is **SUPERSEDED** and does **NOT** authorize FIRE.
Two residual isolation/binding holes WITHIN the materialized closure (both independently reproduced
by Mos in the harness source; accepted as gate-**strengthening**, not softening):
1. **Broker child env not isolated.** `launch_verified_broker` (`:551`) calls `Popen` with **no
`env=`** (only `PiRpc.__init__` `:50` passes an allow-listed `env`) → the broker child inherits
ambient `os.environ` (PYTHONPATH/PYTHONHOME/PYTHONPYCACHEPREFIX). `closure_import_guard` is a
static AST check and cannot bind the child's runtime stdlib resolution.
2. **Executed bytecode-cache outside the pin.** No `PYTHONDONTWRITEBYTECODE`/`-I`/`-B`/`__pycache__`
handling anywhere. `exec_module` on the pinned helper writes derived `.pyc` the pin never covers;
only the `.py` is re-hashed → executed bytecode ≠ pinned-source-hash.
Both break "hashed==executed on the FULL executed closure" on **fidelity** grounds even in a
non-adversarial fixture. Mos **authorized the bounded repair** (broker `Popen` with strict
allow-listed `env=` + `-I` + `PYTHON*` stripped; bytecode-cache suppressed via
`PYTHONDONTWRITEBYTECODE=1`/`-B` + reject stray `__pycache__`/`.pyc` fail-closed before each
consumer; launcher sibling-import binding to the pinned helper preserved). This **rides the existing
(ii)-full-closure authorization + R1 + Mos adjudication** — it deepens isolation/binding of an
already-authorized touch, stays inside the fixture temp root, no fresh Jason owner-window. It is
**NOT a Case-C escalation** (no probe fired, no evidence produced — a static pre-fire catch, exactly
what the review gate is for). Added review bars **B7** (broker child env-isolated) and **B8**
(executed bytecode pinned-or-suppressed) on top of B5+B6+all priors.
**Live target = v8** (env-isolated + bytecode-pinned harness, forthcoming). `f609a449` /
`2bba933f` / this co-attestation (`e08ad03`) are **SUPERSEDED**. A fresh Mos co-attestation will be
committed on v8 byte-verify PASS. WI-3 #830 remains HELD at `f4008307`; nothing banked; C-hatch
armed; NO FIRE.

View File

@@ -1,145 +0,0 @@
# GATE0 Probe-3 (#827) — Mos byte-scope-verify CO-ATTESTATION (v9 full-closure, env-isolated + bytecode-pinned)
**Principal:** Mos (orchestrator, merge authority for the mosaic-stack governance lane).
**Committed under a DISTINCT git identity** (`mos-orchestrator@mosaic.local`) — deliberately NOT the
`ms-lead-reviewer@mosaic.local` lane signer that authored the harness and the §3 review — so this
record stands as a *distinct-identity* co-attestation. See "Independence".
**Verify class:** independent provider-byte read (`git show <full-40>:path | sha256sum`) plus
source-level inspection of the executed mechanism and the v7→v9 delta. Not a re-build, not a re-run.
This SUPERSEDES the v7 co-attestation `e08ad03` (and its SUPERSEDED note `2ae379e`), which was
byte-clear on the v7 bar and was overtaken by homelab's third-principal FAIL @f609a449 (broker
env-isolation + executed-bytecode-cache) and the resulting B7/B8 bar.
## Package under attestation
| Artifact | Ref |
|---|---|
| Branch | `feat/827-gate0-probe` |
| Harness commit-40 | `1c34e3cb3172acdcd094e683e847d7c984afc96c` |
| Harness `p3_d4_focused_run.py` sha256 | `29e5c7bfbe1911b52984bd94c79036bb1200ee82588318367b13c2b1053a0103` (32725 B) |
| §3-review-v9 commit-40 | `e1c9a4682da2892ca5f5381012caffe1dd7b43a7` (parent == harness `1c34e3cb`, byte-stable; adds only the review `.md`; ms-secrev-828 authored) |
| Review path | `docs/compaction-refresh/reviews/GATE0-PROBE3-V9-LAUNCHERFIX-S3-REVIEW-v9.md` |
**Pinned closure (all 3 sha256 INDEPENDENTLY recomputed by Mos from git objects == pins == embedded harness constants):**
| Member | Source (commit:path) | Pin sha256 | Size |
|---|---|---|---|
| launcher `launch-runtime.py` | `f4008307:packages/mosaic/framework/tools/lease-broker/launch-runtime.py` | `e950e422…` | 4237 B |
| helper `lease_generation.py` | `f4008307:packages/mosaic/framework/tools/lease-broker/lease_generation.py` | `061625402f…` | 3769 B |
| broker `p3_generation_broker.py` | `23c0caca:docs/compaction-refresh/probes/p3_generation_broker.py` | `4db4fef1…` | — |
> `f4008307` (held WI-3 #830 gated head) is **not on origin** — recomputed launcher+helper from the
> local `stack-cr-wi3-revoke` worktree (HEAD == `f4008307`); broker from origin `23c0caca`.
## Findings — VERDICT: byte-scope + mechanism + hygiene **PASS** (v9 = v7 full-closure + B7 + B8)
The v7→v9 delta is **exactly 22 insertions / 2 deletions**, confined to the intended B7+B8+B6c
surface; every prior invariant is byte-stable (outside the delta) from the v7 verify.
**B7 — broker child env-ISOLATED (homelab finding 1 CLOSED).** `launch_verified_broker` (`:568`) now
passes `env=environment` (the strict allow-list, `:570`) — the ambient-`os.environ`-inheritance hole
is gone — AND runs the broker with `-I` (`:552`, isolated: ignores `PYTHON*`/user-site) + `-B`
(`:553`). Both children are env-controlled: the launcher was already `env=env` at `PiRpc` (`:53`).
**B8 — executed bytecode PINNED/SUPPRESSED (homelab finding 2 CLOSED).** `reject_pinned_bytecode`
(`:498`) raises `RuntimeError` fail-closed if a `__pycache__` dir or any `*.pyc` exists in the pinned
dir, and is called before **each** consumer (launcher `:535`, broker `:562`). Bytecode writes are
disabled via `PYTHONDONTWRITEBYTECODE=1` (`:729`) in the allow-list env **and** `-B` on both command
lines. No unpinned `.pyc` can be executed; only the pinned `.py` re-hash governs.
**B6c — launcher sibling-import PRESERVED (v8 regression FIXED).** v8 over-applied `-I` to the
launcher; on Py3.11+ `-I` implies `-P`, dropping the script dir from `sys.path[0]`, so the pinned
launcher's bare `from lease_generation import` (launch-runtime.py:15) would `ModuleNotFoundError`. v9
uses `-s` (`:514`) + `-B` (`:515`) on the launcher (NO `-I`) — neither touches `sys.path[0]`, so the
sibling import still resolves to `pinned/lease_generation.py`. **Mos empirically re-verified on host
Py3.11.2** (throwaway, not the harness): `-I` launcher → `ModuleNotFoundError`; `-s`+`PYTHONNOUSERSITE`
→ import OK. The launcher's env isolation comes from the `PiRpc` `env=` allow-list, NOT `-I`, so
dropping `-I` does **not** reopen B7. My earlier constraint-(c) assumption ("`-I` does not strip the
script dir") was FALSIFIED for 3.11+; the author≠reviewer gate (ms-secrev-828) caught it — recorded.
**B5 conjunction (load-bearing repair) — HELD, all three legs (byte-stable from v7):**
(a) materialized from pinned git-object bytes via `materialize_closure`/`git_object_bytes`, `sha256==pin`
fail-closed; (b) `pinned/` `mkdir(0o700)` + `O_EXCL|O_CLOEXEC` `0o600`, no writable window — now also
`reject_pinned_bytecode` closes the `.pyc` side-channel; (c) re-hash == pin IMMEDIATELY before each
exec, no interleaved yield: launcher re-hash (`:538`) → `return PiRpc(command,…)` whose `__init__`
first statement is `Popen` (`:50`); broker re-hash (`:563`) + helper re-hash (`:566`) → `Popen`
(`:568`) on the next line.
**B6 — single pinned helper, complete closure.** Broker gets `--generation-module {closure.generation}`;
launcher resolves `import lease_generation` to the sibling pinned copy via `sys.path[0]`.
`closure_import_guard` (`:351`, called `:433`) AST-rejects any non-stdlib import other than
`lease_generation`. Single broker: `launch_verified_broker` 1 def (`:543`) + 1 call (`:766`).
**Invariants (all INTACT):** BAR1 `GATED_WI_HEAD == f4008307` (`:36`) and `merge-base --is-ancestor
66b1e0a0 f4008307` = YES (file-backed `.state` revocation fidelity present); fidelity
`generation_source=='state-file'` (`:639`), `state_file_in_fixture_root` (`:641`),
`MUTATOR_UNVERIFIED` (`:650`), `STALE_GENERATION` (`:651`); `lease_anchor_registered` (`:593`);
`-O`-safe (0 bare `assert`); `--runs choices=(3,)` (`:838`); allow-list env (0 `os.environ.copy`);
ABSENT-sweep (`yolo`/`execRuntime`/`p3_bank`/`promote_p2`/`retry`) = 0; the only `shutil.copy2`
(`:708`) is the `.pi/agent` credential copy, not a closure copy.
**Closure = exactly 3 files, materialized inside the fixture root.** No path escapes the fixture temp
root; no live/default broker; `.state` fixture-bound. **R1 owner tripwire NOT tripped** — B7/B8
deepen isolation/binding of an already-authorized touch, they do not widen the touched surface.
## Independence
Substantive principal-independence of review-v9 is satisfied by an orchestrator-dispatched,
builder-distinct Opus SECREV (`ms-secrev-828`, byte-only, non-builder, non-Mos). The shared
`ms-lead-reviewer` git signer on harness+review commits is evidentiary, not substantive — resolved by
(1) this Mos co-attestation under a **distinct** identity (`mos-orchestrator`) and (2) a homelab
third-principal verify under its own distinct identity = three distinct-identity principals of record.
Shared signer = tracked fleet-infra tooling-gap (durable fix = per-lane distinct signers), not a blocker.
## Scope of this record — byte-clear, NOT fire-authorization
Producing probe evidence **executes** the Gate0 mechanism; a byte-clear is not a fire-authorization.
This clears **bytes / scope / mechanism / hygiene on the v9 (full-closure + B7 + B8) bar**. FIRE
remains gated on: **homelab third-principal re-verify** (4th round, own distinct identity) + **Mos
transparency-to-Jason** + **Mos explicit FIRE GO**. Until then: nothing banked, WI-3 #830 held at
`f4008307` (unmoved), C-hatch armed (materialized-closure rig still no-fire / wrong-value /
assertion-FAIL / isolation-FAIL → possible Case-C → STOP + escalate to Jason).
**Mos verdict: v9 full-closure + B7 + B8 byte-scope + mechanism + hygiene PASS. Co-attestation of
record — committed.**
---
## ⚠️ SUPERSEDED — homelab v9 4th-round FAIL @1c34e3cb raised a stricter *startup-closure* bar
This co-attestation was **byte-clear on the v9 (env-iso + bytecode-pin) bar ONLY** and self-limited
above to *"byte-clear NOT fire-authorization; FIRE remains gated on homelab 4th-round re-verify."*
Homelab (the required third principal) returned **FAIL @1c34e3cb** (static, nothing executed), and Mos
**UPHELD** it after independently confirming the finding in-source AND empirically on host Py3.11.2 —
so the v9 byte-clear this document records is **SUPERSEDED** and does **NOT** authorize FIRE.
**Residual startup-closure hole (empirically reproduced by Mos; accepted as gate-STRENGTHENING):**
neither child carries `-S`, so CPython imports the `site` module **before** the script runs. `-s`
(launcher) suppresses only *user*-site; `-I` (broker) implies `-s -E -P` but **NOT** `-S`. Proven:
[-s -B ] no_site=0 site_imported=True ← v9 launcher: site runs
[-I -B ] no_site=0 site_imported=True ← v9 broker: -I does NOT imply -S
[-s -S -B] no_site=1 site_imported=False ← v10 launcher fix (sibling import STILL resolves)
[-I -S -B] no_site=1 site_imported=False ← v10 broker fix (additive)
System-site executable `.pth` lines + sitecustomize/usercustomize can therefore run **unpinned startup
code** before the exact launcher/broker and **outside** `closure_import_guard`, while every hash +
`reject_pinned_bytecode` + import-guard still pass — defeating hashed==executed on the full *startup*
closure (strictly wider than the module-import closure v9 cleared). A genuine fidelity hole for a
fail-closed DO-178C evidence gate.
Mos **authorized the bounded v10 repair**: add `-S` to the **launcher** (keep `-s -B`, NOT `-I`) and
to the **broker** (keep `-I -B`) — a minimal 2-line delta; no B6c regression (launcher `-s -S -B`
sibling import empirically intact; `-S` does not touch `sys.path[0]`). Added review bar **B9**
(no-site startup closure). This **rides the existing (ii)-full-closure authorization + R1 + Mos
adjudication** (deepens startup-closure isolation of an already-authorized touch, inside the fixture
temp root, no fresh Jason owner-window) and is **NOT a Case-C escalation** (static pre-fire catch, no
probe fired). A separate **FIRE-time** constraint is captured: the 3× isolation dispatch must launch
the runner under externally-enforced `python -I -S -B` (a self-reexec is too late).
**Live target = v10** (no-site harness, forthcoming). `1c34e3cb` / `e1c9a468` / this co-attestation
(`f320d075`) are **SUPERSEDED**; the prior 2-of-3 (ms-secrev-828 v9 §3 PASS + `f320d075`) does NOT
carry — all three distinct-identity principals re-verify the new v10 SHA. A fresh Mos co-attestation
will be committed on v10 byte-verify PASS. WI-3 #830 remains HELD at `f4008307`; nothing banked;
C-hatch armed (fired-rig only); NO FIRE.

View File

@@ -1,82 +0,0 @@
# Gate0 Probe-3 (D4) NEW-5/NEW-6 Closure — §3-Conformance Review v6
**Verdict: ✅ PASS**
## Pin (G1 — reviewed object, mandatory)
- **Reviewed object = `2d54a9dd14cb924701b2ae4ed72dae4df760c4e3`** (harness commit, branch `feat/827-gate0-probe`, approach-**(i)** build; supersedes the reverted (ii) copy-to-fixture builds `7ff63cd5`/`6164dc07`, which were NOT reviewed to a verdict).
- **Reviewed file:** `docs/compaction-refresh/probes/p3_d4_focused_run.py` — sha256 (pushed provider bytes, `-o FILE`, FULL-40 ref, verified before trust): `15a154df55273f51301763a984485fd63813f6d1f05d2728abb9fb8b9c040b1a` (27366 bytes, no not-found sentinel).
- **Co-reviewed fixture broker:** `p3_generation_broker.py` sha256 `4db4fef1ac6658a8ca79ad5091cefc901d2aa26003265c3d6726c294cf895cad`**byte-identical to v5** (unchanged by this delta).
## Principal-independence attestation (Mos independence ruling — process of record)
This review is produced by a **distinct Opus SECREV session, orchestrator-dispatched** — the **`ms-secrev-828`
reviewer lane, dispatched by `mosaic-100`** — **byte review only, ran nothing**, and **did NOT build** this harness
(builder = ms-rev-826). Author ≠ reviewer (Gate-16). This is one of three principals: **Mos commits his own
distinct-identity byte-scope-verify co-attestation at v-final**, and **homelab's independent verify is the third
principal**. (The `ms-lead-reviewer` **Git signer identity** shared across published review commits is a git-signer
question routed to Mos; it does not bear on this lane's process/dispatch independence, attested here.)
## NEW-5 CLOSED — approach (i): exact-byte pin, adjacent re-hash, exec in place
- **Exact-byte sha256 is the trust anchor, NOT substring heuristics.** Launcher bytes are read from the immutable
git object (`git show f4008307:<path>`) and gated on `sha256(launcher_bytes) == GATED_LAUNCHER_SHA256`
(`e950e422…`) (:354). The `behavior_markers` `in`-checks (:364-372) are explicitly commented "the exact launcher
digest above is the trust anchor. These marker checks are diagnostic belt-and-suspenders only, never a substitute
for the pin" (:362-363). The old ordered `.find()` heuristic (`register < initialize < execute`, min<0) is **gone**.
- **Final re-hash immediately adjacent to `Popen`, no interleaved yield.** `launch_verified_pi` assembles `command`,
then — as the statement **immediately before** `return PiRpc(command, …)` (which performs the `Popen`) — re-hashes
the launcher: `if hashlib.sha256(launcher.read_bytes()).hexdigest() != GATED_LAUNCHER_SHA256: raise` (:411-412),
`return PiRpc(...)` (:413). **No harness-controlled step (no `wait_path`, no broker spawn) sits between the re-hash
and the exec** — the broker `Popen` + `wait_path` occur *before* `launch_verified_pi` is called (:611-621). Window
narrowed to the fork/exec itself.
- **Exec stays IN PLACE at the pinned f4008307-worktree path.** The precondition returns the worktree paths
`gated_root / launcher_relative`, `gated_root / generation_relative` (:378); Pi execs `str(launcher)` = that
worktree launch-runtime.py (:390,:621), and the broker `--generation-module` = the worktree lease_generation.py
(:614). The reverted (ii) machinery is **gone**: `grep pinned-lease-broker / PYTHONPATH / fixture_launcher /
fixture_generation / write_bytes == 0`. Launcher import resolution and the file-backed fidelity surface are
therefore **unperturbed** (this is the lower-risk approach Mos mandated over copy-to-fixture).
## NEW-6 CLOSED — portable, validated, off-by-one gone
`GATED_WI_ROOT` is no longer the off-by-one `HERE.parents[3].parent / "stack-cr-wi3-revoke"`. It is resolved by
`resolve_gated_wi_root()` (:257-307): an explicit `GATED_WI_ROOT` env override, else **repo-relative** `git worktree
list --porcelain` (from `repository_root()`, first parent containing `.git`) selecting the **unique** worktree whose
`HEAD == f4008307` **and** `branch == refs/heads/feat/830-compaction-revoke` (raise if ambiguous/absent). It then
**fail-closes** unless `gated_root.is_dir()`, `git rev-parse --is-inside-work-tree == "true"` (:304-305), and
`HEAD == GATED_WI_HEAD` (:306-307). Independently recomputed on this host (git query, harness not run): it resolves
to the real worktree **`/home/hermes/agent-work/stack-cr-wi3-revoke`**. Portable + validated; the off-by-one is gone.
## Full v4/v5 carry-over re-sweep (byte-stable vs `7f975b95` except the NEW-5/6 delta)
`diff 7f975b95 → 2d54a9dd` confines changes to launcher resolution (NEW-6) + adjacent-rehash-exec-in-place (NEW-5);
nothing else moved. Re-swept intact: **creds-scrub** (`scrub_fixture_credentials` + outermost `finally`); **`source-invalid`
ABSENT** (grep=0 both files); **fidelity file-backed** unperturbed (broker `read_runtime_generation`/`bump_runtime_generation`
on the fixture `.state`; `assert_d4` `generation_source=="state-file"` + `state_file_drives_lifecycle` +
`state_file_in_fixture_root` + `new→MUTATOR_UNVERIFIED`/`prior→STALE_GENERATION`); **`lease_anchor_registered`** INTACT
(event + `hex-256`); **live-path** gated launcher; **fail-closed precondition**; **fixture-socket isolation**; **`-O`-safe**
(0 bare `assert`, PASS derived); **allow-list env** (0 `os.environ.copy`); **`--runs choices=(3,)`**; **single p3 broker**
(broker byte-identical to v5). **ABSENT sweep = 0** (P5/P6/P2-bank/retry-launder/mosaic-yolo/execRuntime/pi_gate0/run_open/
atomic; extension actions only bump/lifecycle/authorize/promote; no exec-at-import). **Beyond-R1 tripwire: not tripped**
exec is in place, imports and the file-backed observation surface untouched; no isolation crossing.
## Verdict
**PASS @ `2d54a9dd`.** NEW-5 (approach (i): exact-byte sha256 pin as trust anchor; adjacent re-hash immediately
before `Popen` with no interleaved yield; exec in place at the pinned f4008307-worktree path; (ii) copy-to-fixture/
PYTHONPATH machinery reverted) and NEW-6 (portable, validated, off-by-one-gone root resolution) are both **closed**;
the full v4/v5 carry-over holds byte-stable except the two intended surfaces; ABSENT sweep is 0; the R1 file-backed
fidelity surface is unperturbed. Zero out-of-scope surface.
**Findings: none.**
## Scope reminder (not a finding)
Producing this evidence **executes** the Gate0 mechanism (§3/§5). This review clears **bytes** only; **FIRE remains
separately gated on Mos's explicit post-clear GO** and is not authorized by this review.
---
**Reviewer:** distinct Opus SECREV session (`ms-secrev-828` lane, dispatched by `mosaic-100`), Gate-16 author≠reviewer,
byte review only; ran nothing; did not build.
**Reviewed object (pin):** `2d54a9dd14cb924701b2ae4ed72dae4df760c4e3` · harness sha256 `15a154df55273f51301763a984485fd63813f6d1f05d2728abb9fb8b9c040b1a`.

View File

@@ -1,86 +0,0 @@
# Gate0 Probe-3 (D4) No-Site Startup Closure — §3-Conformance Review v10
**Verdict: ✅ PASS**
## Pin (GUARD 1 — reviewed object)
- **Reviewed object = `ce5ba762051354338889959bfce2b0381f4a4e2a`** (harness commit, branch `feat/827-gate0-probe`).
- **Reviewed file:** `docs/compaction-refresh/probes/p3_d4_focused_run.py` — sha256 (pushed provider bytes, `-o FILE`, FULL-40 ref, verified before trust): `7e14ead89a7b2a297fcc17e7653291b3bcace1d2002a8f90a989db74f6985b6f` (32753 bytes, no not-found sentinel).
- **Closure pins (unchanged):** launcher `e950e422…` @f4008307 · helper `061625402f08488eac47acd23272904e71fd1a71fd15b3bdab158632c801be4c` @f4008307 · broker `4db4fef1…` @23c0caca.
## Independence (GUARD 2 — principal-independence attestation)
Distinct Opus SECREV session, orchestrator-dispatched — the **`ms-secrev-828` reviewer lane, dispatched by
`mosaic-100`** — **byte review only, ran nothing** (harness/broker not executed); did **not** build this harness
(builder = ms-rev-826); is not Mos; distinct principal from both. This re-verifies from scratch on the v10 SHA after
homelab's 4th-round FAIL @`1c34e3cb` (no `-S``site` startup-closure hole) superseded my v9 PASS + Mos's co-attest.
The `-S`/`-s`/`-I` behavior checks below use a *throwaway* script to observe interpreter startup — not the harness.
## ★ B9 — No-site startup closure (the homelab 4th-round FAIL)
The delta vs `1c34e3cb` is **exactly two `-S` insertions**, byte-confirmed by `diff` (nothing else; +28 B fully
accounted by the two ` "-S",\n` lines):
- **(i) Launcher command** (`launch_verified_pi`, :512-516): `sys.executable, "-s", "-S", "-B", str(launcher), …`
carries `-s` + **`-S`** + `-B`, and **no `-I`**.
- **(ii) Broker command** (`launch_verified_broker`, :551-554): `sys.executable, "-I", "-S", "-B", str(broker_path), …`
— carries `-I` + **`-S`** + `-B`.
- **(iii) `site` not imported at child startup** — empirically confirmed (Python 3.11.2, throwaway script):
`python3 -s -S -B main.py``sys.flags.no_site == 1`, `'site' in sys.modules == False`; `python3 -I -S -B main.py`
`no_site == 1`. So system-site `.pth` executable lines and `sitecustomize`/`usercustomize` **cannot run unpinned
startup code** before the pinned launcher/broker. (Contrast without `-S`: `python3 -s -B``no_site == 0`, `site`
imported — the exact v9 hole this closes.)
- **(iv)** No harness reliance on any site-injected path/hook (env is the constructed allow-list; children execute
pinned bytes).
- **(v) `-S` does not touch `sys.path[0]`** (unlike `-I`/`-P`) — empirically confirmed: `python3 -s -S -B main.py`
keeps `sys.path[0]` = the script's directory, so the launcher's bare `from lease_generation import
initialize_runtime_generation` (`f4008307:launch-runtime.py:15`) **still binds `pinned/lease_generation.py`** with
`-S` present. The broker's explicit `--generation-module` import (via `importlib`) binds the pinned helper
regardless of `sys.path`/site, so `-I -S` is correct there.
- **(vi) Delta = exact 2-line `-S` only** vs `1c34e3cb` (git-diff/byte-compared, not accepted on assertion).
## All prior bars — byte-stable (delta was only the two `-S` lines)
- **B6(c):** launcher still carries no `-I`; sibling import binds `pinned/` (confirmed above with `-S` present). ✅
- **B7:** broker `Popen` `env=environment` (allow-list, **not** `os.environ`; no `PYTHONPATH`/`PYTHONHOME`/
`PYTHONPYCACHEPREFIX`) + `-I`. ✅
- **B8:** `reject_pinned_bytecode` fail-closed before each consumer; `PYTHONDONTWRITEBYTECODE=1` + `PYTHONNOUSERSITE=1`
in env; `-B` on both children. ✅
- **B5:** 3-leg conjunction — materialize each of launcher/helper/broker from git-object bytes with `sha256==pin`
fail-closed; `pinned/` `0o700` in fixture root, files `O_EXCL 0o600` (no writable window); re-hash `==pin`
immediately before each `Popen`; launcher + broker consume the same single pinned helper. ✅
- **B6:** `closure_import_guard` AST present; single pinned helper; broker `--generation-module = closure.generation`. ✅
- **Fidelity:** extension bumps `generation-{sid}.state` via `MOSAIC_LEASE_GENERATION_FILE` (not in-mem); broker
`read_runtime_generation`; `assert_d4` `generation_source=="state-file"` / `state_file_in_fixture_root` /
`new→MUTATOR_UNVERIFIED` / `prior→STALE_GENERATION`; `.state` fixture-root-bound. ✅
- **Traceability:** `GATED_WI_HEAD == f4008307` + `merge-base --is-ancestor 66b1e0a0 f4008307`. ✅
- `lease_anchor_registered` + `hex-256` INTACT; LIVE-PATH (pinned gated launcher); single p3 broker;
promotion=fixture-only; `--runs choices=(3,)`; `-O`-safe (0 bare `assert`); `copy2` = creds-only; allow-list env
(0 `os.environ.copy`). ✅
## ABSENT sweep
P5/P6/P2-bank/retry-launder/mosaic-yolo/execRuntime = 0; `source-invalid` = 0; no live/real-broker path; no `.state`
outside fixture root; no exec-at-import (`__main__`-guarded); no adjacency-only-exec-from-worktree; the only change is
the authorized `-S` no-site isolation-deepening (no mechanism change, no scope-widen); `-O`-safe.
## Verdict
**PASS @ `ce5ba762`.** B9 (no-site startup closure) is closed — both children carry `-S`, `site` is not imported at
startup (so system-site `.pth`/`sitecustomize` cannot execute unpinned code before the pinned launcher/broker),
`-S` leaves `sys.path[0]` intact so the launcher sibling import and the broker explicit-path import both still bind
the pinned helper, and the delta vs `1c34e3cb` is exactly the two `-S` insertions. All prior bars (B5/B6/B6c/B7/B8/
fidelity/traceability/lease_anchor/live-path/single-broker/promotion/`--runs`/`-O`-safe) are byte-stable. Zero
out-of-scope surface. **Findings: none.**
## Scope reminder (not a finding)
Producing this evidence **executes** the Gate0 mechanism (§3/§5). This review clears **bytes** only; **FIRE remains
separately gated on Mos's explicit post-clear GO**, his byte-scope-verify co-attestation, and homelab's
third-principal verify — not authorized by this review.
---
**Reviewer:** distinct Opus SECREV session (`ms-secrev-828` lane, dispatched by `mosaic-100`), Gate-16 author≠reviewer,
byte review only; ran nothing.
**Reviewed object (pin):** `ce5ba762051354338889959bfce2b0381f4a4e2a` · harness sha256 `7e14ead89a7b2a297fcc17e7653291b3bcace1d2002a8f90a989db74f6985b6f`.

View File

@@ -1,120 +0,0 @@
> ⚠ SUPERSEDED by v8 (homelab 3rd-principal FAIL @f609a449: broker env-inherit + unpinned .pyc; PASS overtaken by stricter B7+B8 bar). This v7 PASS record is NOT fire-authorization.
# Gate0 Probe-3 (D4) Full-Closure Materialization — §3-Conformance Review v7
**Verdict: ✅ PASS** *(superseded — see banner above)*
## Pin (GUARD 1 — reviewed object)
- **Reviewed object = `f609a44953f5ae61916805fcb45ca337de00b0b0`** (harness commit, branch `feat/827-gate0-probe`).
- **Reviewed file:** `docs/compaction-refresh/probes/p3_d4_focused_run.py` — sha256 (pushed provider bytes, `-o FILE`, FULL-40 ref, verified before trust): `0f1bd1b39399b32f243d901230e2d840794a2144edd723a095dab716833a7a9b` (32071 bytes, no not-found sentinel).
- **Closure pins (independently recomputed from the git objects):**
- launcher `packages/mosaic/framework/tools/lease-broker/launch-runtime.py` @ `f4008307` = `e950e4224e280f16979d90cabb89aa1896c5ee28bed2df957e14d018d43cda82`
- **helper** `packages/mosaic/framework/tools/lease-broker/lease_generation.py` @ `f4008307` = `061625402f08488eac47acd23272904e71fd1a71fd15b3bdab158632c801be4c`
- **broker** `docs/compaction-refresh/probes/p3_generation_broker.py` @ `23c0caca` = `4db4fef1ac6658a8ca79ad5091cefc901d2aa26003265c3d6726c294cf895cad`
## Independence (GUARD 2 — principal-independence attestation)
This review is produced by a **distinct Opus SECREV session, orchestrator-dispatched — the `ms-secrev-828`
reviewer lane, dispatched by `mosaic-100`** — **byte review only, ran nothing**, that **did NOT build** this harness
(builder = ms-rev-826) and **is not Mos**. Three distinct principals: this reviewer, the builder, and Mos (whose
own distinct-identity byte-scope-verify follows); homelab's independent verify is a further principal — it is
homelab's third-principal FAIL @`2d54a9dd` (upheld by Mos) that correctly retired the approach-(i) adjacency
re-hash and authorized this full-closure. v6/`2d54a9dd`/`23c0caca`/`12914d8` are superseded.
## Why v7 (the reopen-after-hash hole)
Approach (i) re-hashed the launcher then let `Popen` **reopen the worktree path** — hashed-snapshot ≠ executed-bytes
(the worktree file is a shared, same-UID-mutable path). Statement adjacency alone did not bind. v7 closes it for the
**full project-code closure** (launcher + `lease_generation.py` helper + `p3_generation_broker.py`).
## B5 — HASHED == EXECUTED on the full closure (binding conjunction, stated verbatim)
The reopen-after-hash shape is unavoidable for imported/exec'd files, so closure rests on the **conjunction of all
three legs**, each byte-verified here:
> **(a)** bytes are materialized **from the pinned git-object @ `f4008307`** (helper/launcher) and **@ `23c0caca`**
> (broker) — `git show <commit>:<path>`, the trusted immutable object, **never the mutable worktree file**; **AND**
> **(b)** into a **fixture-private `0o700` dir with `0o600` files created via `O_CREAT|O_EXCL`** — no writer exists in
> the threat model between hash and exec; **AND** **(c)** each member is **re-hashed == its pin IMMEDIATELY before
> exec/import, fail-closed (`RuntimeError`)**.
Byte evidence:
- **(a)** `git_object_bytes(git_root, commit, relative)` = `git show <commit>:<path>` (:322-327); `materialize_closure`
reads all three members from git objects and asserts `sha256(data) == digest` else `RuntimeError` (:415-433). Worktree
working-tree files are never read.
- **(b)** `pinned = root / "pinned"; pinned.mkdir(mode=0o700)` (:435-436); `write_pinned_file` uses
`os.open(path, O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC, 0o600)` (:379-382). **No `os.chmod`/`os.rename`/`shutil.move`
anywhere** (grep=0); `O_EXCL` refuses a pre-planted file/symlink, so no symlink-follow or hijack gap; the dir is a
fresh per-run `mkdtemp` child, owner-only. **No code re-opens the pinned files for write between materialize and
consume** — there is no writable window.
- **(c)** launcher re-hash `sha256(launcher.read_bytes()) == GATED_LAUNCHER_SHA256` is the statement immediately before
`return PiRpc(command,…)` (:528-530); broker **and** helper re-hashes (`== GATED_BROKER_SHA256`,
`== GATED_GENERATION_SHA256`) are the two statements immediately before `return subprocess.Popen(command,…)`
(:546-551). No interleaved harness yield.
Adjacency-only exec-from-worktree is **absent** for every member (all three exec/import from `pinned/`; grep worktree-exec=0).
## B6 — helper + broker pinned and bound to execution (one shared helper)
`materialize_closure` writes exactly **one** `pinned/lease_generation.py` (:442). The broker executes the **pinned**
broker with `--generation-module = closure.generation` = that pinned helper (`launch_verified_broker`, :533-551, called
:746-747). The launcher executes the **pinned** launcher (`python3 pinned/launch-runtime.py`), whose
`import lease_generation` resolves via `sys.path[0]` = the script's own `pinned/` dir to the **same** sibling
`pinned/lease_generation.py`. Launcher-import and broker-`--generation-module` therefore resolve the **same single
pinned helper copy**, not two copies and not the worktree. Worktree helper/broker are not re-read at runtime.
**Closure-import guard:** `closure_import_guard` AST-parses each member and refuses any non-stdlib import outside the
allow-set `{"lease_generation"}` (and any relative import) → `RuntimeError` (:341-364). The 3-member closure is
therefore provably complete — no unpinned project-code dependency can slip in.
## BAR1 — Traceability
`GATED_WI_HEAD == f4008307`; the precondition asserts `git merge-base --is-ancestor 66b1e0a0 f4008307` (:315-330),
independently confirmed **YES** — the pinned launcher forward-contains the `66b1e0a0` file-backed generation mechanism.
## BAR2 — Fidelity file-backed, `.state` in fixture root, UNTOUCHED
`pinned/` holds **code bytes only** (launcher/helper/broker). The `.state` generation file is written by the launcher
to `socket_path.parent` (the fixture root), **not** `pinned/`. The broker (pinned, byte-identical `4db4fef1`) still
enforces `generation_environment` raising if `state_path.parent != socket_path.parent` (grep=2), and `assert_d4`
still checks `state_file_source == "state-file"` / `state_file_drives_lifecycle` / `state_file_in_fixture_root` +
`new→MUTATOR_UNVERIFIED` / `prior→STALE_GENERATION` (grep=4, unchanged). The v7 change did not move `.state` into
`pinned/` or perturb these asserts.
## BAR3 — Carry-over
(a) **live-path:** Pi launched via `python3 pinned/launch-runtime.py --runtime pi -- pi …` (gated register-before-exec);
`mosaic yolo`/`execRuntime` = 0. (b) **fail-closed precondition:** `gated_launcher_precondition` (resolve+materialize+
verify) runs before any launch, fail-closed. (c) **fixture-socket isolation:** `MOSAIC_LEASE_BROKER_SOCKET` = per-run
fixture socket; single pinned p3 broker serves `register_anchor`; no live/default broker reachable; non-destructive.
(d) **`lease_anchor_registered` INTACT:** event + `session_id_shape=="hex-256"` unchanged (broker byte-identical);
`assert_d4` folds it into the single-identity set — not deleted/softened/optional/repointed.
## Re-confirm + ABSENT sweep
Spawns ONLY the single pinned p3 broker; promotion=fixture-only; full D4 asserts; `--runs choices=(3,)`; allow-list
env (0 `os.environ.copy`); **`-O`-safe** (all new checks `RuntimeError`, **0 bare `assert`**); creds-scrub intact;
non-destructive (fixture tempdir only); deterministic (git objects + fixed pins); closure-import guard present.
**ABSENT = 0:** P5/P6/P2-bank/retry-launder/mosaic-yolo/execRuntime/pi_gate0; `source-invalid` grep=0; no live/real-broker
path; no `.state`/gen path outside the fixture root; no extra broker/socket; no exec-at-import (`__main__`-guarded); no
adjacency-only exec-from-worktree for any member; the only mechanism change is materialization; no scope-widen.
## Verdict
**PASS @ `f609a449`.** B5 (full-closure hashed==executed via the (a)+(b)+(c) conjunction with no writable window),
B6 (one shared pinned helper bound to both launcher-import and broker-`--generation-module`; complete closure), BAR1,
BAR2 (fidelity `.state`-in-fixture-root untouched), and BAR3 all hold, with zero out-of-scope surface. **Findings: none.**
## Scope reminder (not a finding)
Producing this evidence **executes** the Gate0 mechanism (§3/§5). This review clears **bytes** only; **FIRE remains
separately gated on Mos's explicit post-clear GO**, his v-final byte-scope-verify co-attestation, and homelab's
third-principal verify — not authorized by this review.
---
**Reviewer:** distinct Opus SECREV session (`ms-secrev-828` lane, dispatched by `mosaic-100`), Gate-16 author≠reviewer,
byte review only; ran nothing; did not build.
**Reviewed object (pin):** `f609a44953f5ae61916805fcb45ca337de00b0b0` · harness sha256 `0f1bd1b39399b32f243d901230e2d840794a2144edd723a095dab716833a7a9b`.
**Pinned closure:** launcher `e950e422…` @f4008307 · helper `06162540…be4c` @f4008307 · broker `4db4fef1…` @23c0caca.

View File

@@ -1,92 +0,0 @@
> ⚠ SUPERSEDED by v9: the reviewed harness `a92ad090` is superseded by the narrow fix `1c34e3cb` (my v8 B6(c) FAIL — `-I` on the launcher — was remediated by `-I`→`-s` + `PYTHONNOUSERSITE=1`; re-review v9 = PASS). This v8 FAIL record pertains to the superseded commit.
# Gate0 Probe-3 (D4) Broker Env-Isolation + Bytecode Binding — §3-Conformance Review v8
**Verdict: ❌ FAIL** (B7 and B8 land correctly, but the same change breaks **B6(c)**: the launcher is run with `-I`, which strips the script directory from `sys.path` on Python 3.11+, so its bare `import lease_generation` cannot resolve the pinned helper — empirically confirmed).
## Pin (GUARD 1 — reviewed object)
- **Reviewed object = `a92ad090ae3828c643f961c7628d809b8521185f`** (harness commit, branch `feat/827-gate0-probe`).
- **Reviewed file:** `docs/compaction-refresh/probes/p3_d4_focused_run.py` — sha256 (pushed provider bytes, `-o FILE`, FULL-40 ref, verified before trust): `915ebeb5aeab108cb60c5f629c1db520623ab4914eed427ca34ee66f9aa08390` (32614 bytes, no not-found sentinel).
- **Closure pins (unchanged from v7):** launcher `e950e422…` @f4008307 · helper `061625402f08488eac47acd23272904e71fd1a71fd15b3bdab158632c801be4c` @f4008307 · broker `4db4fef1…` @23c0caca.
## Independence (GUARD 2 — principal-independence attestation)
Distinct Opus SECREV session, orchestrator-dispatched — the **`ms-secrev-828` reviewer lane, dispatched by
`mosaic-100`** — **byte review only, ran nothing** (the harness/broker were not executed); did **not** build this
harness (builder = ms-rev-826); is not Mos. This verdict is my own. (The `-I` semantics check below runs a *throwaway*
two-line script to observe the interpreter's `sys.path` behavior — it does not run the harness, broker, or any part of
the reviewed closure.)
## 🔴 BLOCKING FINDING — B6(c) broken: `-I` on the launcher strips the pinned-helper import path
**File:line — `p3_d4_focused_run.py:512`** (the `"-I"` added to `launch_verified_pi`'s launcher command).
The pinned launcher `launch-runtime.py` @`f4008307` imports its helper with a **bare top-level import**:
`from lease_generation import initialize_runtime_generation` (launcher line 15) — no `sys.path` manipulation. Under
v7 this bound because `python3 pinned/launch-runtime.py` put the script's directory (`pinned/`) at `sys.path[0]`, so
the sibling `lease_generation` resolved to `pinned/lease_generation.py`.
v8 now runs the launcher as `python3 -I -B pinned/launch-runtime.py …` (:512-513). **`-I` implies `-P` (Python 3.11+),
which does NOT prepend the script's directory to `sys.path`.** Empirically confirmed on this host (Python 3.11.2),
using a throwaway script (not the harness):
```
python3 -I -B main.py → sys.path[0] = '/usr/lib/python311.zip'
import sibling → ModuleNotFoundError: No module named '…'
python3 -B main.py → sys.path[0] = '<script dir>' → sibling import: OK
```
Therefore, at FIRE on Python 3.11+, the launcher's line-15 `from lease_generation import …` raises
`ModuleNotFoundError` at module load — the pinned helper does **not** resolve (neither pinned nor worktree; the import
simply fails). **B6(c) — "launcher sibling-import to `pinned/` via `sys.path[0]` STILL BINDS" — does not hold.** The
build report's assertion "`-I` keeps script dir" is false on 3.11+, and could not have been observed under the
correct "never run" boundary.
Note: the env allow-list carries no `PYTHONPATH` (correct for B7), and `-I` ignores `PYTHON*` env regardless, so there
is no alternate resolution path — the launcher import is unrecoverable under `-I`.
**Fix:** remove `-I` from the **launcher** command only (keep `-B` + the `env=` allow-list — the launcher's
env-isolation is already provided by the constructed allow-list, which contains no `PYTHONPATH`/`PYTHONHOME`/
`PYTHONPYCACHEPREFIX`, and it needs `pinned/` at `sys.path[0]` for the sibling import). Keep `-I` on the **broker**
command (it loads the helper by explicit `--generation-module` path via `importlib`, so it never needs the script
dir on `sys.path`). Alternatively, inject the pinned dir explicitly (e.g. `PYTHONPATH=pinned/` — but that reintroduces
a `PYTHON*` passthrough B7 forbids, so dropping `-I` on the launcher is the clean fix).
## What DID land correctly (for the author's fast turnaround)
- **B7 — broker child env-isolated: correct.** `launch_verified_broker` now takes `environment` and passes
`env=environment` (the constructed allow-list, **not** `os.environ`) to `Popen` (:566-568); the broker command
includes `-I` (:551); the allow-list contains no `PYTHONPATH`/`PYTHONHOME`/`PYTHONPYCACHEPREFIX` passthrough. The
broker child cannot inherit ambient env or resolve stdlib imports to ambient code. ✅
- **B8 — bytecode pinned-or-suppressed: correct.** `PYTHONDONTWRITEBYTECODE=1` is in the allow-list env (:728) and
`-B` is on **both** child commands (:512-513 launcher, :551-552 broker); `reject_pinned_bytecode` fails closed
(`RuntimeError`) on any pre-existing `pinned/__pycache__` or `*.pyc` (:498-501) and is called **before each
consumer** (:534 launcher, :561 broker). No unpinned `.pyc` can be executed. ✅
- **B5 conjunction / B6 single-helper / closure-import-guard / BAR1 / BAR2 (`.state` fidelity untouched) / BAR3
(live-path, fail-closed precondition, fixture-socket isolation, `lease_anchor_registered` + hex-256) / single p3
broker / `-O`-safe / allow-list env / `--runs==(3,)` / ABSENT sweep:** all intact/unperturbed (the delta touches only
the env/`-I`/`-B`/bytecode-reject surfaces). These are **not** the failing item.
## Verdict
**FAIL @ `a92ad090`.** B7 (broker env isolation) and B8 (bytecode pinned-or-suppressed) are correctly implemented,
but the `-I` added to the **launcher** command breaks B6(c): the launcher's bare `from lease_generation import` at
`f4008307:launch-runtime.py:15` cannot resolve the pinned helper because `-I`/`-P` strips `sys.path[0]` on Python
3.11+ (empirically confirmed, 3.11.2 → `ModuleNotFoundError`). PASS requires **all** of B7+B8+B5+B6+BAR1/2/3; B6(c)
does not hold. Not softened → returns to author (ms-rev-826). The fix is narrow: drop `-I` from the launcher command
(retain `-B` + allow-list env), keep `-I` on the broker.
**Findings:** B6(c) — `p3_d4_focused_run.py:512` (`-I` on the launcher command; breaks the pinned-helper sibling
import under Python 3.11+).
## Scope reminder (not a finding)
Producing this evidence **executes** the Gate0 mechanism (§3/§5). This review clears **bytes** only; FIRE remains
separately gated on Mos's post-clear GO — moot until this FAIL is remediated.
---
**Reviewer:** distinct Opus SECREV session (`ms-secrev-828` lane, dispatched by `mosaic-100`), Gate-16 author≠reviewer,
byte review only; ran nothing (harness/broker not executed).
**Reviewed object (pin):** `a92ad090ae3828c643f961c7628d809b8521185f` · harness sha256 `915ebeb5aeab108cb60c5f629c1db520623ab4914eed427ca34ee66f9aa08390`.

View File

@@ -1,95 +0,0 @@
> ⚠ SUPERSEDED: homelab 4th-round FAIL @`1c34e3cb` — no `-S` → Python imports `site` at startup, running unpinned system-site `.pth` executable lines + `sitecustomize`/`usercustomize` before the pinned launcher/broker (site startup-closure hole). This v9 PASS record is overtaken by the stricter B9 (no-site) bar and is NOT fire-authorization; superseded by v10.
# Gate0 Probe-3 (D4) Launcher-Import Fix — §3-Conformance Review v9
**Verdict: ✅ PASS** *(superseded — see banner above)*
## Pin (GUARD 1 — reviewed object)
- **Reviewed object = `1c34e3cb3172acdcd094e683e847d7c984afc96c`** (harness commit, branch `feat/827-gate0-probe`).
- **Reviewed file:** `docs/compaction-refresh/probes/p3_d4_focused_run.py` — sha256 (pushed provider bytes, `-o FILE`, FULL-40 ref, verified before trust): `29e5c7bfbe1911b52984bd94c79036bb1200ee82588318367b13c2b1053a0103` (32725 bytes, no not-found sentinel).
- **Closure pins (unchanged):** launcher `e950e422…` @f4008307 · helper `061625402f08488eac47acd23272904e71fd1a71fd15b3bdab158632c801be4c` @f4008307 · broker `4db4fef1…` @23c0caca.
## Independence (GUARD 2 — principal-independence attestation)
Distinct Opus SECREV session, orchestrator-dispatched — the **`ms-secrev-828` reviewer lane, dispatched by
`mosaic-100`** — **byte review only, ran nothing** (harness/broker not executed); did **not** build this harness
(builder = ms-rev-826); is not Mos. This is the re-review after **my own** v8 FAIL @`a92ad090` (B6(c): `-I` on the
launcher broke the sibling import); the author applied the narrow fix and I verify it here. The `-s`/`-I` `sys.path`
checks below use a *throwaway* two-line script to observe interpreter behavior — not the harness/broker/closure.
## ★ B6(c) — THE FIX (was the v8 FAIL): launcher `-I` dropped; sibling import binds to `pinned/`
The launcher command no longer carries `-I`; it now uses **`-s`** (`:514`, commented "`-s` preserves `sys.path[0]=pinned/`
for the launcher's sibling helper") + `-B` (`:515`), and `PYTHONNOUSERSITE=1` is added to the allow-list env (`:730`).
`-s` and `PYTHONNOUSERSITE` disable **user site-packages only** — they do **not** strip the script's directory from
`sys.path` (unlike `-I`/`-P`). Empirically confirmed on this host (Python 3.11.2), throwaway script:
```
python3 -s -B main.py → sys.path[0] = '<script dir>' → sibling import: OK
PYTHONNOUSERSITE=1 python3 -s -B main.py → sys.path[0] = '<script dir>' → sibling import: OK
python3 -I -B main.py (the v8 FAIL form) → sys.path[0] = stdlib zip → ModuleNotFoundError
```
Therefore `python3 -s -B pinned/launch-runtime.py …` puts `pinned/` at `sys.path[0]`, so the pinned launcher's bare
top-level `from lease_generation import initialize_runtime_generation` (`f4008307:launch-runtime.py:15`, no `sys.path`
manipulation) resolves to the **pinned** `pinned/lease_generation.py` — not the worktree, not a miss. **B6(c) holds.**
## B7 — Broker env-isolation (still holds)
`launch_verified_broker` passes `env=environment` (the constructed allow-list, **not** `os.environ`; contains no
`PYTHONPATH`/`PYTHONHOME`/`PYTHONPYCACHEPREFIX`) to `Popen` (`:570`), and the broker command includes `-I` (`:552`).
The broker imports the helper by explicit `--generation-module` path via `importlib`, so it never needs `sys.path[0]`
`-I` is correct there and does not affect it. (The env's `PYTHONNOUSERSITE`/`PYTHONDONTWRITEBYTECODE` are hardening
flags, not path/home passthrough, and `-I` ignores all `PYTHON*` env anyway.)
## B8 — Bytecode pinned-or-suppressed (still holds)
`PYTHONDONTWRITEBYTECODE=1` (`:729`) and `PYTHONNOUSERSITE=1` (`:730`) in the allow-list env; `-B` on **both** child
commands (`:515` launcher, `:553` broker); `reject_pinned_bytecode` fails closed (`RuntimeError`) on any pre-existing
`pinned/__pycache__` or `*.pyc` (`:498-501`) and is called **before each consumer** (`:535` launcher, `:562` broker).
No unpinned `.pyc` can be executed.
## B5 — 3-leg conjunction (still holds)
`materialize_closure` reads launcher+helper+broker from **git-object bytes** (`git show <commit>:<path>`) and asserts
`sha256 == pin` for each, fail-closed; `pinned/` is a fixture-private `0o700` dir inside the per-run fixture temp root;
files created `O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC 0o600` (no chmod/rename/symlink gap → no writable window); each member
re-hashed `== pin` immediately before its `Popen` (launcher; broker + helper). Launcher and broker consume the **same
single** pinned helper. `closure_import_guard` AST-rejects any unpinned non-stdlib import.
## Fidelity + traceability + carry-over (still hold)
`GATED_WI_HEAD == f4008307` + `merge-base --is-ancestor 66b1e0a0 f4008307` (forward-contains). Extension bumps
`generation-{sid}.state` via `MOSAIC_LEASE_GENERATION_FILE` (not in-mem); broker reads via `read_runtime_generation`;
`assert_d4` observes the file-backed transition (`generation_source=="state-file"`, `state_file_drives_lifecycle`,
`state_file_in_fixture_root`, new→`MUTATOR_UNVERIFIED`, prior→`STALE_GENERATION`); `.state` stays in the fixture temp
root. `lease_anchor_registered` INTACT (event + `session_id_shape=="hex-256"`). LIVE-PATH drives the pinned gated
launcher (no released `mosaic`/`execRuntime`). Fail-closed precondition before any launch. Single pinned p3 broker.
Promotion=fixture-only. `--runs choices=(3,)`. `copy2` = creds-only. Allow-list env (0 `os.environ.copy`).
## ABSENT sweep
P5/P6/P2-bank/retry-launder/mosaic-yolo/execRuntime = 0; `source-invalid` = 0; no live/real-broker path; no `.state`
outside the fixture root; no exec-at-import (`__main__`-guarded); no adjacency-only-exec-from-worktree; the only change
is the authorized launcher-flag isolation fix (no mechanism change, no scope-widen); **`-O`-safe** (0 bare `assert`).
## Verdict
**PASS @ `1c34e3cb`.** The v8 FAIL is remediated by the narrow fix (launcher `-I``-s` + `PYTHONNOUSERSITE=1`),
empirically verified to preserve `sys.path[0]=pinned/` so the pinned launcher's sibling import binds to the pinned
helper; the broker retains `-I` (explicit-path import). B7, B8, B5, B6-rest, fidelity, traceability, and all carry-over
bars remain intact; zero out-of-scope surface. **Findings: none.**
## Scope reminder (not a finding)
Producing this evidence **executes** the Gate0 mechanism (§3/§5). This review clears **bytes** only; **FIRE remains
separately gated on Mos's explicit post-clear GO**, his byte-scope-verify co-attestation, and homelab's
third-principal verify — not authorized by this review.
---
**Reviewer:** distinct Opus SECREV session (`ms-secrev-828` lane, dispatched by `mosaic-100`), Gate-16 author≠reviewer,
byte review only; ran nothing.
**Reviewed object (pin):** `1c34e3cb3172acdcd094e683e847d7c984afc96c` · harness sha256 `29e5c7bfbe1911b52984bd94c79036bb1200ee82588318367b13c2b1053a0103`.

View File

@@ -1,290 +0,0 @@
# Design — #791: Framework upgrades must not destroy operator-owned config under `~/.config/mosaic`
- **Issue:** mosaicstack/stack#791
- **Branch:** `feat/791-upgrade-config-protection` (off `origin/main` `9745bc3f`)
- **Author:** ms-791 worker lane
- **Status:** Phase 1 — DESIGN, awaiting MS-LEAD confirmation before implementation
- **Ratified scope (Mos-approved, not re-litigated):** deliver **(b) strict ownership separation [PRIMARY]** + **(a) transactional pre-update snapshot [safety net]** + **(d) regeneration-from-SSOT [recovery]**. **(c) periodic backup timer is DEFERRED** — noted as future work only.
---
## 1. Current updater behavior + exact wipe mechanism (evidence)
### 1.1 What runs on `mosaic update`
`mosaic update` re-seeds the framework by invoking the **bash installer** in sync-only, keep mode:
- `packages/mosaic/src/runtime/update-checker.ts:509` `buildReseedCommand()` returns
`bash <frameworkRoot>/install.sh` with env `MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`,
`MOSAIC_HOME=<mosaicHome>`.
- The same `install.sh` is the direct/`tools/install.sh` upgrade path and the framework-vN migration path.
So the destructive surface is **`packages/mosaic/framework/install.sh`**.
### 1.2 The wipe
`sync_framework()` (`install.sh:177`) performs, in `keep` mode:
```
rsync -a --delete --exclude .git --exclude .framework-version --exclude '*.pre-constitution.bak' \
[--exclude "/$path" for each PRESERVE_PATHS entry] SOURCE_DIR/ TARGET_DIR/
```
- `install.sh:199``rsync -a --delete`. **`--delete` prunes every path in `~/.config/mosaic`
that is NOT present in the shipped framework source**, unless excluded.
- `install.sh:47``PRESERVE_PATHS` is the **only** thing standing between `--delete` and operator
data. It is a _denylist of exclusions_:
```
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md"
"memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents"
"fleet/run" "fleet/backlog" "fleet/roles.local")
```
- The cp-fallback (no rsync) is equally destructive: `install.sh:223`
`find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ... -exec rm -rf {} +` then re-copies source, restoring
only PRESERVE_PATHS globs.
**Root-cause model:** _"Everything under `~/.config/mosaic` is framework-owned and pruneable UNLESS
explicitly preserved."_ Any operator path the list forgets is destroyed on the next upgrade.
### 1.3 The exact operator paths wiped
Cross-referencing the issue's operator-owned list against `PRESERVE_PATHS`:
| Operator path (issue #791) | In PRESERVE_PATHS? | Fate on `mosaic update` |
| ----------------------------------------------------------------- | --------------------------------------- | ----------------------- |
| `agents/*.conf` (per-agent runtime) | **NO** | **WIPED** |
| `policy/*.md` (operator overlays) | **NO** | **WIPED** |
| `*.local.md` (SOUL/USER/STANDARDS) | **NO** | **WIPED** |
| harvester / SOP artifacts + timers | **NO** | **WIPED** |
| `tools/_lib/credentials.json` | **NO** (`credentials/` dir ≠ this path) | **WIPED** |
| `fleet/agents/*.env` | yes (`fleet/agents`, added by #631) | survives |
| `memory/`, `fleet/roster.*`, `fleet/backlog`, `fleet/roles.local` | yes | survives |
The `fleet/agents`, `memory`, `fleet/backlog` entries were **retro-added after prior incidents**
(#631). This whack-a-mole is the structural signature of a denylist.
**Stale-comment evidence:** `update-checker.ts:492` claims the reseed preserves
"`SOUL/USER/*.local/credentials`" — but `PRESERVE_PATHS` contains **no `*.local` entry**. The code
documents protection it does not deliver.
### 1.4 Second code path (TS) — already non-destructive, but drifted
`FileConfigAdapter.syncFramework()` (`packages/mosaic/src/config/file-adapter.ts:157`) →
`syncDirectory()` (`packages/mosaic/src/platform/file-ops.ts:66`) is a **copy-overlay**: it copies
source over target and skips preserved paths, but **never deletes** target paths absent from source
(`file-ops.ts:77-109`). It is used by the wizard/init flow, not `mosaic update`.
Two problems remain:
1. Its `preservePaths` (`file-adapter.ts:164-185`) has **already diverged** from `install.sh` — it is
**missing `fleet/backlog` and `fleet/roles.local`**. Two hand-maintained denylists, drifted. This
is direct evidence for a single shared SSOT manifest.
2. Even non-destructive, it will happily _overwrite_ an operator file that collides with a
framework-shipped path unless that path is on its (incomplete) preserve list.
### 1.5 Existing snapshot is inadequate for rollback
`make_snapshot()`/`restore_snapshot()` (`install.sh:76-87`) copy `TARGET_DIR` to `mktemp -d` under
`/tmp`, restore **only on `ERR/INT/TERM` trap**, and are **deleted on success** (`cleanup_snapshot`,
`install.sh:345`). Consequences: ephemeral `/tmp`, no retention, no post-success rollback, and **no
`mosaic restore`**. It is crash-safety only, not the transactional safety net #791 requires.
---
## 2. Fix (b) — Strict ownership separation [PRIMARY / root cause]
### 2.1 Ownership model (invert to allow-list)
Replace _"framework-owned unless preserved"_ with _"operator-owned unless framework-owned"_, resolved
**per target path** with operator carve-outs winning inside shared framework subtrees.
Two declared lists, one SSOT data file shipped in the framework
(`framework/framework-manifest.json`), consumed by **both** bash and TS:
- **`framework` globs** — paths the updater is entitled to create / overwrite / prune. Authored to
match exactly what the framework ships in `packages/mosaic/framework/` (e.g. `CONSTITUTION.md`,
`AGENTS.md`, `STANDARDS.md`, `TOOLS.md`, `guides/**`, `constitution/**`, `templates/**`, `tools/**`,
`skills/**`, `mcp/**`, `defaults/**`, `fleet/examples/**`, `fleet/roles/**`, `fleet/profiles/**`,
`fleet/roster.schema.json`).
- **`operatorReserved` globs** — NEVER written or pruned, even nested inside a `framework` subtree;
these **win** over `framework` (deny-wins / most-specific-wins). At minimum:
`agents/**`, `policy/**`, `memory/**`, `sources/**`, `credentials/**`, `*.local.md`,
`tools/_lib/credentials.json`, `fleet/roster.yaml`, `fleet/roster.json`, `fleet/agents/**`,
`fleet/run/**`, `fleet/backlog/**`, `fleet/roles.local/**`, plus operator harvester/SOP artifacts.
### 2.2 Ownership resolution for a target path `P`
1. `P` matches `operatorReserved` → **operator-owned**: updater MUST NOT write, MUST NOT delete.
2. else `P` matches `framework` → **framework-owned**: may overwrite; may prune **only if absent from
the current SOURCE** (a genuinely retired framework file).
3. else (matches neither) → **UNKNOWN ⇒ operator-owned by default (fail-safe)**: never delete.
Rule 3 is the actual root-cause fix: an operator path the manifest authors forget is still protected,
because _unknown defaults to operator_. A denylist can never provide this guarantee.
### 2.3 Sync mechanism change (the mechanically-critical part)
`--delete` cannot express "prune only framework-owned" without re-enumerating every operator path
(the denylist trap). So:
1. **Drop `--delete` from the bulk sync.** Copy `SOURCE → TARGET` non-destructively (writes/overwrites
all framework files; deletes nothing). rsync without `--delete`, or the existing overlay copy.
2. **Explicit manifest-scoped prune pass.** Iterate the **`framework` manifest** (not the whole tree);
for each framework path present in `TARGET` but **absent in `SOURCE`**, delete it — after
re-checking it does not match `operatorReserved`. Because the prune iterates only declared
framework globs, operator/unknown paths are **structurally unreachable** by deletion.
This is implemented in both bash `sync_framework()` and TS `syncFramework()` from the shared manifest.
A pure **prune-planner** function (TS) computes the delete-set from
`(manifest, sourceListing, targetListing)` so the invariant is unit-testable in isolation.
`PRESERVE_PATHS` becomes redundant (kept as a defense-in-depth alias mapping to `operatorReserved`, or
removed) — either way the two lists stop drifting because they read one file.
### 2.4 HARD GATE test — "upgrade touches no path outside the manifest"
Filesystem-observation test in the existing `test-install-migration.sh` harness pattern (mktemp
`MOSAIC_HOME`, `MOSAIC_SYNC_ONLY=1`), plus TS specs:
1. Seed a throwaway `TARGET` with a realistic operator mix — one sentinel per operator class:
`agents/x.conf`, `policy/p.md`, `SOUL.local.md`, `memory/m.md`,
`tools/_lib/credentials.json` (with a secret value), `fleet/agents/a.env`, `fleet/roster.yaml`,
`harvester/sop.md`, **and a deliberately-unanticipated `unknown-operator-dir/x`**.
2. Record hash+mtime of every sentinel.
3. Run the upgrade from a `SOURCE` containing none of those operator paths.
4. **Assert:** every sentinel exists, byte-identical, **mtime unchanged** (not even rewritten). The
`unknown-operator-dir` surviving proves the fail-safe default — a denylist could not pass this case.
5. **Positive controls:** framework files WERE updated; a retired framework file WAS pruned.
6. **Property test** (TS prune-planner): for fuzzed operator paths, `deleteSet ⊆ {matches framework ∧
in target ∧ not in source}` and `deleteSet ∩ operatorReserved = ∅`.
---
## 3. Fix (a) — Transactional pre-update snapshot [safety net]
- **Destination:** `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/`.
**Outside `~/.config/mosaic`** (so no future sync can sweep it) and outside any repo.
- **Perms:** dir `0700`, files `0600` — enforced with `umask 077` around the copy **and** explicit
`chmod`. Never world-readable.
- **Scope:** the operator-owned surface (`operatorReserved` paths that exist) — bounded; does not copy
the framework tree.
- **Timing:** taken before ANY mutation in the upgrade flow.
- **Post-sync verify + selective restore:** after sync, diff the operator surface against the snapshot;
since (b) should never touch operator paths, any diff means a manifest bug — restore the affected
paths from the snapshot and warn loudly. This is precisely (a) catching a miss in (b).
- **Retention:** keep N most-recent (default 5; `MOSAIC_BACKUP_RETENTION` override); prune older.
- **`mosaic restore`:** `--list` (default, dry-run) enumerates snapshots by timestamp;
`--from <ts>` restores that snapshot over the operator surface, confirmation-gated. Reports
counts/paths only.
- **Secret-safety:** snapshot copy and restore never emit file **contents**; only paths/counts.
Tests assert `0700/0600` and that no secret value appears in stdout/stderr.
---
## 4. Fix (d) — Regeneration-from-SSOT [recovery]
The incident's live blast radius: `fleet/agents/*.env` (systemd `EnvironmentFile` sources) gone →
`mosaic-agent@<name>` boots **unit defaults** on restart (because `EnvironmentFile=-...` is
absent-tolerant) → **silent identity/runtime/workdir downgrade**.
The SSOT for those `.env` files is the roster. The reconciler **already** separates a
`regenerate-projections-from-roster` projection phase from lifecycle
(`packages/mosaic/src/fleet/fleet-reconciler.ts:93,234`; env rendering in
`generated-env-boundary.ts:149-264`).
**`mosaic fleet regen`** is therefore a **thin recovery-framed wrapper over the existing projection
phase** — it does NOT reimplement fleet logic and does NOT preempt in-flight FCM cards (M4/M5):
- Regenerates derivable config (per-agent `*.env.generated`, unit files) from roster SSOT.
- **Preview-first:** dry-run default; `--write` to apply. Idempotent.
- **Never restarts agents** (the recovery order forbids restart-before-verify).
- Prints the runbook's next step (verify `EnvironmentFile` resolves, THEN restart).
Alternatively documentable as `install.sh --relink` per the issue; `mosaic fleet regen` is preferred
because it reuses the merged reconciler plumbing.
---
## 5. Secret-safety approach (secrev surface)
- Snapshots/backups: `0700`/`0600`, outside any repo, never world-readable. (§3)
- No secret **value** ever emitted to logs/stdout/stderr by snapshot, restore, sync, or regen —
paths/counts only. Adversarial test: a secret value placed in `tools/_lib/credentials.json` must
never appear in installer or command output.
- `tools/_lib/credentials.json` is an explicit `operatorReserved` carve-out inside the framework-owned
`tools/**` subtree — it is never overwritten or pruned.
- The HARD GATE test doubles as a secret-safety test (asserts the credentials sentinel is untouched).
---
## 6. Test plan (TDD, tests-first, ≥85% on new code, co-located `*.spec.ts`)
1. **Manifest SSOT parity** — bash and TS resolve identical framework/operator sets from the one file;
a test fails if either path hard-codes a divergent list.
2. **Manifest completeness** — every path shipped in `framework/` is covered by a `framework` glob (so
a new shipped file cannot silently fall outside the manifest and become un-prunable/undeclared).
3. **HARD GATE** — upgrade touches nothing outside the manifest, incl. the unanticipated-path case
(§2.4).
4. **Prune-planner** unit + property tests (§2.4.6).
5. **Snapshot** — perms `0700/0600`, correct destination, retention prune, secret value absent from
output.
6. **Restore** — `--list` / `--from` round-trip restores operator surface byte-exact; confirmation
gate; no secret leakage.
7. **Regen** — roster→env projection deterministic + idempotent; dry-run makes no writes; `--write`
restores `*.env`; **never** issues a lifecycle/restart call.
8. **Cross-path regression** — TS `syncFramework` and bash `install.sh` agree on a shared fixture
(closes the current #631-style drift).
Gates before every push: `pnpm typecheck && pnpm lint && pnpm format:check` + mosaic package tests
green. Never `--no-verify`.
---
## 7. web1 recovery runbook (operator-agnostic; web1 specifics live in the issue as evidence only)
For a currently-wiped fleet EnvironmentFile state — **do NOT service-restart while
`fleet/agents/*.env` is absent** (a restart boots unit defaults and silently downgrades identity):
1. **Regenerate:** `mosaic fleet regen --write` — rebuild `~/.config/mosaic/fleet/agents/*.env` from
roster SSOT.
2. **Verify each unit resolves to the intended runtime/workdir** _before_ any restart:
`systemctl --user show mosaic-agent@<name> -p EnvironmentFile` and confirm the generated env exists
and carries the intended `MOSAIC_AGENT_*` runtime/workdir values.
3. **Only then** `systemctl --user restart mosaic-agent@<name>`, one unit at a time.
If config (not just fleet env) was lost, `mosaic restore --list` → `mosaic restore --from <ts>` before
step 1.
---
## 8. Proposed PR split (reviewable; DAG-ordered)
| PR | Scope | Depends | Review focus |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------- |
| PR1 | **PRIMARY** — shared `framework-manifest.json` + ownership resolver + non-deleting sync + scoped prune (bash + TS) + **HARD GATE** + prune-planner tests | — | correctness (root fix) |
| PR2 | **Safety net** — pre-update snapshot (`~/.local/state`, 0700/0600, retention) + post-sync verify/restore + `mosaic restore` | PR1 | **secrev** (backup/secret) |
| PR3 | **Recovery** — `mosaic fleet regen` (projection-only, preview-first, no restart) + docs (upgrade-safety + recovery runbook) | PR1 | correctness + docs |
Rationale: PR1 closes the failure class on its own; if PR2/PR3 slip, the class stays fixed. Each PR is
one reviewable unit with its own tests ≥85%. Independent review (author≠reviewer) on all; **secrev** on
PR2 (and PR1's secret-sentinel assertions).
## 9. Deferred (noted per scope)
**(c) periodic backup timer** — a systemd user timer snapshotting operator dirs on a cadence
(defense-in-depth for non-upgrade losses). Explicitly **out of scope now**; future phase.
## 10. Constraints honored
- **Framework-PR firewall:** manifest + logic are operator-agnostic; no SOUL/USER/operator specifics
in framework code; web1 details are issue evidence only.
- **Capacity-fill:** must not preempt M5-001 or #790; `fleet regen` reuses merged FCM-M3 plumbing and
does not overlap FCM-M4/M5 migration cards.
- **Delivery gates:** TDD tests-first, ≥85% new-code coverage, trunk-based squash PRs, independent
review + secrev, completion = merged PR + descendant-main green + #791 closed.
---
**Requesting MS-LEAD confirmation of:** (1) the manifest allow-list + non-deleting-sync + scoped-prune
approach as the (b) root-cause fix; (2) snapshot destination/retention + `mosaic restore` UX;
(3) `mosaic fleet regen` as a projection-only wrapper; (4) the 3-PR split. Implementation begins only
on your confirmation.

View File

@@ -66,12 +66,8 @@ checks the exact `=<agent-name>` tmux target; it never uses an ambient socket or
The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative;
the shell sidecar only provides fallback state when the native marker is stale or absent.
`mosaic agent comms-block <exact-member>` can inspect that exact roster member's resolved Fleet-Comms
block. It is a read-only inspection tool and fails loudly for an unknown exact member or missing roster.
On Linux, the installed roster, TOOLS contract, and executable helper are opened through a held
descriptor chain rooted at `/`; every managed path component uses no-follow traversal, and content plus
execute validation stay bound to the same opened file. Systems without Linux `/proc/self/fd` support
fail closed rather than falling back to pathname revalidation.
`mosaic fleet comms-block <role>` can inspect the role's resolved Fleet-Comms block. It is a read-only
inspection tool and fails loudly for an unknown role or missing roster.
## Current M2 boundary

View File

@@ -41,27 +41,10 @@ artifact can be removed.
| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. |
| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. |
## M4 migration-preview evidence
FCM-M4-001 layers an executable migration posture over the same 13-entry M1 inventory without
changing the retained artifact classification:
- every `v1-fixture` is previewed only with explicit class and lifecycle evidence;
- every `canonical-profile` remains validated by the shared baseline-plus-`roles.local` resolver;
- the canonical service policy remains generic and uses only the approved tool-policy alias.
`validateShippedFleetMigrationDispositions` first runs the existing executable M1 guard, then requires
explicit decisions and lifecycle observations and executes `previewV1ToV2Migration` for every shipped
v1 fixture. `collectShippedFleetMigrationDispositions` derives the 13-entry posture directly from
`SHIPPED_FLEET_ARTIFACT_DISPOSITIONS`, so additions or removals continue to fail the M1 guard rather
than creating a second artifact list. None of these dispositions claims a cutover, canary, or
rollback; those gates belong to FCM-M4-002. See [v1-to-v2 preview](./v1-to-v2.md).
## Running the guard
```bash
pnpm --filter @mosaicstack/mosaic test -- v1-v2-migration.spec.ts \
-t "validates all 13 shipped artifacts and executes ready previews for every v1 fixture"
pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts
```
The guard is intentionally limited to shipped assets and validation. It does not generate

View File

@@ -1,86 +0,0 @@
# Previewing a Fleet Roster v1-to-v2 Migration
**Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only
`mosaic fleet migrate-v1 preview` inventories a v1 roster and emits a canonical v2 candidate plus
recovery evidence. It does not write a roster, apply environment projections, invoke systemd or
`tmux`, contact connectors or remote hosts, launch an agent, run a canary, or execute rollback.
FCM-M4-002 owns reversible cutover and rollback.
## Inputs
```bash
mosaic fleet migrate-v1 preview \
--source roster-v1.yaml \
--decisions migration-decisions.json \
--observations reviewed-observations.json
```
The command emits one JSON object and exits nonzero when the preview is blocked, including when any of
`--source`, `--decisions`, or `--observations` is omitted, passed without a path value, or passed an empty
path value. These request-shape failures are reported before any input file is read. Decision and
observation JSON is validated fail-closed: unknown fields, malformed values, and records for non-local
agents are rejected. Decisions must supply a positive v2 `generation`, a reviewed `fleetHost` whenever
v1 agents include `host` or `ssh`, explicit `defaultRuntime`, and per-local-agent provider, model,
reasoning, enabled state, and launch policy. The v1 source remains authoritative for socket semantics:
a supported declared socket field, including an explicit empty value for the default tmux server, is
preserved; if both supported root aliases are absent, the production v1 default is the literal empty socket.
A matching `socketName` decision is accepted and an incompatible decision blocks, but a decision never
supplies or repairs a missing source socket. If v1 omitted `tool_policy`, decisions must supply an
explicit replacement; it is never derived from `class`. `model_hint` is never split or treated as
authority.
Observations are separate reviewed evidence keyed by local agent name:
```json
{
"coder0": { "systemd": "inactive", "tmux": "missing" }
}
```
Only `active` plus `present` maps to `running`; only `inactive` plus `missing` maps to `stopped`.
Missing, extra, unknown, or contradictory evidence blocks output. An observed-running agent cannot
be marked disabled. Observed-stopped agents always remain stopped.
## Field disposition
| v1 field | v2 disposition |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version`, `transport`, `tmux`, `defaults`, `runtimes` | Inventoried and structurally compiled; omitted runtimes retain v1 built-in defaults, while each explicitly declared runtime without a reset field follows the production v1 `/clear` fallback; present-empty holder/work-directory/reset values block |
| agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition. Canonical `~`/`~/...` values stay unchanged in roster evidence and traversal-free forms expand only at the shared production environment-projection boundary before unchanged absolute-path validation |
| `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference |
| `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation |
| `kickstart_template` | No v2 field; explicit inventory-only disposition required |
| agent `host`, `ssh` | `host != fleetHost` is demonstrably remote and inventory-only; `host == fleetHost` stays local; SSH targets with or without an explicit user must agree with `host`; ssh-only, missing fleet-host evidence, or contradictory targets block |
| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition |
| root `connector` | Inventory-only; never contacted or reconciled |
| unknown fields or snake/camel synonym collisions | Inventoried and block readiness |
| `.env.generated` | Rebuild from canonical roster data |
| no legacy `.env` | `absent`; no legacy action required |
| legacy `.env` containing generated keys only | `regenerate-only`; replace later from canonical roster data |
| legacy `.env` containing strict local keys | `relocate-local`; preserve those keys in `.env.local` during a later reviewed cutover |
| legacy `.env` containing forbidden/unsafe/sensitive/malformed keys | `quarantine`; private input only, with diagnostics limited to code, key, and SHA-256 |
The only automatic aliases are `implementer → code`, `reviewer → review`, and
`operator-interaction → interaction`. Similar or domain-specific names are never inferred. Automatic
classes do not accept competing disposition records. Semantic validation delegates to the existing
baseline-plus-`roles.local` resolver after the candidate is compiled by the existing v2 compiler.
## Evidence and recovery boundary
Ready output includes source and candidate SHA-256 identities, value-free field inventory, excluded
remote/connector entries, explicit environment dispositions with sanitized diagnostics, and the lifecycle
evidence used for each local candidate. Canonical lifecycle and remote-exclusion evidence ordering compares
Unicode code points directly and does not depend on source-agent order or process locale. Source field
inventory remains position-addressed evidence of the exact input. Recovery is marked non-executable and
assigns the executable gate to FCM-M4-002.
Before any later cutover, preserve these artifacts:
1. authoritative v1 roster backup;
2. agent environment backup, including `.env.local` and private quarantine inputs;
3. reviewed lifecycle observations;
4. canonical candidate v2 roster and its SHA-256.
See [backup and restore](../operations/backup-restore.md). Preview output is migration-readiness
evidence, not proof that migration, canary, or rollback occurred.

View File

@@ -1,40 +0,0 @@
# Fleet Configuration Backup and Restore Boundary
**Issue:** #758 · **Card:** FCM-M4-001
This page defines evidence that must exist before a roster v1-to-v2 cutover. FCM-M4-001 lists these
prerequisites in non-executable recovery evidence but does not validate that backups exist and performs
no backup, migration, canary, or restore. FCM-M4-002 owns the executable reversible canary and rollback
gates.
## Preserve before cutover
- The authoritative v1 roster, byte-for-byte, with a SHA-256 identity.
- Existing per-agent legacy `.env`, strict `.env.local`, and quarantine files under private
permissions.
- Reviewed per-local-agent systemd and exact-socket tmux observations.
- The canonical v2 candidate and its SHA-256 identity.
- Inventory-only remote agents and connector configuration as evidence, not local control-plane input.
`.env.generated` is a rebuildable projection and is not restored as authority. It must be regenerated
from the selected authoritative roster. `.env.local` is operator-owned strict data and must not be
overwritten or absorbed into generated output. Quarantined source remains private evidence; public
diagnostics expose only rule code, key name, and SHA-256.
## Restore requirements
A later rollback implementation must restore the authoritative roster and operator-owned environment
files, regenerate managed projections, and preserve each reviewed pre-cutover stopped/running state.
It must never start an agent observed stopped and must never reconcile an inventory-only remote or
connector entry.
The preview evidence deliberately records:
- `executable: false`;
- required backup artifacts;
- source and candidate identities;
- lifecycle observations and resulting desired states;
- environment relocation/quarantine dispositions;
- FCM-M4-002 as the executable rollback gate owner.
Do not interpret a ready preview as a completed backup, migration, canary, or rollback.

View File

@@ -11,15 +11,8 @@ mosaic fleet restart [name] --expected-generation <n> [--dry-run]
mosaic fleet status [name]
mosaic fleet verify
mosaic fleet doctor
mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path>
```
`migrate-v1 preview` is non-mutating: it emits value-free v1 inventory, a canonical semantically
validated v2 candidate when ready, sanitized environment dispositions, and non-executable recovery
evidence. It has no write, apply, canary, or rollback option. Missing preview inputs also return one stable
blocked JSON object and a non-zero exit, rather than Commander text. See
[the migration preview contract](../migration/v1-to-v2.md).
`apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational.
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry.

View File

@@ -63,8 +63,8 @@ agents:
## Nested fields
| Path | Required | Constraint |
| ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `tmux.socket_name` | yes | `[A-Za-z0-9_.-]*`; empty string means the literal default tmux server, while a non-empty value names a socket |
| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity |
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
| `defaults.working_directory` | yes | non-empty string |
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |

View File

@@ -24,7 +24,7 @@
"properties": {
"socket_name": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]*$"
"pattern": "^[A-Za-z0-9_.-]+$"
},
"holder_session": {
"type": "string",

View File

@@ -8,9 +8,8 @@
4. [Adding New Agent Tools](#adding-new-agent-tools)
5. [Adding New MCP Tools](#adding-new-mcp-tools)
6. [Database Schema and Migrations](#database-schema-and-migrations)
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
8. [API Endpoint Reference](#api-endpoint-reference)
9. [Local Fleet Canary](./fleet-local-canary.md)
7. [API Endpoint Reference](#api-endpoint-reference)
8. [Local Fleet Canary](./fleet-local-canary.md)
---
@@ -354,37 +353,6 @@ defined there.
---
## Claude Code Skill Bridge
The framework's canonical skill root is `~/.config/mosaic/skills/`; Claude Code
requires registrations under `~/.claude/skills/`. The implementation in
`packages/mosaic/src/commands/skill.ts` owns only direct-child symlinks whose
resolved target remains inside the canonical root.
Security invariants:
1. Validate the user-supplied name before filesystem access against
`[A-Za-z0-9][A-Za-z0-9._-]*`. Separators, control characters, whitespace,
`..`, absolute paths, and leading `-` are invalid; filesystem-derived invalid
names are escaped before terminal output.
2. Never replace a real file, directory, foreign symlink, or live misdirected
symlink in the Claude skill directory.
3. Repair a dangling link only when its lexical target is inside the canonical
Mosaic skills root.
4. Unregister only a symlink pointing inside that root.
5. Enumerate canonical directories at runtime; never hardcode framework skill
names.
`finalizeStage` reconciles after wizard/framework synchronization, and
`runFrameworkReseed` reconciles after the sync-only `mosaic update` path. A
foreign conflict is reported but does not prevent unrelated canonical skills
from registering. Filesystem tests use injected temporary roots in
`skill.spec.ts`, `finalize-skills.spec.ts`, and `update-checker.reseed.spec.ts`.
M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the
canonical root directly. Codex still relies on the existing full skill-sync
linker and needs separate parity analysis before this lifecycle API is extended.
## API Endpoint Reference
All endpoints are served by the gateway at `http://localhost:14242` by default.

View File

@@ -98,39 +98,6 @@ Expected results:
that means the unit ran, not that an agent pane is live. Treat tmux
`has-session`, `list-panes`, process tree, and logs as the liveness evidence.
## Recovery — rebuild generated env projections
Each agent's `~/.config/mosaic/fleet/agents/<name>.env.generated` is a
deterministic projection of `roster.yaml` (the SSOT) that the launcher
(`start-agent-session.sh`) sources at start. If an upgrade or a manual mistake
wipes or diverges those projections, rebuild them from the roster with
`mosaic fleet regen` — do NOT restart the affected unit first.
```bash
mosaic fleet regen # dry-run (default): show create/rebuild plan per agent
mosaic fleet regen --json # same plan, machine-readable
mosaic fleet regen --write # rebuild fleet/agents/<name>.env.generated on disk
```
`regen` is projection-only and **never restarts an agent** — it has no path to
systemd lifecycle. It is dry-run by default, deterministic/idempotent, uses the
same roster→env mapping as `mosaic fleet reconcile`, and emits paths and counts
only (never the projected `KEY=value` body). After `--write`, verify each unit
resolves the intended values before restarting one unit at a time. The unit sets
no `EnvironmentFile=``start-agent-session.sh` sources `.env.generated` itself —
so verify the generated file directly and the launcher path, not a nonexistent
`EnvironmentFile` property:
```bash
test -f ~/.config/mosaic/fleet/agents/<name>.env.generated
systemctl --user cat mosaic-agent@<name> | grep ExecStart
systemctl --user restart mosaic-agent@<name>
```
Full recovery runbook and the three-layer #791 protection model (manifest
ownership → pre-update snapshot/restore → regen): see
[Upgrade Safety & Recovery](./upgrade-safety-and-recovery.md).
## Release Preflight
Run this checklist before cutting or dogfooding a fleet release:

View File

@@ -1,147 +0,0 @@
# Upgrade Safety & Recovery
How Mosaic protects operator-owned configuration under `~/.config/mosaic` across
framework upgrades, and how to recover if a projection is ever lost.
A framework upgrade runs `install.sh` in keep-mode (`MOSAIC_INSTALL_MODE=keep`,
`MOSAIC_SYNC_ONLY=1`) to refresh framework-owned files in place. The incident
this hardening addresses: an upgrade that silently overwrites or deletes a file
the operator owns — credentials, personas, a roster, or a generated agent env —
with no snapshot to fall back to.
Protection is layered. Each layer is independent; a later layer catches what an
earlier one misses.
## Layer 1 — Manifest-owned sync (prevention)
The single source of truth for ownership is
[`framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt).
Both the bash installer and the TypeScript sync path resolve every path against
this one file (parity is enforced by test), so they can never drift.
- Ownership is **allow-list, deny-wins**: a path is framework-owned only if a
`[framework]` glob matches and no `[operator]` carve-out overrides it.
- **Unknown paths default to operator** (fail-safe): a file the manifest never
anticipated is treated as operator-owned and is never pruned.
- Keep-mode does a non-deleting copy plus an explicit, manifest-scoped prune that
only ever iterates framework globs — operator and unknown paths are
structurally unreachable by the prune.
Result: a correct upgrade cannot touch operator config at all.
## Layer 2 — Durable pre-update snapshot + verify net (safety + rollback)
Before **any** mutation, the installer snapshots the operator-owned surface that
exists into:
```
${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-timestamp>/
```
- `0700` directories / `0600` files (`umask 077`, scoped and restored),
outside `~/.config/mosaic` and outside any repo.
- **Fail-open**: a snapshot failure warns but never aborts the upgrade it
protects.
- Retention is `MOSAIC_BACKUP_RETENTION` snapshots (default 5).
After the sync, a **verify net** compares each snapshot file against its target
and restores (with a loud warning) any operator file the upgrade diverged or
removed — a divergence means a manifest bug slipped through Layer 1.
Inspect and restore snapshots with the CLI:
```bash
mosaic restore --list # dry-run: enumerate snapshots by timestamp
mosaic restore --from <UTC-timestamp> # restore the operator surface from one snapshot
mosaic restore --from <ts> --dry-run # preview a specific restore without writing
```
`mosaic restore` reports **counts and relative paths only** — it never emits file
contents, so a secret in `tools/_lib/credentials.json` is never echoed. Restores
are confirmation-gated (`--yes` or `MOSAIC_ASSUME_YES`) and write each leaf
atomically with `O_NOFOLLOW` (a symlink swapped in after the snapshot fails
closed rather than following out of the managed tree).
## Layer 3 — Regeneration from roster SSOT (recovery)
Some operator files are **derived** and do not need a byte-for-byte snapshot to
recover — they can be rebuilt from their source of truth. The fleet's per-agent
generated env projections are the prime case:
- `~/.config/mosaic/fleet/agents/<name>.env.generated` is a deterministic
projection of `~/.config/mosaic/fleet/roster.yaml`.
- The launcher (`start-agent-session.sh`, invoked by
`mosaic-agent@<name>.service`) sources that generated projection to establish
each agent's identity, runtime, model, and working directory. If it is missing
or wrong, the agent cannot launch with its intended identity.
`mosaic fleet regen` rebuilds those projections from the roster SSOT:
```bash
mosaic fleet regen # dry-run (default): show what would be rebuilt
mosaic fleet regen --json # same, machine-readable
mosaic fleet regen --write # rebuild the projections on disk
```
- **Dry-run by default.** Nothing is written until you pass `--write`.
- **Deterministic and idempotent** — the projection is a pure function of the
roster, so repeated `--write` runs produce byte-identical files.
- **Projection-only. It never restarts an agent.** Recovery order forbids
restart-before-verify; `regen` has no path to systemd lifecycle at all.
- **It rebuilds only `<name>.env.generated`** — it never writes, relocates, or
deletes the operator-owned `.env` / `.env.local` surface.
- It **validates the roster the same way `reconcile` does** (persona resolution
and protected-class tool-policy match), so a hand-edited or corrupt roster is
rejected rather than projected, and a `--write` takes the shared reconcile
lock so it cannot race a concurrent reconcile.
- Output is **paths and counts only** — the rendered `KEY=value` body is never
echoed.
`regen` uses the exact same roster→env mapping as `mosaic fleet reconcile`, so a
recovered projection matches what a normal reconcile would have written.
## Recovery runbook — wiped `fleet/agents/*.env.generated`
If an upgrade (or a manual mistake) has left an agent without its generated
projection, **do not restart the unit first** — a launch against a missing
projection fails closed, and any stale state must be corrected before restart,
not after.
1. **Prefer a snapshot restore if one exists** (byte-exact operator state):
```bash
mosaic restore --list
mosaic restore --from <UTC-timestamp>
```
2. **Otherwise regenerate the derived projections from the roster SSOT:**
```bash
mosaic fleet regen # confirm the plan (create vs rebuild per agent)
mosaic fleet regen --write # rebuild fleet/agents/<name>.env.generated
```
3. **Verify each unit will resolve the intended runtime/workdir _before_ any
restart.** The unit sets **no** `EnvironmentFile=` — it launches from a minimal
environment and `start-agent-session.sh` sources `.env.generated` itself, so
verify the generated file directly and confirm the launcher path:
```bash
# Confirm fleet/agents/<name>.env.generated exists and carries the intended
# MOSAIC_AGENT_* values (name, runtime, model, workdir, socket).
test -f ~/.config/mosaic/fleet/agents/<name>.env.generated
# Confirm the unit launches the session script that reads it.
systemctl --user cat mosaic-agent@<name> | grep ExecStart
```
4. **Only then restart, one unit at a time:**
```bash
systemctl --user restart mosaic-agent@<name>
```
## See also
- Design: [`docs/design/791-upgrade-config-protection.md`](../design/791-upgrade-config-protection.md)
- Fleet operations: [`docs/guides/fleet-local-canary.md`](./fleet-local-canary.md)
- Ownership SSOT: [`packages/mosaic/framework/framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt)

View File

@@ -183,8 +183,6 @@ non-interactive use:
--no-auto-launch # Skip auto-launch of wizard after install
```
Unrecognized flags or positional arguments fail before installation starts and print the supported-option usage.
Or if installed globally:
```bash
@@ -309,39 +307,6 @@ mosaic quality-rails
---
### Claude Code Skill Registration
Mosaic stores canonical skills under `~/.config/mosaic/skills/`. Claude Code scans
`~/.claude/skills/`, so Mosaic maintains one symlink per skill between those
directories.
```bash
mosaic skill list
mosaic skill register <name>
mosaic skill unregister <name>
```
- `register` is idempotent and repairs a dangling Mosaic-owned link. Names use
the safe grammar `[A-Za-z0-9][A-Za-z0-9._-]*`; files, directories, foreign
symlinks, path traversal, absolute paths, and names beginning with `-` are
refused.
- `unregister` is idempotent when no entry exists. It removes only symlinks that
point inside `~/.config/mosaic/skills/`; foreign entries are never removed.
- `list` reports `registered`, `unregistered`, `dangling`, `foreign`,
`foreign-dangling`, or `misdirected` for each canonical or Claude entry.
Install, wizard finalization, and `mosaic update` framework re-seeding reconcile
every canonical skill automatically. A skill directory added after initial
setup therefore receives its Claude bridge without a per-skill code change or
manual `ln -s`. If Claude Code is already running, use `/reload-skills` or start
a new session after registration so its in-process skill registry rescans.
This command group is Claude-only in M1. Pi can consume Mosaic's canonical skill
root through its Mosaic launcher configuration and does not need this Claude
bridge. Codex has a separate link path managed by the legacy full skill-sync
script; equivalent lifecycle management remains follow-up scope and is not
changed here.
## Sub-package Commands
Each Mosaic sub-package exposes its full API surface through the `mosaic` CLI.

View File

@@ -1,80 +0,0 @@
# FCM-M4-001 — v1-to-v2 inventory, preview, and migrator
- **Task / issue:** FCM-M4-001 / mosaicstack/stack#758
- **Branch / base:** `feat/758-v1-v2-migrator` from `origin/main` `c1aecfabe97a5dc81a72f44910cd4e626f41863f`
- **Base tree:** `46cdfbcdc1d1ff9c7b8b2b9cf3841086590bf774`
- **Scope:** field-complete inventory, non-mutating preview, canonical v2 migration output, and migration/recovery disposition evidence. All effects use injected fakes or temporary fixtures.
- **Budget:** 35K task estimate is the hard working cap. Keep one card/one PR and prefer focused reuse of the v2 compiler, shared role resolver, generated-env boundary, M1 executable disposition inventory, and reconciler observations.
## Objective
Implement preview-first v1 migration that never infers unresolved classes or lifecycle, preserves observed running/stopped state, quarantines forbidden legacy environment inputs with key-name/SHA-256-only diagnostics, inventories remote/connector/schema-only entries without reconciling them, covers every M1-classified shipped artifact, and emits deterministic recovery disposition evidence for the later M4-002 canary/rollback gate.
## Acceptance mapping
1. Field-by-field v1 inventory and no-mutation preview.
2. Canonical output compiled by `roster-v2.ts` and semantically validated by the existing baseline-plus-`roles.local` resolver.
3. Only approved deterministic aliases; every other noncanonical class requires an explicit disposition.
4. Observed stopped/running maps explicitly to persisted lifecycle; stopped observations never produce running targets.
5. Generated env is regenerated; strict local data is relocated; forbidden keys are quarantine inputs reported only by key name and SHA-256.
6. Remote/connector/schema-only entries are inventory-only and excluded from local reconciliation output.
7. Every shipped M1 example/profile/service preset has executable migration disposition evidence.
8. Deterministic migration/recovery evidence records source, output, exclusions, quarantine, and restore prerequisites without executing a canary or rollback.
## Boundaries
Out of scope: FCM-M4-002 executable canary/rollback and host fixture, #766 communications, #636 commands/channels, live fleet/systemd/tmux/session/migration/deploy/connector/remote/gateway effects, `docs/TASKS.md`, parent issue mutation, commit, push, and PR operations.
## TDD plan
Migration rules and redaction are critical data-mutation/security logic, so tests are written red-first for inventory completeness, explicit class disposition, observed-state preservation, quarantine redaction, inventory-only remote/schema entries, compiler/resolver reuse, artifact coverage, and recovery evidence. Production code follows only after the focused tests fail for the missing behavior.
## Plan
1. Map existing v1 loader, v2 compiler/resolver, env quarantine, reconciler observation, and M1 disposition guard.
2. Add behavior-oriented failing migration tests with temporary fixtures and injected observation/filesystem adapters only.
3. Implement the narrow migration module and CLI boundary without a second resolver or live command runner.
4. Add scoped M4 migration/recovery documentation and executable shipped-artifact evidence.
5. Run focused tests, full package tests, package/root typecheck and lint, formatting, diff checks, and adversarial redaction/no-effect verification.
6. Run independent code/security review, remediate findings, reconstruct the synthetic tree using a temporary index, and stop uncommitted.
## Progress
- Collision checks passed: no local/remote branch, worktree, target path, or open PR owned `feat/758-v1-v2-migrator`.
- Dedicated worktree created at the exact green `origin/main` base.
- Required global/repository guides and FCM requirements/evidence loaded.
- No matching migration skill exists under the configured skill directories; no unrelated skill loaded.
- Added a preview-only CLI and migration module that compile with the existing v2 parser/renderer and validate through the shared persona resolver.
- Added value-free raw-v1 inventory, strict unknown-field/synonym/duplicate detection, inventory-only remote and connector handling, and explicit class/tool-policy decisions.
- Added separate reviewed lifecycle observations with only unambiguous running/stopped mappings.
- Added sanitized, non-mutating environment preflight and recovery evidence explicitly marked non-executable.
- Added executable disposition evidence derived from the exact 13-entry M1 inventory and operator documentation.
- Tightened untrusted decisions/observations to reject unknown keys, invalid types/enums, extra local records, and competing automatic-alias dispositions.
- Remediated independent review findings: v1 runtime/reset defaults are preserved, `~` workdirs expand only at env preflight, malformed/required agent fields fail closed before remote exclusion, and all seven shipped v1 fixtures now execute real previews with explicit evidence.
- Remediated socket and locality authority blockers: socket-only agents stay local; `host == fleetHost` stays local; only `host != fleetHost` is inventory-only; ssh-only, missing reviewed fleet-host identity, and contradictory host/ssh targets block explicitly without lifecycle omission.
- Remediated final exact-tree blockers: a declared v1 root socket cannot be overridden; matching/conflicting socket decisions retain reviewed running evidence; canonical ordering uses a shared locale-independent Unicode code-point comparator; migration evidence preserves all four legacy environment dispositions; backup documentation no longer claims validation that M4-001 does not perform.
- Remediated immutable-review socket-presence blocker: both `socket_name` and `socketName` are field-presence-aware, so explicit empty/default-server declarations remain authoritative and incompatible named decisions block rather than replacing them.
- Remediated the replacement-tree blockers: the shared v2 compiler and reconciler accept an explicit empty socket as literal default-server identity; present-empty holder session, default/agent work directory, runtime reset command, and alias values block rather than defaulting; missing preview inputs emit one stable blocked JSON object with non-zero status. Snake/camel aliases and whitespace-only input have adversarial coverage.
- Remediated the subsequent authority blockers: each present-empty CLI path emits exactly one stable blocked JSON object with exit 1 before file reads, and reconciler `start`/`restart` or desired-state `apply`/`reconcile` fail closed before fixed `mosaic-fleet` systemd services can act on a default-server roster.
- Remediated committed-head review blockers: explicitly declared empty runtime objects use the production v1 `/clear` reset fallback while omitted `pi` retains `/new`; lifecycle observations are sorted by canonical agent name; and bare CLI path flags reach preview validation, emit one stable blocked JSON object with exit 1, and perform zero reads. Built production-CLI subprocess tests cover all three bare flags.
- Remediated late-audit blockers: the documented M4 guard invokes the 13-artifact validator and all seven v1 previews; canonical `~`/`~/...` workdirs remain unchanged in migration evidence and traversal-free forms expand at the shared production projection boundary while ordinary relative and home-relative traversal paths remain rejected; remote inventory and exclusion evidence sort canonically; and every default-server lifecycle-mutating reconciler path fails before observation, projection preparation/application, or fixed-unit effects. Explicit regressions preserve `plan`, `status`, `doctor`, and `verify` as observational default-server commands.
- Remediated exact-tree traversal review: `~/../escape` and `~/src/../../escape` remain unexpanded and fail the unchanged shared `unsafe-path` validation. Both the shared generated-environment boundary and the production v1 environment caller have red-first regressions, preventing earlier caller normalization from bypassing the boundary.
## Verification evidence
- Focused migration/compiler/environment/reconciler/CLI: 8 files, 372 tests passed.
- Documented 13-artifact guard: 1 matching test passed and executed all seven v1 previews.
- Full `@mosaicstack/mosaic`: 59 files, 902 tests passed.
- Workspace build: 23 tasks passed.
- Root typecheck: 42 tasks passed.
- Root lint: 23 tasks passed.
- Root format check and `git diff --check`: passed.
- Built production CLI: canonical `~/src` remains in ready roster/YAML evidence; generated projection preflight succeeds with no blockers; a bare path flag emits one blocked JSON object, exit 1, and no stderr.
- Independent high-effort late-audit review: no blocker remained in the four assigned repair surfaces; separate exact-tree security audits found no qualifying newly introduced vulnerability.
- Exact temporary-index synthetic tree includes every tracked changed path; immutable SHA and duplicate reconstruction are recorded in the final handoff.
## Risks / blockers
- M4-001 emits rollback prerequisites/evidence only; executable rollback/canary and the managed/unmanaged host fixture remain owned by M4-002.
- Remote/connector entries remain inventory-only; later federation or connector reconciliation requires separately reviewed work.
- Existing environment data is only preflighted. Cutover backup, quarantine write, legacy removal, and generated projection application remain later reviewed effects.

View File

@@ -1,529 +0,0 @@
# Scratchpad — #791 Upgrade config protection (ms-791 worker lane)
**Lane:** web1:ms-791 → reports to MS-LEAD (web1:mosaic-100). Do NOT contact Jason/Mos directly.
**Worktree:** `/home/hermes/agent-work/stack-agents-dir-791`, branch `feat/791-upgrade-config-protection`
off `origin/main` `9745bc3f` (verified exact head).
## Mission prompt (verbatim intent)
Protect operator-owned config under `~/.config/mosaic` from framework-upgrade wipes. Ratified
combination (Mos-approved, do NOT re-litigate): (b) strict ownership separation [PRIMARY] + (a)
transactional pre-update snapshot [safety net] + (d) regeneration-from-SSOT [recovery]. (c) periodic
timer DEFERRED. HARD GATE: unit test that an upgrade run touches NO path outside the manifest.
Design-first: write design doc, send to MS-LEAD, WAIT for confirmation before impl.
## Session 1 (2026-07-16) — Phase 1 design
### Evidence gathered (wipe mechanism, file/line)
- `mosaic update``update-checker.ts:509` `buildReseedCommand``bash install.sh`
(`MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`).
- Wipe = `packages/mosaic/framework/install.sh:199` `rsync -a --delete` + `PRESERVE_PATHS` denylist
(`install.sh:47`). cp-fallback `install.sh:223` `find ... -exec rm -rf`.
- Denylist gaps → WIPED: `agents/*.conf`, `policy/*.md`, `*.local.md`, harvester/SOP,
`tools/_lib/credentials.json`.
- Stale comment `update-checker.ts:492` claims `*.local` preserved — PRESERVE_PATHS has no such entry.
- TS path `file-adapter.ts:157``file-ops.ts:66` `syncDirectory` = non-destructive copy-overlay, BUT
its preserve list (`file-adapter.ts:164`) already DRIFTED from install.sh (missing `fleet/backlog`,
`fleet/roles.local`). Evidence for single shared manifest SSOT.
- Existing snapshot (`install.sh:76`) = /tmp, crash-trap only, deleted on success → inadequate; no
`mosaic restore`.
- `fleet-reconciler.ts:93,234` already has `regenerate-projections-from-roster` phase separate from
lifecycle → `mosaic fleet regen` = thin projection-only wrapper (no restart), no FCM-M4/M5 preemption.
### Design decisions
- **(b)** Invert to allow-list: shared `framework/framework-manifest.json` (framework globs +
operatorReserved carve-outs); resolve per-path, deny-wins; **UNKNOWN ⇒ operator (fail-safe)**.
Mechanism: drop `--delete`; non-deleting bulk copy + explicit manifest-scoped prune pass (iterate
framework globs only → operator/unknown structurally unreachable). Pure prune-planner fn for tests.
- **(a)** Snapshot to `~/.local/state/mosaic/backups/pre-update-<ts>/` 0700/0600, retention N=5,
post-sync verify+restore, `mosaic restore --list/--from`. No secret values in output.
- **(d)** `mosaic fleet regen` projection-only, preview-first, never restart.
- HARD GATE test includes a deliberately-unanticipated operator path to prove fail-safe default.
- **PR split:** PR1 manifest+guard (root fix, ships alone) → PR2 snapshot/restore (secrev) → PR3
regen+docs. PR2/PR3 depend on PR1.
### Status
Design doc written: `docs/design/791-upgrade-config-protection.md`. Sent to MS-LEAD.
## Session 1 (cont.) — MS-LEAD CONFIRMED → Phase 2 GO
All 4 asks approved. Binding conditions:
- TDD tests-first, red-first proof per PR; ≥85% new-code; co-located `*.spec.ts`; never `--no-verify`.
- HARD GATE test (§2.4, unanticipated sentinel survives byte-identical + mtime unchanged) = MERGE-BLOCKING for PR1.
- Manifest-completeness test (§6.2) required.
- Bash+TS read ONE shared `framework-manifest.json`; parity test (§6.1) required (closes #631 drift class).
- UNKNOWN⇒operator (rule 3) non-negotiable. Keep prune-planner PURE.
- `fleet regen`: NEVER restart; dry-run default, `--write` to apply; "never issues restart" test mandatory.
- Independent review every PR; PR2 dedicated secrev.
- One PR at a time through DAG. Report PR1 exact head + red→green evidence for review commission.
### Now: implementing PR1 (manifest + resolver + non-deleting sync + scoped prune + guard tests).
## Session 2 (2026-07-16) — PR1 built, tests-first, red→green proven
Deviation noted to MS-LEAD in PR: manifest is `framework-manifest.txt` (line-oriented), NOT `.json`.
Rationale: keep the bash installer free of a python3/jq dependency. The "ONE shared file, parity-
tested" requirement is honored — `manifest-parity.spec.ts` drives the bash resolver as a subprocess
and asserts byte-identical ownership vs the TS resolver over 34 probe paths spanning every class.
### PR1 artifacts
- SSOT: `packages/mosaic/framework/framework-manifest.txt` ([framework]/[operator], deny-wins, fail-safe).
- TS resolver: `src/framework/manifest.ts` (pure: parse/matchGlob/resolveOwnership/frameworkSubtreeRoots/
planPrune) + `manifest.spec.ts` (18 tests incl. planPrune property test + §6.2 completeness).
- Bash resolver: `framework/tools/_lib/manifest.sh` (compiled globs → fork-free `manifest_is_framework`;
CLI `resolve|subtree-roots|classify`). Sourced by install.sh.
- HARD GATE (§2.4): `framework/tools/quality/scripts/test-upgrade-manifest-guard.sh` — keep-mode reseed,
10 operator sentinels (incl. unanticipated `unknown-operator-dir/x`, `harvester/sop.md`,
`fleet/my-fleet.yaml`) survive byte-identical + mtime-unchanged; retired framework file pruned;
secret value absent from output. RED=31 fail (orig install.sh) → GREEN=48 pass (fixed).
- install.sh: keep mode now manifest-driven (`sync_framework_keep`, no `--delete`); overwrite unchanged.
PRESERVE_PATHS denylist deleted.
- TS sync: `file-ops.syncDirectory` gains `isOperatorOwned` guard; `file-adapter.syncFramework` derives
it from `loadManifest` — hardcoded (drifted) preservePaths deleted. Fixture uses the REAL manifest.
- Parity: `manifest-parity.spec.ts` (§6.1) — bash↔TS agree on 34 paths + subtree roots.
- Migration matrix `test-install-migration.sh`: F6 flipped — `my-fleet.yaml` now MUST survive (fail-safe).
- CI: new merge-blocking `upgrade-guard` step (`.woodpecker/ci.yml`) runs both bash suites (adds rsync).
- update-checker.ts reseed comment corrected to the manifest model.
### Gates (all green)
- `pnpm typecheck` ✓ · `pnpm lint` ✓ · `pnpm format:check`
- Full mosaic vitest: 1062 passed (cli-smoke needs `pnpm build` first — build-artifact dep, not this change).
- HARD GATE 48/48 · migration 21/21 · parity 3/3 · manifest 18/18 · file-adapter 8/8.
### PR opened + reported (2026-07-16)
- **PR #802** http://git.mosaicstack.dev/mosaicstack/stack/pulls/802 — base `main`@`9745bc3f`,
head `34e55d4a` (commit `feat(mosaic): manifest-owned upgrade guard…`). 15 files, +1160/-142.
- Reported PR head + red→green evidence to MS-LEAD (web1:mosaic-100); queued (lead busy).
Standing by for the independent-review commission at head `34e55d4a`.
- **TWO items flagged to MS-LEAD for decision (awaiting reply):**
1. Deviation `.txt` vs `.json` — confirm accept (parity-tested) or convert to `.json`+jq.
2. `pr-create -i 791` appended `Fixes #791` → would auto-close the tracking issue on PR1 merge
while PR2/PR3 remain. Recommended edit to `Part of #791`; awaiting go-ahead to patch PR body.
- DO NOT start PR2/PR3 until PR1 merges (DAG; one PR at a time).
### MS-LEAD ruling → #797 ledger-survival sentinel folded into PR1 (2026-07-16)
MS-LEAD ruled both my decisions: (1) `.txt` format ACCEPTED (parity must be strict/merge-blocking incl.
format edge cases + negative probe); (2) trailer `Fixes #791``Part of #791` APPROVED (patched PR #802
body via Gitea API — tracking issue no longer auto-closes on PR1 merge). Plus Mos-ELEVATED merge-blocker
(spec `~/agent-work/planning/epic-796/791-ledger-survival-sentinel-SPEC.md`): #797 Runtime Session Ledger
must survive upgrade. Two coupled deliverables landed in PR1:
- (i) Carve-out: `fleet/run/**` was ALREADY an explicit `[operator]` entry — glob matches the spec's
pinned `fleet/run/**` EXACTLY, so NO divergence to route back to planner-opus. Strengthened its comment
to name the ledger (`fleet/run/sessions/` events.ndjson + ledger.json) so it is unmistakably load-bearing.
- (ii) HARD-GATE sentinel: seeded populated ledger (events.ndjson 3 events + ledger.json node+edge+gen,
0600 under 0700) into test-upgrade-manifest-guard.sh sentinels; asserts byte-identical + mtime-unchanged
+ dir-perms unchanged. Negative control (retired framework file IS pruned) relabeled explicitly.
HARD GATE now 58/58 (was 48).
- Decision-1 parity hardening: format-edge fixtures (comments/blanks/whitespace, duplicate+overlapping
globs deny-wins, section/glob-ordering independence) + explicit UNKNOWN→operator negative probe, driven
through BOTH resolvers via MANIFEST_FILE override. Parity 7/7 (was 3).
- RED-FIRST honesty note: the bash ledger sentinel stays GREEN even against the pre-fix installer (the
ledger was incidentally safe from the rsync --delete bug; overall pre-fix run 30/58 as expected). The
carve-out's TRUE load-bearing value (deny-wins if framework ownership ever broadens to `fleet/**`) is
isolated by a dedicated resolver-seam red→green in manifest.spec.ts: WITHOUT `fleet/run/**` operator
entry + hypothetical `fleet/**` framework → ledger resolves framework and planPrune DELETES it (RED);
WITH the carve-out → deny-wins → operator, unprunable (GREEN). manifest.spec.ts 21/21 (was 18).
- Gates all green: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1069 passed · HARD GATE 58/58
· migration 21/21. Committing FORWARD on the branch (NOT rebasing 34e55d4a out from under review).
### MS-LEAD REQUEST CHANGES @ 0a5e703a → B1/B2/B3 fixed red-first (2026-07-16)
MS-LEAD returned REQUEST CHANGES (routed merge-blockers satisfied; 2 CRITICAL reliability defects from
the commissioned independent review). Fixed forward on the branch, red-first:
- **B1 (CRITICAL) — dead ERR trap.** install.sh had `set -euo pipefail` (no `-E`), so the
`trap restore_snapshot ERR` never fired for a failure inside sync_framework_keep() (function body) —
a mid-sync abort left a half-written target with NO rollback. Fix: `set -Eeuo pipefail` (errtrace) +
disarm the trap at the top of restore_snapshot() to prevent re-entrancy. New gate
`test-upgrade-rollback.sh`: injects a mid-sync `cp` EACCES (read-only divergent framework file);
Part A asserts the shipped installer rolls back (restore message fires AND target byte-identical to
pre-upgrade); Part B control strips `-E` and asserts the rollback message does NOT fire (dead trap) —
self-verifying red→green. 7/7.
- **B2/B3 (CRITICAL) — empty/unreadable/malformed manifest divergence.** Pre-fix: TS `parseManifest('')`
returned `{framework:[],operator:[]}` (NO throw) → silent no-op "Installation complete"; bash aborted
fragilely (the `_manifest_compile` `"${MANIFEST_OPERATOR[@]:-}"` artifact returned 1 with no message)
AND the CLI dispatch swallowed manifest_load's rc (no `|| exit`) so `resolve` exited 0 resolving
everything operator. Fix (fail-loud + identical both langs):
* TS `parseManifest`: throw on zero framework entries; `loadManifest`: wrap read error →
"Cannot read framework manifest …".
* bash `manifest_load`: explicit unreadable guard (`[[ ! -r ]]`) + zero-`[framework]` guard, both loud
stderr + return 1; `_manifest_compile` gets explicit `return 0` (kills the empty-array artifact);
CLI dispatch `manifest_load … || exit 1`.
* `finalize.ts`: wrap syncFramework → `spin.stop('Framework sync aborted …')` + rethrow (never falls
through to "Installation complete").
Tests: manifest.spec.ts +5 fail-closed (empty/comment-only/operator-only/empty-section/missing);
manifest-parity.spec.ts +7 failure-mode parity (both reject empty/comment-only/operator-only/
empty-section/entry-before-header/unknown-header/missing — TS throws, bash CLI exits non-zero+stderr);
HARD GATE +4 end-to-end fail-closed matrices (empty/operator-only/malformed/missing → abort non-zero,
manifest error surfaced, every operator sentinel byte-identical). RED proven by reverting
manifest.ts+manifest.sh to HEAD → 12 new tests fail; restore → 40/40 green.
- **Non-blocking addressed.** MEDIUM install.sh:222 find-empty now warns on a real failure instead of
blanket `|| true`. LOW: corrected the "both destructive paths rsync vs cp" overstatement in the HARD
GATE header + cp-fallback comment + ci.yml (keep mode is a single cp-based path; the rsync-present vs
-absent runs prove rsync-independence). `.pre-constitution.bak` triage: single-shot backup is
intentional (reconcile_framework_files backs up once), no change.
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1081 passed (was 1069, +12) · HARD GATE
118/118 (was 58) · rollback 7/7 (new) · migration 21/21. No --no-verify. Rollback test wired into
ci.yml upgrade-guard. Committing FORWARD (no rebase of 34e55d4a/0a5e703a).
### Codex round 2 (pre-push self-review) → blockers A/B + should-fix C fixed red-first (2026-07-16)
Before committing round 1 I re-ran codex on the change set; it surfaced two fresh reliability defects
and one messaging defect on the SAME rollback/manifest path. Fixed forward, red-first:
- **Blocker-A (CRITICAL) — signal trap resumed instead of terminating.** A bash INT/TERM handler that
merely `restore_snapshot` (returns) does NOT terminate the script — execution RESUMES past the
interrupt, cleans the snapshot and reports success, leaving a partial post-interrupt update. Fix:
`trap 'restore_snapshot; exit 1' ERR INT TERM` so both the errtrace (ERR) and signal (INT/TERM) paths
exit non-zero. Rollback test Part C: a `cp` shim that `kill -TERM $PPID` mid-sync then succeeds (so
set -e never fires and only the signal path governs) → asserts abort non-zero + restore fires + does
NOT print "file phase complete"; control strips `exit 1` and asserts the buggy resume-to-success.
- **Blocker-B (CRITICAL) — degenerate `[framework]` section resolved everything operator.** A manifest
whose framework entries are all empty / bare-dot (`/`, `./`, `.`, `..`) passed the non-empty guard yet
yielded zero usable globs → nothing is framework → a keep-mode sync silently no-ops (bash resolved
`operator`, exit 0). Fix (both langs, parity): reject when no entry has a char other than `/`/`.`
TS `isUsableFrameworkGlob` = `/[^/.]/.test(normalizeRel(glob))`, throws `ManifestError`; bash mirror
loops `[[ "$(_manifest_norm "$_g")" =~ [^/.] ]]`, loud stderr + return 1. Tests: manifest.spec.ts
`it.each(['/','./','.','..','/\n./'])` throw; parity +3 `expectBothReject` (root-slash/dot-slash/
bare-dot). RED: reverting the guard makes `[framework]\n/` resolve `operator` exit 0.
- **Should-fix-C — misleading abort message.** finalize.ts printed one generic "may be partially
applied" for every sync failure. A `ManifestError` is a PRE-sync validation abort (manifest is
validated before any copy) → nothing was written; conflating it with a mid-copy failure misdirects
recovery. Fix: introduce `ManifestError` (exported from manifest.ts, thrown by every fail-closed
parse/load path), and classify in finalize.ts — ManifestError → "no files were changed"; any other →
"may be partially applied". New co-located `finalize-sync-abort.spec.ts` (3 tests) asserts both
branches re-throw the original error + the correct message, and that config writes are never reached.
RED proven by collapsing the classification → the ManifestError test fails.
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 (was 1081, +3 finalize-abort;
manifest specs already counted) · HARD GATE 193/193 · rollback 14/14 · migration 21/21.
### Codex round 3 (pre-push self-review) → blockers D1/D2 fixed red-first (2026-07-16)
Re-ran codex again; it found two more rollback-path gaps `set -E` cannot catch. Fixed forward, red-first:
- **Blocker-D1 (CRITICAL) — `find` scan failures swallowed by process substitution.** Both the overlay
copy and the scoped prune consumed `< <(find … -print0)`. Bash does NOT propagate the producer's exit
status to the `while`, so an EACCES/I/O failure mid-scan truncates the file list yet leaves the loop
exiting 0 → a partial upgrade commits and reports success; the ERR/restore trap never fires. Fix:
`_scan_or_die` runs `find … -print0 > "$tmp"` to completion, checks its status, and returns non-zero
(→ ERR trap → restore) on failure; both loops now read from the checked temp file. Rollback test
Part D: a `find` shim that fails every `-print0` scan → shipped installer aborts non-zero + restores +
emits "Could not enumerate framework files" + target byte-identical; control neuters the `# D1-GUARD`
`return 1` → find failure swallowed, upgrade wrongly reports "file phase complete", no rollback.
- **Blocker-D2 (CRITICAL) — silent `set -e` exit on a failed target reset.** restore_snapshot did a bare
`rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"` (trap disarmed, under set -e). If `rm`/`mkdir` fails —
possibly after `rm` deleted part of the target — the script exits immediately, skipping the cp AND the
recovery pointer, leaving a half-removed target and an orphaned snapshot the operator can't locate.
Fix: `if ! rm -rf … || ! mkdir -p …; then fail "Snapshot restore could not reset … preserved at:
$SNAPSHOT_DIR — copy it back …"; return 1; fi` (tested like the cp -a check; snapshot NOT deleted).
Rollback test Part E: cp-poison triggers restore + an `rm` shim fails `rm -rf <TARGET>` → shipped
emits the recovery pointer, the named snapshot dir survives, secret value never leaked; control deletes
the recovery line → operator gets no pointer. RED: reverting D1+D2 → 7 shipped/control assertions fail.
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 · HARD GATE 193/193 ·
rollback 28/28 (was 14, +14 for D1/D2 with controls) · migration 21/21. shellcheck clean on new lines.
No --no-verify. Committing FORWARD (no rebase of 34e55d4a/0a5e703a).
## Session 3 (2026-07-16) — PR1 MERGED, starting PR2 (durable snapshot + restore + secrev)
PR1 (#802) squash-merged → main `32a0ffba`; issue #791 stays open (3-PR DAG umbrella). Independent Opus
adversarial/security review APPROVED at head `af627e75` (Gitea RoR cmt 17892); lead ran rollback 28/28 +
HARD GATE 193/193 green; CI #1877 green. PR2 UNBLOCKED.
PR2 branch: `feat/791-pr2-snapshot-restore` off `origin/main` 32a0ffba. Same treatment applies:
tests-first red-first, independent review + durable Gitea Reviewer-of-Record comment BEFORE MS-LEAD runs
the queue guard/merge. Report PR2 number + exact head when ready. PR body: `Part of #791` (NOT Fixes).
### PR2 scope (ratified §3/§5 of design doc, Mos-approved — do NOT re-litigate)
- **(a) Durable pre-update snapshot** to `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/`
— OUTSIDE ~/.config/mosaic and any repo. Perms dir 0700 / files 0600 (umask 077 + explicit chmod).
Scope = operator-owned surface that EXISTS (operatorReserved paths), not the framework tree. Taken
BEFORE any mutation. Retention N=5 (`MOSAIC_BACKUP_RETENTION`), prune older.
- **Post-sync verify + selective restore**: diff operator surface vs snapshot; (b) should never touch
operator paths, so ANY diff = manifest bug → restore affected paths + warn loudly. (a) catches a (b) miss.
- **`mosaic restore`** (TS CLI): `--list` (default, dry-run) enumerates snapshots by ts; `--from <ts>`
restores over operator surface, confirmation-gated. Counts/paths only.
- **Secret-safety (secrev)**: snapshot/restore NEVER emit file contents; only paths/counts. Tests assert
0700/0600 AND that a secret value seeded in tools/_lib/credentials.json never appears in any output.
### PR2 implementation status (2026-07-16, ready-for-review)
All three tasks implemented, red-first proven, unit-green:
- **Task #10 — durable snapshot (install.sh)**: `backup_root()`/`enumerate_operator_files()`/
`prune_durable_snapshots()`/`make_durable_snapshot()` wired into keep-mode main() after `manifest_load`,
before any mutation. umask 077 + explicit chmod 700/600. UTC ts, collision suffix. FAIL-OPEN (a backup
failure never aborts the upgrade it protects). Retention `MOSAIC_BACKUP_RETENTION` (default 5), in-place
`sort -r -o` prune (no `mv` — stays inside the rsync-absent coreutils whitelist).
- **Task #11 — post-sync verify net (install.sh)**: `verify_operator_surface()` runs after sync (trap
disarmed), `cmp -s` each snapshot file vs target; restores any diverged/missing operator file + warns
loudly (a divergence = manifest bug). VERIFY-NET wired before `cleanup_snapshot`.
- **Task #12`mosaic restore` (TS)**: `src/commands/restore.ts` + co-located spec (19 tests).
`--list` default (dry-run enumerate), `--from <ts>` confirmation-gated restore, `--dry-run`, `--yes`/
`MOSAIC_ASSUME_YES`. Injectable `confirm` for testability (proceed/decline/env-bypass covered). Restored
files forced 0600. Registered in `cli.ts`. Path convention mirrors install.sh `backup_root()`.
- **CI**: `.woodpecker/ci.yml` upgrade-guard runs the new `test-upgrade-durable-snapshot.sh` gate.
- **Gates green**: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1241 (+5) ·
durable-snapshot 26/26 · manifest-guard 193/193 · rollback 28/28 · migration 21/21.
Est. new-code coverage ≈93% (only the interactive readline default + process.exit-on-error uncovered).
- Regression fixed: PR2's `date`/`sort`/`mv` broke the rsync-absent manifest-guard PATH whitelist →
made date/sort fail-open, replaced `mv` with in-place `sort -o`, added `date sort` to the test whitelist
+ isolated `XDG_STATE_HOME`. All 193 manifest-guard assertions green under restricted PATH.
- Codex code-review + security-review (secrev) run on the uncommitted diff before commit.
### PR2 review round 1 — findings + remediations (2026-07-16, pre-PR)
Codex code-review returned **request-changes** (1 blocker + 3 should-fix); Codex security-review returned
**high** (1 high + 1 medium). Deduped to 5 distinct defects, ALL legitimate, ALL fixed FORWARD, each with
a red-first regression test whose control neuters exactly the guard under test:
- **A · BLOCKER — verify net undid the legacy bin/ migration (install.sh).** On a pre-v2 install `bin/**`
is operator-classified, so the durable snapshot captured it; `run_migrations()` deletes bin/ on purpose,
but `verify_operator_surface()` then saw it "missing" and healed it back — the migration would be silently
undone forever once the version stamps. **Fix:** `MIGRATION_REMOVED_PATHS[]` recorded by run_migrations
(`bin`,`rails`) + `is_migration_removed()` skip in the verify loop (`# MIGRATION-SKIP-GUARD`).
**Test:** Part 6 — v1 fixture with bin/; shipped keeps it removed + stamps v3; control (guard stripped)
wrongly restores bin/tool.sh.
- **B · HIGH (CWE-59) — restore/verify wrote secrets THROUGH a symlink (install.sh + restore.ts).** An
attacker swapping an operator path (e.g. tools/_lib/credentials.json) for a symlink after the snapshot
would make `cp`/`copyFileSync` write the snapshot's secret out through the link. **Fix (bash):** refuse a
symlinked ancestor (`has_symlinked_parent`), drop a symlinked leaf before restore
(`# SYMLINK-LEAF-GUARD`). **Fix (TS):** reuse audited `secure-file.ts``assertCanonicalContainment`
+ `ensureManagedDirectory` on every dst, open the leaf `O_NOFOLLOW|O_CREAT|O_TRUNC` 0600 (ELOOP =
fail-closed). **Tests:** Part 7 (shipped leaves external exfil target untouched, restores a real 0600
file; control leaks the secret through the link) + restore.spec symlinked-leaf/ancestor cases (red-first).
- **C · MEDIUM/should-fix (CWE-22) — `--from` traversal escaped the backup root (restore.ts).**
`join(root, from)` accepted `../poison`. **Fix:** validate the selector against
`^\d{8}T\d{6}Z(?:-\d+)?$`, build exactly `join(root,'pre-update-'+ts)`, `lstat` (reject symlinked snap
dir). **Test:** restore.spec `it.each` of 6 malformed selectors + `--from ../poison` fail-closed (red-first).
- **D · should-fix — verify `mkdir -p` unguarded under set -e (install.sh).** A parent replaced by a
regular file aborted the installer before the recovery pointer printed. **Fix:** guard `mkdir -p`, warn
+ `continue` on failure (keeps healing remaining files).
- **E · should-fix — snapshot `umask 077` leaked process-global (install.sh).** Later sync copies/dirs
inherited 0600/0700. **Fix:** save `old_umask`, restore on EVERY return path (`# UMASK-RESTORE-NORMAL`).
**Test:** Part 8 — synced framework file is 0644 while the secret backup stays 0600; control (restore
stripped) makes the synced file 0600.
**Full gate suite re-run after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic
vitest **1252** · restore.spec **30** · durable-snapshot **41** · manifest-guard 193 · rollback 28 ·
migration 21. shellcheck clean on all new lines; new test markers mirror the existing `# VERIFY-NET`
anchor convention. NOTE: codex self-review does NOT satisfy the independent-review gate — an independent
(author≠reviewer) review + durable Gitea Reviewer-of-Record comment is still required before MS-LEAD merges.
## Session 4 (2026-07-16) — PR2 MERGED, PR3 built (fleet regen — recovery layer)
PR2 (#811) squash-merged → main `31607a4a`; issue #791 stays open (final PR of the 3-PR DAG). Independent
exact-head RoR at `d12c5f78` APPROVE (Gitea cmt 17904); #1882 green; busybox-portable Part 7 control fix
verified in-Alpine. PR3 UNBLOCKED.
PR3 branch: `feat/791-pr3-fleet-regen` off `origin/main` 31607a4. Same discipline: tests-first red-first,
independent review + durable Gitea RoR BEFORE MS-LEAD runs the queue guard/merge. PR body `Part of #791`.
### PR3 scope (ratified §4/§7 of design doc) — `mosaic fleet regen`
Projection-only recovery command: rebuilds each `fleet/agents/<name>.env.generated` from `roster.yaml`
(SSOT). Dry-run default; `--write` applies; `--json` machine output. Structural guarantee: NO code path to
systemd lifecycle — **never restarts an agent**. Single-SSOT: reuses `projectRosterV2AgentGeneratedEnv`
(extracted, shared with the reconciler apply path) so regen and reconcile cannot drift. Secrev: paths +
counts only, never the rendered KEY=value body.
New files: `commands/fleet-regen-command.ts` (+ `.spec.ts`), guide `docs/guides/upgrade-safety-and-recovery.md`
(three-layer model: PR1 manifest ownership → PR2 snapshot/restore → PR3 regen; do-NOT-restart-before-verify
runbook), regen reference added to `docs/guides/fleet-local-canary.md`. Wired in `commands/fleet.ts`.
### Independent review (3 reviewers: subagent code-reviewer + codex code-review + codex security) → 4 fixes, red-first
- **A · BLOCKER (codex) — regen mutated/deleted legacy operator env.** `applyPreparedAgentEnvironmentProjection`
also writes `.env.local`/`.env.quarantine` and unlinks legacy `.env`. Violated projection-only contract.
**Fix:** NEW generated-only boundary primitives `prepareGeneratedAgentEnvironmentProjection` +
`applyPreparedGeneratedAgentEnvironmentProjection` (write ONLY `<name>.env.generated`). regen now has no
code path that touches `.env`/`.env.local`/`.env.quarantine`. **Test:** projection-only leaves legacy `.env`
verbatim, no local/quarantine fabricated.
- **B · should-fix (codex + subagent + security) — partial write on mid-loop failure.** Interleaved
prepare/apply left earlier agents written when a later agent failed prepare. **Fix:** PREPARE ALL agents
before writing ANY (mirrors reconciler `defaultPrepareProjections`). **Test:** 2nd agent's projection
pre-seeded 0644 → prepare rejects → coder0 NOT written, exit 1.
- **C · subagent — semantic-validation bypass.** Default readRoster skipped `validateRosterV2Semantics`, so
a tampered protected-class `tool_policy` would be silently projected. **Fix:** default readRoster now runs
`validateRosterV2Semantics` (persona resolution + protected-class match), rolesDir/overrideDir defaults
mirroring the reconciler. **Test:** merge-gate agent w/ tool_policy=code → fails closed, no write.
- **D · MEDIUM (codex security, CWE-362) — concurrent-reconcile race.** regen `--write` wrote without the
reconcile lock. **Fix:** `--write` acquires `acquirePrivateReconcileLock(mosaicHome)` for the whole
read-prepare-apply sequence, released in `finally`; dry-run stays lock-free. **Test:** pre-held lock →
regen fails closed, no write.
**Gate suite after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1265**
(regen spec 13, incl. 4 new red-first regressions). NOTE: codex self-review does NOT satisfy the
independent-review gate — an independent (author≠reviewer) review + durable Gitea RoR is still required
before MS-LEAD merges. STOP at PR-open for MS-LEAD's exact-head review; do NOT self-merge.
## Session 5 — PR3 review round 2 (finding L + M1/M2/M3), red-first fixes
Second review pass on the lock-cleanup plumbing surfaced one round-1 residual (L) and three round-2
findings (M1 blocker, M2/M3 should-fix). All fixed red-first (RED proven per-finding, then GREEN).
- **L · should-fix (codex r1) — mutation-lock release swallowed unlink failures.** regen's
`acquirePrivateRosterMutationLock` release copied CRUD's `unlink().catch(()=>{})`, hiding a stale
`roster.yaml.mutation.lock`. **Fix:** its release PROPAGATES the unlink fault (finding-J stale-lock
warning then fires for this lock too). **Test:** acquire real lock, `rm` it, assert `release()` rejects.
- **M1 · BLOCKER (codex r2) — replacement-lock race.** The propagating release from L did an
UNCONDITIONAL `unlink(lockPath)` without proving ownership. If the lock is cleared + re-created by
another writer mid-op, regen deletes the STRANGER's live lock → a third writer enters → mutual
exclusion defeated. **Fix (reuse, not reimplement):** generalized the reconciler's ownership-proving
lock body into shared `acquirePrivateManagedRosterLock(mosaicHome, lockLeaf, busyMessage, openLock)`;
`acquirePrivateReconcileLock` delegates to it (behavior-identical: same leaf/codes/messages), and a NEW
hardened `acquirePrivateRosterMutationLock` (now in fleet-reconciler.ts, leaf `roster.yaml.mutation.lock`)
records dev/ino + ownership token and RE-PROVES ownership (`assertLockOwnership`) before unlinking —
fails closed as `lock-cleanup-failed` if replaced. Removed the crud-based export; reverted
`acquireMutationLock` (fleet-agent-crud.ts) to its original inline empty-file/swallowing-release form
(CRUD behavior intentionally unchanged). Compatibility: CRUD empty-file `wx` and regen tokened `wx`
contend on the same path but never co-own (wx winner owns; loser → concurrent-mutation), so the token
is only ever read back by the same regen invocation. **Test:** acquire, `rm`+recreate lock (new inode),
assert `release()` rejects AND the replacement survives (not unlinked).
- **M2 · should-fix (codex r2) — acquire-unwind fault dropped.** The acquire-failure catch discarded
`releaseFleetLocks`' return (a possible fault on the already-held first lock). **Fix:** capture and
augment — `const releaseFault = await releaseFleetLocks(releases); throw augmentWithLockCleanupFault(error, releaseFault);`
(symmetric to finding J). **Test:** mutation lock acquires w/ faulting release + reconcile acquire
throws → thrown error mentions stale/lock, nothing written.
- **M3 · should-fix (codex r2 + subagent REQUEST-CHANGES) — cleanup warning named only reconcile lock.**
Finding L made the mutation-lock release fault reachable, so the `cleanup` marker can originate from
EITHER lock. **Fix:** `formatFleetRegenReport`'s WARNING now names BOTH `roster.yaml.mutation.lock` and
`roster.yaml.reconcile.lock`, matching `augmentWithLockCleanupFault`. **Test:** fault the mutation-lock
release specifically → report names both lock files.
**Refactor note (no cycle):** neither fleet-reconciler nor fleet-agent-crud imports the other; regen
imports lock acquirers from fleet-reconciler and the projection mapping from fleet-reconciler. The two
reconcile-lock reviewers reconciled: independent reviewer validated acquire-time empty-file compatibility
(preserved), codex flagged RELEASE-time replacement race (closed by ownership proof) — non-contradictory.
**Gate suite after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1275**
(regen spec 23, incl. 7 red-first lock regressions E/F/G/K/L/M1/M2/M3). RED proven per-finding by
temporary revert before re-applying each fix. Independent (author≠reviewer) review of M1/M2/M3 + codex
code/security re-run in flight. STOP at PR-open for MS-LEAD's exact-head review + durable Gitea RoR; do
NOT self-merge; #791 umbrella stays OPEN; PR body `Part of #791`.
### Round 3 review (after M1/M2/M3) — independent review PASS + codex residual-TOCTOU disposition
Three reviewers on the post-M1/M2/M3 head:
- **Independent (subagent, author≠reviewer) — PASS.** Verified M1/M2/M3 all correctly fixed; "never
restarts" is STRUCTURAL (runner never referenced in executable code); no secrets; no deadlock (only
regen holds both locks); tests meaningful (assert inode preservation + exact lock-file names). Raised:
- **should-fix #1 (fixed, red-first):** generalizing the lock helper left `assertSafeLockLeafIfPresent`/
`assertLockOwnership` hardcoding "reconciliation lock" in thrown messages → a MUTATION-lock fault
misreported as the reconcile lock, undercutting M3's accurate-diagnosis goal. **Fix:** thread
`lockLabel = fleet/<leaf>` through both helpers + the generic lock-io messages, so every fault names
the actual lock file. Red-first: strengthened the M1 test to assert `/roster\.yaml\.mutation\.lock/`
(RED: got "reconciliation lock"; GREEN after). Also resolves nit #3 (generic-message drift).
- **nit #2 (fixed):** `FleetRegenResult.cleanup` JSDoc still said "the shared reconcile lock"; now names
both locks (regen holds both).
- **nit #4 (fixed):** removed the redundant duplicate `assertLockOwnership` call before unlink
(pre-existing in merged main; harmless but dead — dropped since the fn was already being touched).
- **Codex security — clean (risk: none).** Validates roster semantics, constrains env values, no shell
eval, no secret output, generated-only writes, serialized against both locks.
- **Codex code — request-changes, 1 "blocker": residual check-then-unlink TOCTOU.** Between the final
`assertLockOwnership` and the path-based `unlink`, an external actor could vacate our inode and a new
writer grab the path, so the unlink deletes the stranger's lock. **Disposition: documented known
limitation, NOT fixed in PR3.** Rationale: (1) byte-identical to the MERGED, shipped reconcile-lock
release on origin/main (fleet-reconciler.ts L654-659) — not introduced here; (2) UNREACHABLE within the
`wx` writer protocol — no Mosaic writer removes a lock it doesn't own (wx fails EEXIST while our inode
exists), so only external interference can vacate our inode in the sub-instruction window; (3) the
ownership guard DOES close the reachable case (stale-lock reaper/operator cleared our lock + another
writer took it BEFORE release began → fail closed, don't delete stranger's lock); (4) the true atomic
fix — fd-held advisory lock (flock/lockf) adopted by ALL fleet writers (CRUD + reconcile + regen) — is
a cross-cutting mechanism change touching merged CRUD + reconciler, out of scope for a projection-only
recovery PR. Documented honestly in the acquirer doc + M1 test comment. **The binding independent
review did NOT treat this as a blocker.** Recommendation to MS-LEAD: proceed to PR-open + spin a
SEPARATE follow-up issue for the fd-advisory-lock migration; MS-LEAD adjudicates scope at exact-head
review (merge authority).
**Gates after round-3 fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
**1275** (regen spec 23). Fresh codex code re-run in flight to confirm no NEW issues from the label fix.
---
## Session 6 — Round 4/5 convergence (stranded-lock robustness)
**Two independent reviewers converged on the SAME should-fix** on the init-failure cleanup path,
strengthening confidence it was real:
- **Codex code-review-5 — 0 blockers, 1 should-fix.** "Stat failure after lock creation strands the new
lock." When `handle.stat()` ITSELF fails right after the `wx` create (transient EIO/EBADF), `created`
is `undefined`, so `removeOwnedLockLeafBestEffort` had `if (!created) return;` → no cleanup → the
just-created `roster.yaml.mutation.lock`/`reconcile.lock` is stranded, permanently blocking future
regen + CRUD. (Notably NO blocker, and the TOCTOU is no longer flagged in code-review as of r5.)
- **Independent delta reviewer (author≠reviewer, pr-review-toolkit) — no blockers, same should-fix.**
Independently flagged the identical `!created` gap; validated FIX 1 (label threading — no call site
missed, codes unchanged, no test depended on old text) and FIX 2 (dev/ino-guarded cleanup, best-effort,
happy-path release reuses captured dev/ino) as correct. Suggested an unconditional best-effort unlink
in the `!created` branch; I took the **safer** variant below.
- **Codex security-review-5 — 0 crit / 0 high / 1 medium.** The single medium is the SAME residual
check-then-unlink TOCTOU already dispositioned in round 3 (its own remediation = "migrate every writer
to an fd-held advisory lock" = the follow-up issue). No new security finding. No secrets.
**Fix (red-first, safer than an unconditional unlink):** thread the persisted random `token` into
`removeOwnedLockLeafBestEffort`. Two independent ownership proofs now: primary dev/ino (unchanged), and a
**fallback** when the post-create stat failed — read the leaf and unlink ONLY if its content equals our
`randomUUID()` token. Only OUR lock carries that token, so a CRUD (empty) or differently-tokened
replacement is never deleted. `tokenPersisted` guards passing the token (only after `writeFile` lands).
Doubly-degenerate case (stat fails AND token write never landed) leaves the lock in place rather than
risk deleting a stranger's file — requires two independent fs faults on a just-created fd; documented.
- **Red-first proof:** new test `does not strand the lock file when the post-create stat itself fails`
injects a real `wx` create + a Proxy handle whose `stat()` rejects (writeFile/close succeed), asserts
`exists(lockPath) === false`. RED before fix (`expected true to be false` — lock stranded); GREEN after.
- **Also fixed (delta nit #3):** `fleet-regen-command.ts` `acquireRosterMutationLock` JSDoc said "CRUD's
private lock"; the default is the reconciler's hardened ownership-proving acquirer for the same
`fleet/roster.yaml.mutation.lock` path. Corrected.
- **PR-description note (delta nit #2):** FIX 1 also collapsed a pre-existing duplicate back-to-back
`assertLockOwnership` call in the release closure (identical args, no intervening logic) into one — a
no-op simplification of merged code, not a behavior change. Called out so a future reader doesn't
wonder if the duplicate had a purpose.
**Gates after round-4 fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
**1277** (regen spec now 25: +1 stat-failure stranded-lock regression). Residual TOCTOU still deferred to
the fd-advisory-lock follow-up issue; MS-LEAD adjudicates scope at exact-head review (merge authority).
---
## Session 6 — Round 6 (persona-root wiring)
**Codex code-review-6 — 0 blockers, 1 should-fix (NEW, distinct from the lock work).** "Forward
configured persona directories to regen." `registerFleetRegenCommand` was registered at
`fleet.ts:2069` with only `{ runner, mosaicHome }`, discarding `deps.reconcileDeps.rolesDir` /
`overrideDir`. The regen command ALREADY has those seams (validates roster semantics via
`validateRosterV2Semantics({ rolesDir, overrideDir })`, defaulting to `<mosaicHome>/fleet/roles{,.local}`),
but the top-level wiring never forwarded the configured roots. **Impact:** in a deployment with custom
persona roots, `fleet reconcile` (which honors the overrides) would ACCEPT a roster while `fleet regen`
REJECTS the same roster (persona resolution against the wrong default dir) — blocking the recovery
command and violating the documented "resolves personas the SAME way reconcile does" contract.
**Fix (red-first):** forward `rolesDir`/`overrideDir` from `deps.reconcileDeps` into
`registerFleetRegenCommand` at `fleet.ts:2069`. Red-first test `forwards configured persona roots
(rolesDir/overrideDir) from reconcileDeps into regen`: seeds personas ONLY under a custom root, leaves
the default `<home>/fleet/roles` empty, registers with `reconcileDeps: { rolesDir, overrideDir }`, and
requires `fleet regen` to SUCCEED. RED before fix (`expected 1 not to be 1` — regen validated against the
empty default and exited 1); GREEN after.
**Codex security-review-6 — 0 crit / 0 high / 1 medium.** Same residual check-then-unlink TOCTOU, now
noted at BOTH the release closure and the init-cleanup path; remediation = fd-held advisory lock across
all writers = the SAME deferred follow-up item. No new security finding, no secrets.
**Independent confirmation review of the token-fallback fix (Session 6/round 4) — PASS, no findings.**
All 7 verification points confirmed; reviewer mechanically reverted `removeOwnedLockLeafBestEffort` to
the pre-fix `if (!created) return;` and re-ran the new test → RED (`expected true to be false`),
confirming the test genuinely pins the fix; restored after. No lint/type issues; doc-comment accurate.
**Gates after round-6 fix (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
**1278** (regen spec now 26: +1 persona-root wiring regression).
---
## Session 6 — Round 7 convergence (review CLOSED for PR-open)
- **Codex code-review-7 — 0 blockers, 1 should-fix = the residual TOCTOU** (previously a "blocker" in r3,
dropped in r4/r5, now re-surfaced as a should-fix). **Codex security-review-7 — 0 crit / 0 high /
1 medium = the SAME residual TOCTOU.** Codex has CONVERGED: the only remaining finding across both
streams is that one race, whose own remediation is "fd-held advisory lock shared by all fleet writers"
= the deferred follow-up. No new distinct finding; the wiring fix introduced nothing.
- **Independent confirmation review of the persona-root wiring fix — PASS, no findings.** Reviewer
mechanically reverted the two forwarded lines → RED (`Roster v2 agent "coder0" class "code" does not
resolve to a readable persona` → exit 1), restored → GREEN (26 regen + 204 fleet tests). Confirmed the
optional-chaining fallback preserves default-deployment behavior and no type/lint issue.
**Review disposition for PR-open:** ALL actionable findings fixed red-first across rounds 36 (label
threading, stranded-lock on init failure, stat-failure strand, persona-root wiring). The residual
check-then-unlink TOCTOU is the ONLY open item and is DEFERRED to a follow-up issue (fd-advisory-lock
migration across CRUD + reconcile + regen) — byte-identical to merged origin/main's reconcile-lock
release, unreachable within the `wx` writer protocol (no Mosaic writer removes a lock it doesn't own;
only external `rm`/a stale-lock reaper can vacate the inode mid-release), and its true fix is a
cross-cutting mechanism change out of scope for a projection-only recovery PR. Two independent human-agent
reviews (author≠reviewer) treated it as non-blocking. MS-LEAD adjudicates scope at exact-head review
(merge authority); recommendation = proceed to PR-open + spin the follow-up issue.
**Final gates (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1278**
(regen spec 26). No secret values in any snapshot/projection/report output (counts + paths only). Regen
NEVER issues a lifecycle/restart call (load-bearing recordingRunner gate). STOP at PR-open for MS-LEAD's
exact-head review + durable Reviewer-of-Record before any merge; do NOT self-merge.

View File

@@ -1,86 +0,0 @@
# Issue #804 — fail closed on unknown installer arguments
## Objective
Implement Part 1 of Gitea issue #804 only: `tools/install.sh` must reject every unrecognized flag or argument with an actionable STDERR error and nonzero exit before installation starts.
## Scope and constraints
- Preserve all currently recognized options and behavior, including `-y` and `--ref <branch>`.
- No positional arguments are currently accepted by the parser.
- Do not add `--next`, `MOSAIC_NEXT`, prerelease routing, or any Part 2 behavior.
- TDD is mandatory: add and observe a failing process-level regression test before changing `tools/install.sh`.
- Worker lifecycle ends after branch push, PR creation, and coordinator notification; do not merge or close #804.
- Existing launcher-owned changes in `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are out of scope and must not be committed.
## Requirements and acceptance criteria
- Unknown input names the offending argument on STDERR.
- STDERR includes a short installer usage hint.
- Exit status is nonzero.
- The installer does not invoke npm or otherwise proceed into installation.
- Existing recognized flags remain unchanged.
## Plan
1. Add a process-level Vitest regression using the installer test location under `packages/mosaic/src/commands/`.
2. Run the focused test and record the expected RED failure.
3. Commit the RED test as `test(#804): ...`.
4. Replace the parser catch-all with a fail-closed STDERR error and usage hint.
5. Update concise installer-facing documentation without introducing prerelease behavior.
6. Run focused tests, shell syntax validation, package tests, lint, typecheck, and format checks.
7. Run independent review tooling and remediate findings.
8. Commit as `fix(#804): ...`, queue-guard, push, open a PR containing `Closes #804.`, notify the coordinator, and exit.
## Budget
- No explicit token cap supplied.
- Working estimate: 8K tokens; narrow two-file behavior/test change plus concise docs and delivery gates.
## Progress
- 2026-07-17: Loaded mission state, issue #804, delivery/QA/documentation rails, and relevant TDD/Vitest/pnpm/Gitea skills.
- 2026-07-17: Confirmed the parser has no legitimate positional arguments and currently drops all unmatched input via `*) shift ;;`.
- 2026-07-17: Installed locked workspace dependencies with a worktree-local pnpm store; no lockfile changes.
- 2026-07-17: Added the process-level unknown-argument regression with an isolated `$HOME` and npm shim.
- 2026-07-17: Replaced the silent catch-all with STDERR error + usage output and exit 2 before preflight or installation.
- 2026-07-17: Initial Codex code review found an unknown option could still be consumed as the `--ref` value. Added a second RED reproducer, then rejected option-shaped/missing `--ref` values without changing valid `--ref <branch>` behavior. The review's launcher-state note is handled by excluding both `.mosaic/orchestrator/` files from commits.
- 2026-07-17: Updated README, user guide, and packaged framework README with the fail-closed argument contract. No API, auth, admin, sitemap/navigation, or publishing surface changed.
## Verification
- RED: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts` — expected failure: installer exited `0` instead of nonzero at the exit-status assertion; confirms the test reproduces the silent-drop defect before production changes.
- Remediation RED: the added `--cli --ref --bogus` case exited `0`, proving `--ref` could swallow an unknown option before the guard was added.
- GREEN: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts src/commands/install-heading.spec.ts` — 2 files, 3 tests passed.
- Situational process check: unknown positional input exited 2, named the input on STDERR, printed usage, and did not call the npm shim.
- `bash -n tools/install.sh` — passed.
- Bare `--ref` process check — exited 2 with `Missing value for --ref` and usage.
- `pnpm --filter @mosaicstack/mosaic test` — 69 files, 1,287 tests passed; framework shell checks passed. The first attempt lacked generated `dist/cli.js`; `pnpm --filter @mosaicstack/mosaic build` restored the required test precondition and the full rerun passed.
- `pnpm lint` — 23/23 tasks passed.
- `pnpm typecheck` — 42/42 tasks passed.
- `pnpm format:check` — passed.
- Codex code re-review against `origin/main``approve`, 0 blockers/should-fix/suggestions.
- Codex security re-review against `origin/main` — risk `none`, 0 findings.
## Acceptance evidence
| Criterion | Evidence |
| --- | --- |
| Unknown input is named on STDERR | Process-level Vitest assertions for `--bogus`, including after `--ref` |
| Short usage hint is printed on STDERR | Vitest usage regex + manual process output |
| Exit is nonzero | Vitest status assertions and manual exit 2 |
| Installation does not proceed | Isolated npm shim marker remains absent |
| Recognized behavior is preserved | Parser cases are unchanged except validation of malformed `--ref`; full Mosaic package suite passed |
| Part 2 is excluded | No `--next`, `MOSAIC_NEXT`, dist-tag, or prerelease routing changes |
## Documentation checklist
- Current canonical `docs/PRD.md` remains unchanged; issue #804 and the coordinator brief supply this bounded defect's acceptance contract.
- Updated installer behavior in root README, user guide, and packaged framework README in the same logical change set.
- API/OpenAPI, auth/permissions, admin operations, developer architecture, sitemap/navigation, and external publishing are not affected.
- Scratchpad remains under `docs/scratchpads/`; no root-hygiene changes.
## Risks and blockers
- Part 2 remains owner-gated under #805 and is intentionally excluded.
- No implementation blocker remains. Independent coordinator RoR, CI, merge, and issue closure remain pending after worker handoff.

View File

@@ -1,64 +0,0 @@
# Issue #807 — GLPI list wrappers accept HTTP 206
- **Branch:** `fix/807-glpi-206`
- **Task:** Gitea issue #807
- **Role:** Author-only worker reporting to `mosaic-100`; no self-review or merge
- **Started:** 2026-07-16
## Objective
Fix the shipped GLPI ticket, computer, and user list wrappers so ranged responses with HTTP 206 Partial Content render successfully while genuine HTTP failures remain non-zero errors.
## Scope
- Modify only the three affected list wrappers and a focused shell regression test.
- Do not touch `session-init.sh`, `ticket-create.sh`, or `docs/TASKS.md`.
- Add task-local delivery evidence here as required by the mission protocol.
## Plan
1. Add a deterministic shell harness that copies each wrapper beside stubbed `session-init.sh`, credentials, and `curl` boundaries.
2. Prove RED against the current 200-only gates: 206 must fail before the implementation change.
3. Update all three status gates to accept exactly 200 or 206.
4. Prove GREEN for 206 rendering and genuine 401/500 failures, then run repository quality gates.
5. Commit with co-author attribution, run the push queue guard, push, and open a PR for independent review and merge by the team lead.
## Budget
- No explicit token cap supplied.
- Soft estimate: 8K tokens; narrow single-worker execution with no exploratory scope.
## Progress
- [x] Mission, task, PRD, QA, documentation, and code-review guidance loaded.
- [x] RED regression evidence captured: `test-list-http-status.sh` exited 1; all three wrappers rejected 206 while retaining 401 failures.
- [x] Implementation complete.
- [x] Relevant tests and repository gates green.
- [ ] Commit pushed and PR opened.
## Tests and evidence
- RED (before source fix): `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → exit 1; ticket/computer/user 206 assertions failed, all 401 assertions passed.
- GREEN: `bash -n packages/mosaic/framework/tools/glpi/{ticket-list.sh,computer-list.sh,user-list.sh,test-list-http-status.sh}` → pass.
- GREEN: `shellcheck packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → pass.
- GREEN: `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → 6 assertions pass (206 renders and 401 errors for all three wrappers).
- GREEN: `pnpm typecheck` → 42/42 tasks pass.
- GREEN: `pnpm lint` → 23/23 tasks pass.
- GREEN: `pnpm format:check` → all matched files pass.
- Setup note: initial gate attempts could not start because the fresh worktree lacked dependencies; `pnpm install --frozen-lockfile --store-dir /home/hermes/.local/share/pnpm/store` restored the locked workspace dependencies without lockfile changes.
## Acceptance criteria mapping
| Criterion | Evidence |
| --- | --- |
| HTTP 206 succeeds and renders each ranged list | Focused test's three 206 render assertions pass |
| Genuine HTTP failures remain non-zero with existing diagnostics | Focused test's three HTTP 401 assertions pass |
| Only affected list wrappers change | Diff contains the three status predicates plus focused test/evidence; session and create wrappers untouched |
## Documentation decision
No operator/API documentation change is needed: this restores documented list behavior for a healthy GLPI response without changing command syntax, output, configuration, or public contracts. This task scratchpad records delivery evidence.
## Risks / blockers
- Existing dirty `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are runtime-owned and will not be edited or committed.

View File

@@ -1,37 +0,0 @@
# Issue #808 — agent-send sender identity
## Objective
Fix cross-socket `agent-send.sh` preambles so replies route to the real sender rather than a destination-socket holder session.
## Scope and acceptance criteria
- Prefer exported `MOSAIC_AGENT_NAME` as the authoritative sender session name.
- If it is unset, query the sender's local/default tmux socket for `#S` without destination `-L` arguments.
- Preserve `?` when sender identity cannot be determined.
- Do not alter destination socket dispatch.
- Add red-first regressions for all three identity paths.
## Plan
1. Extend `agent-send.test.sh` with deterministic fake-tmux coverage.
2. Run the test against the unpatched implementation and record RED evidence.
3. Apply the minimal sender lookup fix only.
4. Run the focused suite and repository quality gates.
5. Commit, queue-guard, push, and open an author-only PR for independent review.
## Constraints and risks
- Worker lane is author-only: no self-review or merge.
- `docs/TASKS.md` and mission state are orchestrator-owned and will not be modified.
- Pre-existing runtime changes under `.mosaic/orchestrator/` are excluded from this work.
- Budget: no explicit token cap; keep changes limited to the shell tool, sibling regression test, and this scratchpad.
## Evidence
- RED: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` failed on the unpatched implementation with `PASS=12 FAIL=3`; it selected `destination-holder` instead of both `MOSAIC_AGENT_NAME=authoritative-agent` and local session `local-agent`. The genuinely unavailable sender case already exercised and preserved `?`.
- GREEN: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed with `PASS=15 FAIL=0`; coverage includes env authority, local/default tmux fallback across a destination `-L`, explicit rejection of the destination holder, and `?` fallback.
- Syntax: `bash -n packages/mosaic/framework/tools/tmux/agent-send.sh packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed.
- Quality gates: `pnpm typecheck` (42/42 tasks), `pnpm lint` (23/23 tasks), and `pnpm format:check` all passed after installing the frozen lockfile dependencies. The first install attempt failed because pnpm's configured store pointed at `/root`; retrying with the existing user-owned store (`--store-dir /home/hermes/.local/share/pnpm/store`) succeeded without changing tracked dependency files.
- Documentation: no public API or operator workflow changed; the source comment, regression-test contract, and this implementation record cover the internal bug fix.
- Independent review: intentionally pending for the reviewer assigned by `mosaic-100`; this author-only lane will not self-review or merge.

View File

@@ -1,112 +0,0 @@
# Issue #824 — Mosaic skill CLI and Claude bridge auto-sync
## Objective
Deliver `mosaic skill register|unregister|list` plus install/upgrade reconciliation of every canonical `~/.config/mosaic/skills/*` entry into `~/.claude/skills/`, without clobbering runtime-owned files or directories.
## Scope and constraints
- Issue: mosaicstack/stack#824
- Branch: `feat/824-mosaic-skill-cli`
- M1 runtime: Claude Code only.
- Pi/Codex parity is documentation-only; no non-Claude bridge implementation.
- Do not author the downstream `mosaic-context-refresh` skill.
- Workers do not modify `docs/TASKS.md`, merge, close #824, or touch `main`.
- TDD is mandatory and red-first; filesystem tests use temporary directories only.
- Budget: no explicit token cap supplied; use a focused single-worker implementation with no new dependencies.
## Requirements mapping
1. Register creates the canonical Claude symlink and is idempotent.
2. Names are untrusted: reject empty/escaping/absolute/separator/`..`/leading-dash names before filesystem mutation, with clear CLI stderr and nonzero status.
3. Register repairs only Mosaic-owned dangling symlinks and refuses foreign files, directories, and symlinks.
4. Unregister removes only symlinks pointing inside the canonical Mosaic skills root and is idempotent when absent.
5. List reports registered, dangling, foreign, and canonical-but-unregistered skills.
6. Install and upgrade generically reconcile all canonical skills after framework sync/re-seed, continuing past foreign conflicts without clobbering them.
7. User/developer documentation describes commands, status meanings, security boundaries, and Claude-only M1 scope.
## Plan
1. Add co-located failing Vitest coverage for all filesystem behaviors and auto-sync.
2. Run the focused spec and record the expected RED failure.
3. Commit the red contract as `test(#824): ...`.
4. Implement the skill bridge and Commander command registration.
5. Wire reconciliation into wizard finalize and `mosaic update` re-seed, preserving non-clobber behavior.
6. Update canonical docs and sitemap if navigation changes.
7. Run focused tests, package tests, typecheck, lint, and formatting.
8. Commit implementation/docs as `feat(#824): ...`, queue-guard, push, open PR with `Closes #824.`, fire completion event, and notify the coordinator.
## Progress
- 2026-07-17: Loaded mission/delivery/TDD/documentation rails, issue #824, active mission state, and relevant installer/update paths.
- 2026-07-17: Confirmed `mosaic update` invokes `framework/install.sh` with `MOSAIC_SYNC_ONLY=1`; that path exits before existing post-install skill linking, leaving newly present canonical skills unregistered.
- 2026-07-17: Coordinator addendum classified the user-supplied skill name and runtime symlink target as a path-traversal/symlink-injection surface. Expanded the initial red contract to reject traversal before mutation, preserve every foreign entry, and unregister Mosaic-owned links only.
- 2026-07-17: Implemented the Commander command group and secure generic bridge; wired wizard finalize and successful framework re-seed reconciliation; updated user/developer/installed/root docs and sitemap.
- 2026-07-17: Focused, package-wide, repository baseline, temp-home situational, and independent review gates completed. Ready for scoped feature commit, queue guard, push, and PR handoff.
## Tests and evidence
### TDD evidence
- RED environment attempt: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/skill.spec.ts` initially could not locate Vitest because this fresh worktree had no dependencies.
- Dependency setup: `pnpm install --frozen-lockfile --store-dir /home/hermes/.local/share/pnpm/store` succeeded. The explicit store was required because machine pnpm config incorrectly resolves the default store under `/root`.
- RED behavior: focused Vitest failed with `Failed to load url ./skill.js ... Does the file exist?`, proving the bridge API was absent.
- RED integration: finalize/update specs failed because no Claude links or `skillSync` result existed.
- RED symlink injection: symlinked Claude/canonical root tests failed because the initial implementation followed ancestor links.
- GREEN after review remediation: `skill.spec.ts` 36/36, `finalize-skills.spec.ts` 6/6, and `update-checker.reseed.spec.ts` 30/30.
### Baseline gates
- `pnpm --filter '@mosaicstack/mosaic...' run build` — pass (fresh-worktree dependency outputs built).
- `pnpm --filter @mosaicstack/mosaic run typecheck` — pass.
- `pnpm --filter @mosaicstack/mosaic run lint` — pass.
- `pnpm --filter @mosaicstack/mosaic test` — pass: 69 files, 1,325 Vitest tests plus framework shell suite.
- `pnpm typecheck` — pass: 42/42 Turbo tasks.
- `pnpm lint` — pass: 23/23 Turbo tasks.
- `pnpm format:check` — pass.
### Situational evidence
A built-CLI temp-home smoke test (no real `~/.claude` or Mosaic config touched) proved:
- register creates the exact link and a second run reports `already registered`;
- list reports registered and unregistered canonical skills;
- `../../etc` exits 1 with `Invalid skill name` and creates no escaped path;
- unregister removes the managed link and a second run reports `already unregistered`;
- a fake successful framework re-seed generically registered both `added-after-setup` and `second-skill` from runtime directory enumeration.
### Review evidence
- Initial uncommitted Codex code/security review described name validation/clobber protection as strong; its only finding was the harness-owned, unrelated `.mosaic/orchestrator/session.lock`, which is excluded from all commits and the PR.
- Exact branch review then identified two remediations: preserve successful framework re-seed status when bridge-wide reconciliation fails, and reject/escape control-character names to prevent terminal/log injection.
- Both findings were reproduced red-first and remediated. A subsequent exact review identified one finalize failure-isolation blocker; a root-wide bridge error now warns and allows wizard doctor/summary/next-steps completion, with a red-first regression.
- All remediations passed the full package and repository gates. Final exact-head review is rerun after amending the feature commit.
### Acceptance mapping
| Acceptance criterion | Evidence |
| --- | --- |
| register/unregister/list, idempotent | `skill.spec.ts` and built-CLI temp-home smoke |
| traversal/symlink-injection protection | invalid-name matrix, foreign file/dir/link tests, symlinked-root tests |
| list flags dangling and foreign entries | deterministic list status test |
| install and upgrade auto-sync every canonical directory | finalize + framework re-seed integration specs; two-skill built-module smoke |
| newly added skill becomes discoverable without manual link | `added-after-setup` auto-sync creates exact Claude link; Claude can rescan with `/reload-skills` or a new session |
| Pi/Codex parity captured as scope note | user guide, developer guide, installed framework README |
| documentation gate | root README, user guide, developer guide, framework README, sitemap |
## Risks
- Symlink replacement uses `lstat` semantics so dangling links are detectable without following them.
- Link ownership is determined lexically against the canonical skills root, and existing symlink ancestors in either managed root are rejected before mutation.
- Auto-sync continues across per-skill conflicts while never deleting real files/directories or foreign symlinks.
- Claude Code discovers filesystem skills at session launch/reload boundaries; bridge creation makes a later `/reload-skills` or new session able to discover the skill, but cannot mutate an already-cached in-process registry by itself.
- Pi does not need this Claude bridge because its Mosaic launcher can consume the canonical root. Codex lifecycle parity remains explicitly deferred.
- No deployment surface is affected.
## PR #826 review remediation
- 2026-07-17: Exact-head RoR requested changes for two ownership bugs: installer pruning deleted foreign-name links under `MOSAIC_HOME` outside canonical skills, and unregister deleted a same-root link targeting a different skill. It also requested trailing-dot rejection and executable coverage support.
- RED evidence: focused regression run failed 4 tests: register/unregister accepted `safe.`, misdirected unregister did not throw, and the install linker deleted the foreign-name link.
- GREEN evidence: `skill.spec.ts` passes 43/43, including live and dangling foreign-name links in a temp HOME/MOSAIC_HOME and the misdirected unregister invariant.
- Coverage: `vitest run src/commands/skill.spec.ts --coverage` passes configured 85% thresholds for `skill.ts`: 91.05% statements/lines, 86.27% branches, 95.23% functions.
- Full gates: package build passed; package tests passed 69 files / 1,332 tests plus framework shell suite; repository typecheck 42/42, lint 23/23, and format check passed.

View File

@@ -1,80 +0,0 @@
# Issue 766 — exact cross-harness fleet comms targeting
- **Issue:** #766
- **Branch:** `fix/766-exact-fleet-comms`
- **Worktree:** `/home/jarvis/src/stack-issue-766`
- **Delivery boundary:** source/tests/docs only; no live tmux, session, or fleet actions; leave uncommitted for independent review.
## Objective
Replace inference-prone fleet onboarding guidance with one roster-resolved contract that gives Claude Code, Codex, OpenCode, and Pi the same authoritative local identity and exact executable command for every known peer.
## Plan
1. Add issue-specific normative requirements to `docs/PRD.md` before source changes; do not modify orchestrator-owned `docs/TASKS.md`.
2. Extract the existing v1 roster parsing/normalization into one lightweight shared resolver used by both fleet commands and runtime comms composition.
3. Write failing contract tests for explicit SSH-only cross-host targeting, global/default socket authority, authoritative identity, unknown-peer failure, no operational metavariables, and four-harness parity.
4. Make source `TOOLS.md` non-operational and marker-versioned; prove fresh installation preserves that exact contract and composition detects a stale installed copy without rewriting it.
5. Render a deterministic comms generation and document comparison/relaunch handling; never rewrite an active session.
6. Run focused Vitest and shell exact-target tests, then package/repository typecheck, lint, format, test, and build gates as relevant.
7. Reconstruct the exact uncommitted tree, including untracked files, for independent review and remediate findings without committing.
## Contract decisions
- `tmux.socket_name` is the one supported socket authority for every local fleet session. A per-agent `socket`, when present for compatibility, must equal that global value; independent per-agent sockets fail closed because the runtime does not provision them. A named socket renders `-L`, while the empty literal default renders no `-L`.
- A peer is same-host only when its resolved host equals the current roster member's resolved host. Every host-omitted member resolves against the stable local fleet-host baseline, never against the viewer's explicit host. Same-host rows never render `-H`.
- A cross-host row requires that peer's explicit roster `ssh`; absence is a contract error. Never substitute `host` as an SSH target.
- The current member's explicit roster `host` wins; otherwise the local machine's short hostname is the baseline for host-omitted local members.
- Unknown members/peers return a deterministic error listing exact known names and an exact self-scoped discovery command. No fuzzy session lookup.
- Exact command fields are structurally constrained to safe targeting grammars and shell-rendered as individual arguments. Unsafe host/SSH/socket values fail roster normalization rather than entering executable guidance.
- Existing installed `TOOLS.md` remains user-owned during ordinary keep-mode updates. Currency requires the expected source and installed marker/version plus bounded SHA-256 byte identity. Explicit `mosaic update --repair-tools` is the supported current-version recovery path: it makes a digest-qualified no-clobber backup, restores the contract and regular executable helper, and does not rewrite active context.
- The v1 resolver preserves and validates `tmux`, `discord`, and `matrix` connector blocks in YAML and JSON; conflicting snake/camel aliases fail closed unless their values are identical.
- JSON roster fallback occurs only when `roster.yaml` is absent. Keep-mode reseed preserves both formats, and relaunch discovery uses the same canonical resolver.
- The helper is inspected without following symlinks and must be a regular executable file. Missing, directory, symlink, and non-executable installations fail closed with deterministic repair guidance.
- Active contexts carry a deterministic comms generation. Operators compare it to `mosaic agent comms-block <exact-agent>` output; mismatch means stale and requires an explicit exact-agent relaunch.
## Risks
- Import cycles if runtime composition imports the command-heavy `fleet.ts`; mitigate with a lightweight shared roster module and re-export compatibility.
- Existing schema prose allowed independent per-agent sockets even though runtime provisioning used one global socket; constrain compatibility declarations to the global value and preserve empty-global default behavior.
- Remote inventory may be incomplete. Fail composition closed for an unreachable cross-host row rather than generating a guessed command.
- `TOOLS.md` is user-seeded and intentionally preserved. Detect/report drift instead of overwriting custom content.
## Planned evidence
- `comms-onboarding.spec.ts`: resolver/renderer/failure/generation contracts.
- `compose-contract.spec.ts`: identical authoritative comms section for all four harnesses and stale installed-contract reporting without mutation.
- `file-adapter.test.ts`: source-to-fresh-install byte equality and preservation of customized installed `TOOLS.md`.
- Existing `agent-send.test.sh`, socket isolation, and tmux runtime transport tests.
- Repository quality gates and independent uncommitted-tree review.
## Evidence log
- Preflight collision scan: no issue-766 local/remote branch, worktree, or open PR collision before branch creation.
- Isolated branch created from fetched `origin/main` at `4990905`; original checkout not edited.
- One strict v1 resolver now serves fleet commands and communications composition; roster writes preserve `host`, `ssh`, and `socket`.
- Exact renderer covers authoritative self identity, global/default socket authority, rejected independent sockets, stable hostless-peer resolution, same-host omission of `-H`, explicit-SSH-only cross-host rows, shell-safe argv rendering, deterministic generations, and fail-closed unknown/missing targets.
- Real framework `defaults/TOOLS.md` is tested byte-equal through a fresh `FileConfigAdapter` install, the installed helper is executable, and the final Pi contract contains the same source contract plus exact generated command; separate parity coverage proves byte-equivalent comms sections for Claude Code, Codex, OpenCode, and Pi.
- Second review remediation adds strict connector/alias coverage, full-semantic generation coverage, ENOENT-only fallback, no-follow helper validation, unconditional current-version repair, digest-qualified no-clobber backups, and marker/version-gated currency.
- The helper, roster, installed TOOLS, and framework source files are read with canonical containment, every existing ancestor and target rejected if symlinked, `O_NOFOLLOW` descriptor reads, inode stability checks, and effective-identity execute access. Read-only TOOLS status treats source/installed symlinks as unavailable without following or rewriting them.
- Explicit repair validates both bundled inputs before destination creation, stages backup/TOOLS/helper plus exact-mode rollback files before any persistent file commit, revalidates destination identity at each commit boundary, installs the digest backup without clobber, and removes or exactly rolls back every committed output on injected failure. `changed: false` is returned only after full cleanup; cleanup/rollback failure is reported as `changed: true`.
- Connector schema and runtime normalization require kind-matching settings and reject inactive connector blocks. Keep-mode installers preserve only exact `roster.yaml`, `roster.json`, `agents/`, and `run/` paths while refreshing framework `roster.schema.json`; shell evidence covers byte preservation and schema refresh.
- Solo contracts render normalized role/class plus explicit no-peer/no-remote authority boundaries; composed-contract evidence keeps role Mandate/Boundaries before Fleet Comms.
- Operational documentation and CLI metavariable now use `mosaic agent comms-block <exact-member>`; historical issue-633 scratchpad text remains historical.
- The latest independent review rejected synthetic tree `556ae4ea04f2715a4e9d381f3cafaf4c8b991b2e` on three mandatory findings: installed `TOOLS.md` could be read through target/ancestor symlinks before unsafe status was reported; ambient class/tool-policy state could split identity authority from the canonical roster member; and the connector schema admitted empty or whitespace-only Discord/Matrix strings rejected by runtime parsing.
- Red-first reproduction proved all three findings with 20 failures and 79 passes. Remediation routes installed `TOOLS.md` through the bounded secure regular-file reader before composition, resolves one exact canonical fleet identity for persona/tool policy/normalized class/Fleet Comms, rejects canonicalized ambient class mismatches, canonicalizes compatibility classes during roster parsing, and aligns parser/schema non-whitespace requirements.
- Four-runtime coverage proves unsafe target and ancestor symlink content is omitted without mutation, while Claude Code, Codex, OpenCode, and Pi all project the same canonical member authority. Connector parser/schema coverage includes empty and whitespace-only Discord `channel_id` and Matrix `homeserver_url`, `user_id`, and `room_id` values.
- Remediated focused gates passed: 99/99 across the two finding-focused suites plus connector schema regression PASS; the six changed-suite matrix passed 341/341; secure-file/transaction coverage remains green, including 28/28 transactional repair tests; installer migration passed 21/21.
- Mosaic package suite passed 906/906. Shell/runtime regressions passed: `agent-send.test.sh` `PASS=11 FAIL=0`; named-socket isolation; matrix/tmux transport 12/12 (Matrix 5/5, tmux 7/7).
- Final repository gates passed: format check; typecheck 42/42 tasks; lint 23/23; tests 42/42 tasks (Mosaic 906/906, gateway 628 passed/12 skipped); build 23/23.
- A subsequent immutable review of tree `aa6414123643a504145fce6ac1d66f0b535feb5e` found one roster-authority blocker: a canonical member with omitted `tool_policy` inherited ambient `MOSAIC_AGENT_TOOL_POLICY`. Red-first four-runtime coverage failed 4/39 specifically on the leaked operator-interaction policy. Composition now branches on canonical membership: fleet launches use only `canonicalMember.toolPolicy` (including canonical absence), while genuinely non-fleet launches retain ambient fallback.
- Final remediated gates passed: four-runtime regression 39/39; six changed-suite matrix 345/345; connector schema regression PASS; Mosaic package 910/910; installer migration 21/21; `agent-send.test.sh` 11/11; named-socket isolation PASS; Matrix/tmux transport 12/12; repository format PASS; typecheck 42/42 tasks; lint 23/23 tasks; tests 42/42 tasks; build 23/23 tasks.
- No live tmux/session/fleet mutation, commit, push, PR mutation, issue mutation, context mutation, or reviewer launch performed.
- Exact synthetic-tree reconstruction and frozen evidence are included in the coordinator handoff.
- Sole-remediation preflight reverified the clean committed checkout at head `0dc47cac92c93a3ffd39ba9dd6685ac4165f6361`, tree `538de6ccce1f8c44ba288a7493286e63a3413e75`, branch `fix/766-exact-fleet-comms`; issue and PR state were read only through Mosaic wrappers.
- Deterministic red-first ancestor substitution swapped validated `root/tools` for an external symlink immediately after `lstat`; current head returned `external marker` (`1 failed, 4 passed`) before implementation.
- Secure reads now hold `/` and every root/descendant directory descriptor, traverse appended components through Linux `/proc/self/fd` with `O_DIRECTORY|O_NOFOLLOW`, and read plus effective-identity execute-check the same final descriptor. Non-Linux or unavailable proc-fd capability fails closed; stable errors redact managed paths while retaining Node `code` compatibility for missing/non-executable repair behavior.
- Added deterministic root-selection, descendant-ancestor, and final-target substitution coverage. All return trusted descriptor-bound bytes after rename/symlink replacement; the race suite passed 50/50 repeated runs.
- Isolated CLI verification drove `mosaic agent --mosaic-home <fixture> comms-block self` while repeatedly swapping `fleet/` with an external symlink: `trusted=2 fail_closed=10 external_marker=0`; a persistent symlink ancestor exited 1 with a redacted unsafe-ancestor error. No live fleet state was used or mutated.
- Remediation gates: focused secure-file/comms/launch/tmux/Matrix `110/110`; full `@mosaicstack/mosaic` `914/914`; package and repository typecheck pass (`42/42` repository tasks); package and repository lint pass (`23/23` repository tasks); repository format check and `git diff --check` pass.
- Independent review found one production hardening blocker (nonblocking final open), one redacted-error blocker, and a deterministic ancestor-test gap. Remediation added `O_NONBLOCK`, normalized execute errors while preserving errno, proved the ancestor hook fires, and added final-target substitution coverage; post-remediation review evidence is clean on the production invariant.

View File

@@ -1,38 +0,0 @@
# ms-792 — Fleet roster error handling and installer heading
## Objective
Make expected missing or malformed fleet roster configuration fail with an actionable message and nonzero exit instead of a raw Node stack trace. Ensure the installer preserves the `@mosaicstack/mosaic` heading.
## Plan
1. Add failing coverage for missing and malformed roster input.
2. Centralize roster-file read and parse error translation; add the CLI async error boundary.
3. Sweep fleet command read paths that bypass the roster loader.
4. Replace the installer heading output with format-safe rendering and test it.
5. Run focused and repository quality checks; request independent review.
## Progress
- 2026-07-16: Confirmed issue #792 and branch base `9745bc3f`.
- 2026-07-16: Installed locked workspace dependencies using a worktree-local pnpm store; no `.mosaic/` files were changed intentionally.
- 2026-07-16: Added a shared roster read/parse guard and routed v1 fleet commands plus v1/v2 selection through Commanders actionable nonzero error path. V2 command modules already return structured nonzero JSON errors for their guarded reads.
- 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading.
- 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace.
- 2026-07-17: Rebased #818 onto `origin/main` at `9ddc6fbd` (#791 PR3). The added `fleet regen` command had a canonical roster read in its sibling module; it now uses the same missing-roster guard and Commander exit path. Internal NORTH_STAR, preset, and post-write invariant reads remain intentionally unguarded.
- 2026-07-17: RoR found that semantically invalid v1 documents still escaped as plain `Error` values. `normalizeFleetRosterV1` now preserves each validation message while converting it to `FleetRosterConfigurationError`, so its command callers use the actionable nonzero Commander path.
## Verification
- `pnpm --filter @mosaicstack/mosaic test` — PASS (61 files, 1,046 tests; executed outside sandbox because CLI smoke tests spawn Node)
- `pnpm typecheck` — PASS
- `pnpm lint` — PASS
- `pnpm format:check` — PASS
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests)
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-regen-command.spec.ts` — PASS (27 tests, including missing canonical roster)
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts -t "semantically invalid v1 roster"` — RED then PASS; verifies duplicate agent names are reported as `fleet.roster` exit 1 without a stack trace.
- Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage.
## Risks / blockers
- Dependency installation is required before executing Vitest, TypeScript, lint, and formatting gates.

View File

@@ -47,61 +47,6 @@ export MOSAIC_ADMIN_PASSWORD="securepass123"
mosaic gateway install
```
## Runtime launchers
```bash
mosaic claude # Launch Claude Code with Mosaic injection
mosaic yolo claude # …with --dangerously-skip-permissions
mosaic codex | opencode | pi
```
### `mosaic claudex` (EXPERIMENTAL)
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
local [`claude-code-proxy`](https://github.com/raine/claude-code-proxy) that
translates the Anthropic Messages API to a ChatGPT-subscription (Codex OAuth)
backend. This is **not Anthropic Claude** — model behavior, tool use, and output
quality may differ. Intended for evaluation, not production delivery.
```bash
mosaic claudex # launch (prompts through the proxy readiness gate)
mosaic yolo claudex # …with --dangerously-skip-permissions
mosaic claudex --print "hello" # trailing args are forwarded to Claude Code
```
**Prerequisite:** the `claude-code-proxy` binary must be installed and
authenticated (`claude-code-proxy codex auth …`). `mosaic claudex` runs a
preflight that verifies the binary, the OAuth state (triggering a device re-auth
if needed), and a trusted local listener before launching; it **fails closed**
if the proxy cannot be brought up with a verified identity.
**Isolation (never touches your real Claude state).** claudex always launches
against an isolated `CLAUDE_CONFIG_DIR` (default `~/.config/mosaic/claudex/home`).
The ambient `CLAUDE_CONFIG_DIR` is deliberately ignored, and a guard proves the
resolved dir can never be — or live under — the real `~/.claude`. A claudex
session therefore cannot mutate your normal Claude Code config.
**No token leakage.** claudex never reads the proxy's credential file. Claude
Code is handed only `ANTHROPIC_AUTH_TOKEN=unused` pointed at the loopback proxy;
the entire credential-bearing env family (`ANTHROPIC_*`, `AWS_*`, `GOOGLE_CLOUD_*`,
`GOOGLE_APPLICATION_CREDENTIALS`, `*_TOKEN`, `*_KEY`, `*_SECRET`, …) is stripped
from the composed environment. The Bedrock/Vertex routing switches
(`CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`, and the `_SKIP_*_AUTH`
pair) are force-removed regardless of value — otherwise their mere presence
would route Claude Code to the real Anthropic API via AWS/GCP and bypass the
proxy. The proxy holds the real OAuth credential.
**Model tiers (override via env).**
| Tier | Env var | Default |
| --------------------- | ---------------------------- | -------------- |
| primary (opus/sonnet) | `ANTHROPIC_MODEL` | `gpt-5.6-sol` |
| small/fast (haiku) | `ANTHROPIC_SMALL_FAST_MODEL` | `gpt-5.6-luna` |
Operator-provided values win over the defaults. Additional overrides:
`MOSAIC_CLAUDEX_CONFIG_DIR` (isolated config dir), `ANTHROPIC_BASE_URL` (proxy
endpoint).
## Hooks management
After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`.

View File

@@ -177,23 +177,15 @@ bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
## Universal Skills
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories.
```bash
mosaic sync # Full canonical catalog sync
mosaic skill list # Show registered, missing, dangling, and foreign entries
mosaic skill register <name> # Register or repair one canonical Claude link
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
mosaic sync # Full sync (clone + link)
~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
```
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosaic root through its launcher configuration. Codex parity remains follow-up scope and continues to use the existing full skill-sync linker.
## Health Audit
```bash

View File

@@ -5,20 +5,20 @@ Tool suites live at `~/.config/mosaic/tools/<suite>/`. This is the index only.
read it (or the relevant service guide) when your task actually touches that service.
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
## Most-used fleet tools (reach for these first)
## Most-used fleet tools (reach for these FIRST — don't hand-roll)
<!-- fleet-comms-contract: 1 -->
You are a Mosaic fleet agent. These cover the highest-frequency cross-agent and git-provider
tasks — use them before improvising with raw `tmux send-keys`, raw `tea`/`gh`/`glab`, or `curl`.
You are a Mosaic fleet agent. Use the runtime-composed **Fleet Comms — authoritative exact targets**
section for inter-agent messaging. It renders your authoritative local host, exact agent/session, resolved
tmux socket, installed helper path, generation, and one executable command per known peer.
**1. Message another agent**`tools/tmux/agent-send.sh` (NOT raw `tmux send-keys`):
Select only a peer row rendered for your exact roster identity. Never invent, substitute, or fuzzy-match
a host, session, socket, SSH destination, or helper path. If a peer is absent, stop and run the exact
self-scoped discovery command shown in that composed section; report the peer as unknown if it remains
absent. Do not use raw `tmux send-keys` for fleet messaging.
```bash
tools/tmux/agent-send.sh -s <target-session> -m "message" # or -f <file> to send a file's contents
```
**Issues / PRs / milestones**`tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
The coordinator session is `mos-claude` — send status, findings, and questions there.
**2. Issues / PRs / milestones**`tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
```bash
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...

View File

@@ -94,11 +94,11 @@
"type": "string"
},
"ssh": {
"description": "Explicit SSH target (normally user@host) for a cross-host inventory peer. Exact comms rendering requires this whenever the peer's resolved host differs from the current agent's host; the host value is never substituted as an SSH destination.",
"description": "SSH target (user@host) for a cross-host peer, so onboarding renders the `agent-send.sh -H <user@host>` form. Optional; only needed for agents on a different host than the fleet.",
"type": "string"
},
"socket": {
"description": "Optional compatibility declaration of the fleet-wide tmux socket. When present it must exactly equal tmux.socket_name; independent per-agent sockets are rejected because the local fleet runtime provisions every session on the fleet-wide socket.",
"description": "tmux socket the agent's session runs on. Onboarding renders `-L <socket>` when set; absent = the default socket (no `-L`). Must match the LIVE socket, not blindly inherit the roster's tmux.socket_name.",
"type": "string"
},
"working_directory": {
@@ -150,67 +150,29 @@
"description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
"type": "object",
"additionalProperties": false,
"oneOf": [
{
"properties": { "kind": { "const": "tmux" } },
"required": ["kind"],
"not": {
"anyOf": [{ "required": ["discord"] }, { "required": ["matrix"] }]
}
},
{
"properties": {
"kind": { "const": "discord" },
"discord": {
"type": "object",
"additionalProperties": false,
"required": ["channel_id"],
"properties": {
"channel_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
}
"kind": {
"enum": ["tmux", "discord", "matrix"]
},
"required": ["kind", "discord"],
"not": { "required": ["matrix"] }
},
{
"properties": {
"kind": { "const": "matrix" },
"matrix": {
"type": "object",
"additionalProperties": false,
"required": ["homeserver_url", "user_id", "room_id"],
"properties": {
"homeserver_url": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"user_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"room_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
"homeserver_url": { "type": "string" },
"user_id": { "type": "string" },
"room_id": { "type": "string" }
}
},
"required": ["kind", "matrix"],
"not": { "required": ["discord"] }
}
],
"discord": {
"type": "object",
"additionalProperties": false,
"required": ["channel_id"],
"properties": {
"kind": { "enum": ["tmux", "discord", "matrix"] },
"matrix": { "type": "object" },
"discord": { "type": "object" }
"channel_id": { "type": "string" }
}
}
}
}
}

View File

@@ -1,85 +0,0 @@
# Mosaic framework path-ownership manifest — SSOT for the updater.
#
# This single file is the source of truth consumed by BOTH the bash installer
# (packages/mosaic/framework/install.sh) and the TypeScript config adapter
# (packages/mosaic/src/config/file-adapter.ts). A parity test asserts both
# paths resolve the same ownership from this file, so the two can never drift
# (the failure mode that #631 patched by hand in two places).
#
# Format: one glob per line, relative to the mosaic home (~/.config/mosaic).
# - Lines starting with '#' and blank lines are ignored.
# - '[framework]' / '[operator]' switch the active section.
# - '**' matches any depth; '*' matches within a single path segment.
#
# Ownership resolution for a path P (deny-wins / fail-safe):
# 1. P matches an [operator] glob -> operator-owned.
# 2. else P matches a [framework] glob -> framework-owned.
# 3. else (matches neither) -> OPERATOR-OWNED BY DEFAULT.
#
# Rule 3 is the root-cause fix for #791: a path the manifest authors never
# anticipated is protected because UNKNOWN defaults to operator. The updater
# may only ever create/overwrite framework-owned paths, and may only prune a
# framework-owned path that lives inside a shipped framework subtree and is
# absent from the current framework source (a genuinely retired file).
# Operator-owned and unknown paths are structurally unreachable by pruning.
[framework]
# Top-level framework contract files (also reconciled from defaults/ on upgrade).
CONSTITUTION.md
AGENTS.md
STANDARDS.md
# Shipped framework subtrees — pruning is scoped to these roots.
adapters/**
constitution/**
CONTRIBUTING.md
defaults/**
examples/**
guides/**
install.sh
install.ps1
LICENSE
profiles/**
runtime/**
systemd/**
templates/**
tools/**
# Fleet: only the framework-seeded fleet subtrees are framework-owned.
fleet/README.md
fleet/examples/**
fleet/profiles/**
fleet/roles/**
fleet/roster.schema.json
fleet/services/**
# The manifest itself is framework-owned.
framework-manifest.txt
[operator]
# Identity / user-seeded contract files — generated by the wizard or seeded
# once from defaults/, then owned by the operator. Never overwritten on upgrade.
SOUL.md
USER.md
TOOLS.md
# Local overlays (tighten-only) authored by the operator.
*.local.md
# Operator-owned trees the updater must never write over or prune.
agents/**
policy/**
memory/**
sources/**
credentials/**
# Secret-bearing operator file INSIDE the framework-owned tools/ subtree.
# Listed explicitly so the deny-wins rule carves it out of tools/**.
tools/_lib/credentials.json
# Operator-owned fleet state (roster SSOT, per-agent env, heartbeats, backlog,
# persona overrides). Losing these silently downgrades a running fleet (#791).
fleet/roster.yaml
fleet/roster.json
fleet/agents/**
# Runtime state, incl. the #797 Runtime Session Ledger at fleet/run/sessions/
# (events.ndjson journal + ledger.json projection). This carve-out is the
# mechanism that makes the ledger upgrade-safe: an upgrade that wiped it would
# defeat its reason to exist. The HARD GATE (test-upgrade-manifest-guard.sh)
# proves a populated ledger survives byte-identical + mtime-unchanged.
fleet/run/**
fleet/backlog/**
fleet/roles.local/**

View File

@@ -1,10 +1,5 @@
#!/usr/bin/env bash
# -E (errtrace): the ERR trap must propagate INTO functions and command
# substitutions. Without it the `trap restore_snapshot ERR` set below is dead
# code for any failure inside sync_framework_keep() (its whole body runs in a
# function) — a mid-sync failure would abort with a half-written target and NO
# rollback (#791 B1). Keep -E first so every later function inherits the trap.
set -Eeuo pipefail
set -euo pipefail
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
#
@@ -24,19 +19,32 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# Shared framework path-ownership manifest reader (#791). Parity with
# 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
source "$SOURCE_DIR/tools/_lib/manifest.sh"
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
# denylist. It is derived from the shared framework-manifest.txt (#791): the
# updater only ever creates/overwrites framework-owned paths and only prunes a
# retired framework file inside a shipped framework subtree. Everything else —
# every operator file, and every path the manifest never anticipated — is
# operator-owned by default (fail-safe) and is never written or deleted. See
# sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts.
# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
# User-created content in these paths survives rsync --delete.
#
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
# so no per-file entry is needed; the preserved "fleet/*.yaml" glob is anchored to
# the top level only and does NOT shadow fleet/profiles/*.yaml). The user's
# own fleet files MUST
# survive `mosaic update` (which runs this sync automatically): the active
# roster (`fleet/roster.yaml` + any other `fleet/*.yaml`), per-agent env
# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
# wipes the operator's fleet AND their backlog. Glob entries are honored by
# both the rsync path (`--exclude`) and the glob-aware cp fallback below.
#
# fleet/roles.local — the persona OVERRIDE layer (H4). Baseline personas in
# fleet/roles/ are reseeded normally on every update (delivering new baseline
# personas), so any local edit there would be clobbered. User customizations
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
# packages/mosaic/src/commands/fleet-personas.ts).
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
# user must not edit them; a divergent copy is backed up once before overwrite).
@@ -67,267 +75,17 @@ step() { echo -e "\n${BOLD}$1${RESET}"; }
SNAPSHOT_DIR=""
make_snapshot() {
is_existing_install || return 0
# mktemp -d creates the dir 0700 — the snapshot (which mirrors operator config,
# possibly including secrets) is never world-readable.
SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")"
# The snapshot MUST be complete: restore rebuilds the target from it, so a
# partial capture (unreadable file, disk-full, I/O error) would silently
# discard whatever it missed. If cp -a cannot copy the whole tree, abort NOW —
# before the restore trap is armed and before anything is mutated. Fail closed
# rather than proceed with a snapshot we cannot trust (#791 blocker-2).
if ! cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/"; then
fail "Could not capture a complete pre-upgrade snapshot of $TARGET_DIR — aborting before any changes were made (fail-closed)."
rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""
exit 1
fi
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
}
restore_snapshot() {
# Disarm the trap first: restore runs under `set -e`, and a non-zero step
# inside it must not re-enter this handler (errtrace makes ERR fire in
# functions now). One restore attempt, then let the script exit non-zero.
trap - ERR INT TERM
[[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0
fail "Install interrupted/failed — restoring previous state from snapshot"
# Reset the target before rebuilding from the snapshot — but CHECK it. Under
# `set -e` (trap already disarmed) a bare `rm -rf; mkdir -p` that fails would
# exit the whole script immediately, after `rm` may have deleted part of the
# target, WITHOUT ever printing the recovery pointer below — the operator would
# be left with a half-removed target and no idea the snapshot survives in /tmp.
# Test the reset explicitly (like the cp -a below), and on failure keep the
# snapshot and tell the operator where it is (#791 blocker-D2).
if ! rm -rf "$TARGET_DIR" || ! mkdir -p "$TARGET_DIR"; then
fail "Snapshot restore could not reset $TARGET_DIR. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
return 1
fi
# Surface an incomplete restore instead of swallowing it: the snapshot is the
# last good copy, so if cp cannot fully rebuild the target we must NOT delete
# the snapshot — point the operator at it for manual recovery (#791 blocker-2).
if ! cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/"; then
fail "Snapshot restore did not complete cleanly. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
return 1
fi
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
}
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
# ─── durable operator-config snapshot (#791 PR2) ─────────────────────────────
# A SECOND, independent safety layer, distinct from SNAPSHOT_DIR above:
# • SNAPSHOT_DIR is ephemeral (/tmp, deleted on success) and mirrors the WHOLE
# target for CRASH rollback if the sync aborts mid-write.
# • DURABLE_SNAPSHOT_DIR is RETAINED, holds only the operator-owned surface, and
# lives OUTSIDE the framework tree and any repo. It exists for the failure the
# crash-rollback cannot see: a sync that finishes "successfully" yet a
# manifest/logic bug let it modify an operator file. verify_operator_surface()
# (post-sync) heals from it; `mosaic restore` recovers from it days later.
# Path convention is mirrored in packages/mosaic/src/commands/restore.ts — keep
# the two in sync (there is no shared code across the bash/TS boundary).
DURABLE_SNAPSHOT_DIR=""
backup_root() { printf '%s/mosaic/backups' "${XDG_STATE_HOME:-$HOME/.local/state}"; }
# Relative paths that a migration INTENTIONALLY removes from the target (e.g. the
# legacy bin/ tree). Such a path is operator-classified by the manifest (unknown⇒
# operator), so the durable snapshot captures it — but its post-migration absence
# is correct, NOT a manifest bug. run_migrations() records each removal here so
# verify_operator_surface() does not "heal" it back and silently undo the
# migration (which would then be skipped forever once the version is stamped).
MIGRATION_REMOVED_PATHS=()
# True (0) if $1 (a path relative to TARGET_DIR) equals or lives under a path a
# migration deliberately removed this run.
is_migration_removed() {
local rel="$1" removed
for removed in ${MIGRATION_REMOVED_PATHS[@]+"${MIGRATION_REMOVED_PATHS[@]}"}; do
[[ -n "$removed" ]] || continue
[[ "$rel" == "$removed" || "$rel" == "$removed"/* ]] && return 0
done
return 1
}
# True (0) if any parent directory of $1 (relative to TARGET_DIR) is a symlink.
# Restoring THROUGH a symlinked ancestor would let cp write snapshot contents —
# possibly secrets — outside the target (CWE-59), so the verify net refuses it.
has_symlinked_parent() {
local rel="$1" dir p seg
dir="$(dirname "$rel")"
[[ "$dir" == "." ]] && return 1
p="$TARGET_DIR"
local IFS='/'
for seg in $dir; do
[[ -n "$seg" ]] || continue
p="$p/$seg"
[[ -L "$p" ]] && return 0
done
return 1
}
# Emit (NUL-delimited, into file $1) the operator-owned relative paths that exist
# under TARGET_DIR, classified via the shared manifest (deny-wins; unknown⇒
# operator). Returns non-zero if the filesystem walk itself failed — we must
# NEVER snapshot from a truncated scan (a `< <(find …)` process substitution
# would hide that error; capture-then-check does not — cf. #791 blocker-D1).
enumerate_operator_files() {
local out="$1" scan abs rel
scan="$(mktemp)"
if ! find "$TARGET_DIR" -type f -print0 > "$scan"; then
rm -f "$scan"
return 1 # OP-SCAN-GUARD
fi
: > "$out"
while IFS= read -r -d '' abs; do
rel="${abs#"$TARGET_DIR"/}"
# Not operator config: version marker and any VCS metadata.
case "$rel" in .framework-version|.git|.git/*) continue ;; esac
manifest_is_framework "$rel" || printf '%s\0' "$rel" >> "$out"
done < "$scan"
rm -f "$scan"
}
# Retain only the newest MOSAIC_BACKUP_RETENTION (default 5) snapshots. The
# pre-update-<UTC-ts> names sort lexicographically = chronologically, so a
# reverse sort is newest-first. Pruning failures are non-fatal (they only leave
# extra old backups); the enclosing find's status is still honored, not swallowed.
prune_durable_snapshots() {
local root keep list d i=0
root="$(backup_root)"
keep="${MOSAIC_BACKUP_RETENTION:-5}"
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
list="$(mktemp)"
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
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
rm -f "$list"; return 0
fi
while IFS= read -r d; do
[[ -n "$d" ]] || continue
i=$((i + 1))
(( i > keep )) && rm -rf "$d"
done < "$list"
rm -f "$list"
}
# Take the durable pre-update snapshot BEFORE any mutation. Fail-OPEN: the durable
# snapshot is a recovery bonus on top of the manifest (which already keeps the
# sync out of operator paths) and the crash-rollback — so an un-writable backup
# location warns and continues rather than blocking the upgrade. Everything it
# creates is private (umask 077 + explicit 0700 dirs / 0600 files): the snapshot
# mirrors operator config, which may hold secrets, and must never be world-readable.
make_durable_snapshot() {
is_existing_install || return 0
local root ts dir list rel src dst count=0 old_umask
root="$(backup_root)"
# Fail-open if we cannot even stamp a timestamp: the durable snapshot is a
# recovery bonus and must never be the thing that aborts an upgrade.
ts="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || true)"
if [[ -z "$ts" ]]; then
warn "Durable snapshot skipped: no UTC timestamp available (upgrade continues)."
return 0
fi
# umask 077 makes every dir/file the snapshot creates private from birth (it
# mirrors operator config, which may hold secrets). It is PROCESS-global, so we
# save and restore it around exactly this block — otherwise every later sync
# copy and new framework dir would inherit 0600/0700 instead of 0644/0755.
old_umask="$(umask)"
umask 077
if ! mkdir -p "$root"; then
umask "$old_umask"
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
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"
fi
if ! mkdir -p "$dir"; then
umask "$old_umask"
warn "Durable snapshot skipped: cannot create $dir (upgrade continues)."
return 0
fi
chmod 700 "$dir"
list="$(mktemp)"
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
return 0
fi
while IFS= read -r -d '' rel; do
src="$TARGET_DIR/$rel"; dst="$dir/$rel"
[[ -f "$src" ]] || continue
mkdir -p "$(dirname "$dst")"
if ! cp "$src" "$dst"; then
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
continue
fi
chmod 600 "$dst" 2>/dev/null || true
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
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)"
prune_durable_snapshots
}
# Post-sync safety net: a keep-mode upgrade must NEVER modify an operator file.
# Compare every file in the durable snapshot to its current target counterpart;
# any that changed (or vanished) was touched by a framework bug — restore it from
# the snapshot and warn loudly. This does NOT abort: the framework itself synced
# correctly; we only heal the operator collateral. Runs after the restore trap is
# disarmed so its corrective copies can't spuriously trip a full rollback, and
# every step is guarded so `set -e` cannot exit silently mid-heal (cf. blocker-D2).
verify_operator_surface() {
[[ -n "$DURABLE_SNAPSHOT_DIR" && -d "$DURABLE_SNAPSHOT_DIR" ]] || return 0
local scan snap rel cur healed=0
scan="$(mktemp)"
if ! find "$DURABLE_SNAPSHOT_DIR" -type f -print0 > "$scan"; then
rm -f "$scan"
warn "Post-upgrade verify skipped: could not enumerate the pre-update snapshot at $DURABLE_SNAPSHOT_DIR."
return 0
fi
while IFS= read -r -d '' snap; do
rel="${snap#"$DURABLE_SNAPSHOT_DIR"/}"
cur="$TARGET_DIR/$rel"
# A migration may legitimately delete an operator-classified path (e.g. legacy
# bin/). Its absence is intended — do not heal it back, or the migration is
# silently undone and never re-runs once the version is stamped (#791 PR2).
is_migration_removed "$rel" && continue # MIGRATION-SKIP-GUARD
if [[ ! -e "$cur" ]] || ! cmp -s "$snap" "$cur"; then
# Never restore THROUGH a symlink: an operator path swapped for a link would
# otherwise let cp write snapshot contents (possibly secrets) outside the
# target (CWE-59). Refuse a symlinked parent; drop a symlinked leaf and write
# a real file in its place.
if has_symlinked_parent "$rel"; then
warn "Operator path '$rel' has a symlinked parent under $TARGET_DIR; refusing to restore through it (possible tampering) — recover it manually from $DURABLE_SNAPSHOT_DIR."
continue
fi
[[ -L "$cur" ]] && rm -f "$cur" # SYMLINK-LEAF-GUARD
# Guard mkdir too: under set -e (trap already disarmed) a bare failure would
# exit the whole installer before the recovery pointer below is emitted.
if ! mkdir -p "$(dirname "$cur")"; then
warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored (parent dir unavailable) — recover it manually from $DURABLE_SNAPSHOT_DIR."
continue
fi
if cp "$snap" "$cur"; then
chmod 600 "$cur" 2>/dev/null || true
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
healed=$((healed + 1))
else
warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored — recover it manually from $DURABLE_SNAPSHOT_DIR."
fi
fi
done < "$scan"
rm -f "$scan"
if (( healed > 0 )); then
warn "$healed operator file(s) were unexpectedly changed by this upgrade and were restored from the pre-update snapshot. A keep-mode upgrade must never modify operator files — this indicates a framework manifest bug; please report it (#791)."
fi
}
# Reconcile contract files after sync: framework-owned overwrite (backup-once),
# user-seeded seed-if-absent.
reconcile_framework_files() {
@@ -426,105 +184,63 @@ sync_framework() {
return
fi
if [[ "$INSTALL_MODE" == "keep" ]]; then
# The `mosaic update` path. Manifest-driven, never-deleting-outside-framework:
# operator config is structurally protected (#791). No rsync --delete here.
# The manifest is already loaded+validated in main() BEFORE the snapshot/trap
# (a fail-closed manifest must abort without ever restoring over operator
# files — see the pre-flight in main, #791 blocker-1).
sync_framework_keep
return
fi
# overwrite mode — a full replace, chosen only for a fresh install or when the
# operator explicitly asks to replace everything. No operator state to protect.
sync_framework_overwrite
}
# Enumerate a NUL-delimited file list via `find` into the temp file $1, failing
# CLOSED if find errors. We capture to a checked file instead of consuming
# `< <(find …)` directly because a process substitution discards the producer's
# exit status: an EACCES/I/O failure partway through a scan would truncate the
# list yet leave the reading `while` loop exiting 0, so a partial upgrade would
# commit and report success and the ERR/restore trap would never fire. Running
# find to completion first, then checking its status, turns that silent
# truncation into a fail-closed abort that the restore trap can act on (#791
# blocker-D1). $1 after the shift is the scan root — named in the error.
_scan_or_die() {
local out="$1"; shift
if ! find "$@" -print0 > "$out"; then
fail "Could not enumerate framework files under '$1' — aborting before committing an incomplete sync (fail-closed)."
return 1 # D1-GUARD
fi
}
# Keep-mode sync: create/refresh framework-owned files and prune only retired
# framework files inside shipped framework subtrees. Operator-owned and unknown
# paths (fail-safe default) are never written and never deleted — the #791 HARD
# GATE. Single code path (no rsync) so it is byte-for-byte parity-testable.
sync_framework_keep() {
local src="$SOURCE_DIR" dst="$TARGET_DIR" abs rel root list
# 1) Overlay copy — every framework-owned source file, refreshed only when its
# bytes changed (no mtime churn on unchanged files, never on operator files).
# The source scan is captured fail-closed (#791 blocker-D1): a find failure
# aborts the sync (→ ERR trap → restore) rather than silently truncating it.
list="$(mktemp)"
_scan_or_die "$list" "$src" -type f || { rm -f "$list"; return 1; }
while IFS= read -r -d '' abs; do
rel="${abs#"$src"/}"
case "$rel" in
.git|.git/*|.framework-version|*.pre-constitution.bak) continue ;;
esac
manifest_is_framework "$rel" || continue
if [[ -f "$dst/$rel" ]] && cmp -s "$abs" "$dst/$rel"; then continue; fi
[[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}"
cp "$abs" "$dst/$rel"
done < "$list"
rm -f "$list"
# 2) Scoped prune — within each shipped framework subtree root, remove
# framework-owned target files the current source no longer ships. Operator
# carve-outs (e.g. tools/_lib/credentials.json) resolve to operator and are
# skipped; unknown paths resolve to operator too — both are unreachable here.
# Each subtree scan is captured fail-closed for the same reason as the copy.
while IFS= read -r root; do
[[ -n "$root" && -d "$dst/$root" ]] || continue
list="$(mktemp)"
_scan_or_die "$list" "$dst/$root" -type f || { rm -f "$list"; return 1; }
while IFS= read -r -d '' abs; do
rel="${abs#"$dst"/}"
case "$rel" in *.pre-constitution.bak) continue ;; esac
[[ -f "$src/$rel" ]] && continue # still shipped
manifest_is_framework "$rel" || continue
rm -f "$abs"
done < "$list"
rm -f "$list"
# Drop framework dirs left empty by the prune (never touches a dir that still
# holds an operator file — those are never emptied). A genuine find failure
# (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
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
fi
done < <(manifest_subtree_roots)
}
# Overwrite-mode sync: full replace. Only reached for a fresh install or an
# explicit operator "replace everything" choice, so nothing is preserved.
sync_framework_overwrite() {
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete \
--exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \
"$SOURCE_DIR/" "$TARGET_DIR/"
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
if [[ "$INSTALL_MODE" == "keep" ]]; then
# Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL
# ~/.config/mosaic/<file> without also excluding defaults/<file> from sync
# (reconcile_framework_files needs the freshly-synced defaults/ copies).
for path in "${PRESERVE_PATHS[@]}"; do
rsync_args+=(--exclude "/$path")
done
fi
rsync "${rsync_args[@]}" "$SOURCE_DIR/" "$TARGET_DIR/"
return
fi
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \
! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \
-exec rm -rf {} +
# Fallback: cp-based sync. Glob-aware so entries like "fleet/*.yaml" preserve
# every matching user file (parity with the rsync --exclude path above).
local preserve_tmp=""
if [[ "$INSTALL_MODE" == "keep" ]]; then
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
local match rel
for path in "${PRESERVE_PATHS[@]}"; do
# Unquoted $path lets the glob expand against TARGET_DIR; nullglob makes a
# non-matching pattern vanish instead of staying literal.
shopt -s nullglob
for match in "$TARGET_DIR/"$path; do
[[ -e "$match" ]] || continue
rel="${match#"$TARGET_DIR/"}"
mkdir -p "$preserve_tmp/$(dirname "$rel")"
cp -R "$match" "$preserve_tmp/$rel"
done
shopt -u nullglob
done
fi
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} +
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
rm -rf "$TARGET_DIR/.git"
if [[ -n "$preserve_tmp" ]]; then
# Restore by re-globbing the SAME patterns against preserve_tmp, so each
# preserved item is restored at its own relative path (e.g. only
# fleet/roster.yaml is replaced — the freshly-synced fleet/examples stays).
for path in "${PRESERVE_PATHS[@]}"; do
shopt -s nullglob
for match in "$preserve_tmp/"$path; do
[[ -e "$match" ]] || continue
rel="${match#"$preserve_tmp/"}"
rm -rf "$TARGET_DIR/$rel"
mkdir -p "$TARGET_DIR/$(dirname "$rel")"
cp -R "$match" "$TARGET_DIR/$rel"
done
shopt -u nullglob
done
rm -rf "$preserve_tmp"
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
@@ -545,10 +261,6 @@ run_migrations() {
# Remove bin/ directory — all executables now live in the npm CLI.
# Scripts that were in bin/ are now in tools/_scripts/.
if [[ "$from_version" -lt 2 ]]; then
# bin/ and the rails symlink are operator-classified by the manifest (unknown⇒
# operator) and thus captured in the durable snapshot; record them as
# intentional removals so the post-sync verify net does not restore them.
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"
@@ -599,26 +311,9 @@ else
ok "Install mode: overwrite"
fi
# Pre-flight (keep mode): load + validate the framework manifest BEFORE taking a
# snapshot or arming the restore trap. A fail-closed manifest (missing / empty /
# malformed) must abort here WITHOUT deleting or restoring over operator files —
# the snapshot/restore path exists only for a genuine mid-sync mutation failure,
# not for a validation failure that has touched nothing yet (#791 blocker-1).
if [[ "$INSTALL_MODE" == "keep" ]]; then
manifest_load
# Durable, operator-scoped backup taken BEFORE any mutation (#791 PR2). Kept
# outside the framework tree; recovered later via `mosaic restore`. Fail-open.
make_durable_snapshot
fi
# Snapshot before any destructive file operation; restore on interrupt/failure.
# The trap MUST exit after restoring: a bash INT/TERM handler that merely returns
# does NOT terminate the script — execution would resume past the interrupt,
# clear the snapshot, and report success, leaving a partial post-interrupt update
# (#791 blocker-A). `restore_snapshot; exit 1` guarantees a non-zero exit for
# both the errtrace (ERR) and signal (INT/TERM) paths.
make_snapshot
trap 'restore_snapshot; exit 1' ERR INT TERM
trap 'restore_snapshot' ERR INT TERM
sync_framework
@@ -647,10 +342,6 @@ run_migrations
# File-system phase complete and consistent — clear the restore trap.
trap - ERR INT TERM
# Post-sync safety net: heal any operator file a manifest bug let the sync touch,
# using the durable pre-update snapshot (#791 PR2). Runs with the trap disarmed so
# a corrective copy can't spuriously trigger a full rollback.
verify_operator_surface # VERIFY-NET (#791 PR2)
cleanup_snapshot
# Testability / minimal-install hook: stop after the file-system phase, before any

View File

@@ -1,253 +0,0 @@
#!/usr/bin/env bash
# Shared bash reader for framework-manifest.txt (#791).
#
# This is the bash half of the SSOT ownership resolver; the TypeScript half is
# packages/mosaic/src/framework/manifest.ts. BOTH read the same
# framework-manifest.txt and MUST resolve identical ownership for any path — the
# parity test (manifest-parity.spec.ts) invokes this file's `resolve` CLI and
# compares it against the TS resolver, so the two can never drift (the #631
# two-copies failure class this closes).
#
# Ownership resolution (deny-wins / fail-safe):
# 1. operator glob matches -> operator
# 2. else framework glob -> framework
# 3. else -> operator (UNKNOWN defaults to operator, #791)
#
# Globs are compiled once at load into exact-prefix checks or POSIX EREs, so the
# hot resolver (manifest_is_framework) forks no subprocesses — the installer
# calls it once per file across the whole tree.
#
# Usage as a library (source it, then):
# manifest_load [manifest-file] # populates + compiles the manifest
# manifest_is_framework <rel-path> # rc 0 = framework-owned, rc 1 = operator
# manifest_resolve <rel-path> # echoes: framework | operator
# manifest_subtree_roots # echoes shipped framework `dir/**` roots
#
# Usage as a CLI (parity harness):
# bash manifest.sh resolve <rel-path>
# bash manifest.sh subtree-roots
# bash manifest.sh classify # reads paths on stdin -> "<own>\t<path>"
MANIFEST_FRAMEWORK=()
MANIFEST_OPERATOR=()
# Compiled forms (parallel arrays). _*_KIND[i] is "exact" or "re".
_MF_KIND=(); _MF_EXACT=(); _MF_RE=()
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
_MF_ROOTS=()
_manifest_default_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; }
# Normalize a path/glob: backslashes -> slashes, strip leading ./ and /, strip
# trailing / (mirrors normalizeRel in manifest.ts).
_manifest_norm() {
local p="$1"
p="${p//\\//}"
p="${p#./}"
while [[ "$p" == /* ]]; do p="${p#/}"; done
while [[ "$p" == */ ]]; do p="${p%/}"; done
printf '%s' "$p"
}
# Translate a normalized glob into a POSIX ERE body (mirrors globToRegExpBody).
_manifest_glob_to_ere() {
local pattern; pattern="$(_manifest_norm "$1")"
local out="" c n i len=${#pattern} trailing
for (( i = 0; i < len; i++ )); do
c="${pattern:i:1}"
if [[ "$c" == "*" ]]; then
n="${pattern:i+1:1}"
if [[ "$n" == "*" ]]; then
i=$((i + 1))
trailing=0
if [[ "${pattern:i+1:1}" == "/" ]]; then i=$((i + 1)); trailing=1; fi
if [[ "$out" == */ ]]; then
out="${out%/}(/.*)?"
elif [[ "$trailing" -eq 1 ]]; then
out="$out(.*/)?"
else
out="$out.*"
fi
else
out="$out[^/]*"
fi
else
case "$c" in
.|+|\?|^|\$|\{|\}|\(|\)|\||\[|\]|\\) out="$out\\$c" ;;
*) out="$out$c" ;;
esac
fi
done
printf '%s' "$out"
}
# Compile one raw glob into (kind, exact, re) appended to the given section.
# $1 = raw glob, $2 = section letter (F|O).
_manifest_compile_one() {
local norm; norm="$(_manifest_norm "$1")"
[[ -n "$norm" ]] || return 0
if [[ "$norm" == *"*"* ]]; then
local re="^$(_manifest_glob_to_ere "$norm")\$"
if [[ "$2" == F ]]; then
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
else
_MO_KIND+=(re); _MO_EXACT+=(""); _MO_RE+=("$re")
fi
else
if [[ "$2" == F ]]; then
_MF_KIND+=(exact); _MF_EXACT+=("$norm"); _MF_RE+=("")
else
_MO_KIND+=(exact); _MO_EXACT+=("$norm"); _MO_RE+=("")
fi
fi
[[ "$2" == F && "$norm" == */"**" ]] && _MF_ROOTS+=("${norm%/**}")
return 0
}
_manifest_compile() {
_MF_KIND=(); _MF_EXACT=(); _MF_RE=(); _MF_ROOTS=()
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
local g
for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done
for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done
# Explicit success: an empty operator array makes the final `[[ -n "" ]] && …`
# short-circuit to rc 1, which would otherwise become this function's (and
# manifest_load's) return code — a spurious failure (#791 B2). Never rely on
# the last loop's exit status here.
return 0
}
# Load + compile the manifest. Rejects a malformed file the same way
# parseManifest() does (entry before a section header / unknown header).
manifest_load() {
local file="${1:-}"
[[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt"
# Fail CLOSED on a missing/unreadable manifest. Without this, `done < "$file"`
# aborts on a raw redirection error with no explanation; downstream that reads
# as "no framework paths" and an upgrade could no-op silently (#791 B2/B3).
if [[ ! -r "$file" ]]; then
echo "manifest: cannot read manifest file: $file — refusing to sync (fail-closed)." >&2
return 1
fi
MANIFEST_FRAMEWORK=()
MANIFEST_OPERATOR=()
local section="" line
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line#"${line%%[![:space:]]*}"}" # ltrim
line="${line%"${line##*[![:space:]]}"}" # rtrim
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
case "$line" in
"[framework]") section=framework; continue ;;
"[operator]") section=operator; continue ;;
"["*) echo "manifest: unknown section header: $line" >&2; return 1 ;;
esac
if [[ -z "$section" ]]; then
echo "manifest: entry before any [section] header: $line" >&2
return 1
fi
if [[ "$section" == framework ]]; then
MANIFEST_FRAMEWORK+=("$line")
else
MANIFEST_OPERATOR+=("$line")
fi
done < "$file"
# An empty or comment-only manifest defines NO framework-owned paths. Treating
# that as valid would make every path resolve operator and an upgrade prune
# nothing / write nothing — a silent no-op indistinguishable from success.
# Fail loud instead, mirroring parseManifest()'s throw in manifest.ts (#791 B2).
if [[ ${#MANIFEST_FRAMEWORK[@]} -eq 0 ]]; then
echo "manifest: no [framework] entries in $file — refusing to sync (empty or malformed manifest)." >&2
return 1
fi
# An entry like `/` or `./` normalizes to nothing and compiles to a glob that
# matches no path — so a manifest whose only [framework] entries are degenerate
# passes the count guard above but leaves the framework matcher empty: every
# path resolves operator, the exact silent no-op we fail closed against. Require
# at least one entry with a real (non-slash, non-dot) character. Mirrors
# parseManifest()'s `isUsableFrameworkGlob` `/[^/.]/` test in manifest.ts (#791 blocker-B).
local _g _usable=0
for _g in "${MANIFEST_FRAMEWORK[@]:-}"; do
if [[ "$(_manifest_norm "$_g")" =~ [^/.] ]]; then _usable=1; break; fi
done
if [[ "$_usable" -eq 0 ]]; then
echo "manifest: no usable [framework] entries in $file (every entry is empty or a bare dot segment) — refusing to sync (malformed manifest)." >&2
return 1
fi
_manifest_compile
return 0
}
# Fork-free: does $1 (a mosaic-home-relative path) match an operator glob?
_mo_matches() {
local path="$1" i n=${#_MO_KIND[@]} re pat
for (( i = 0; i < n; i++ )); do
if [[ "${_MO_KIND[i]}" == exact ]]; then
pat="${_MO_EXACT[i]}"
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
else
re="${_MO_RE[i]}"
[[ "$path" =~ $re ]] && return 0
fi
done
return 1
}
# Fork-free: does $1 match a framework glob?
_mf_matches() {
local path="$1" i n=${#_MF_KIND[@]} re pat
for (( i = 0; i < n; i++ )); do
if [[ "${_MF_KIND[i]}" == exact ]]; then
pat="${_MF_EXACT[i]}"
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
else
re="${_MF_RE[i]}"
[[ "$path" =~ $re ]] && return 0
fi
done
return 1
}
# The installer hot path — no subshell. rc 0 = framework-owned, rc 1 = operator
# (deny-wins / fail-safe). Assumes an already-clean POSIX relative path.
manifest_is_framework() {
_mo_matches "$1" && return 1
_mf_matches "$1" && return 0
return 1
}
# Echo the ownership of a path: framework | operator. Normalizes first, so it is
# safe for CLI / test callers passing unnormalized input.
manifest_resolve() {
local path; path="$(_manifest_norm "$1")"
if manifest_is_framework "$path"; then echo framework; else echo operator; fi
}
# Echo each shipped framework subtree root (a `dir/**` entry, without the /**).
manifest_subtree_roots() {
local r
for r in "${_MF_ROOTS[@]:-}"; do [[ -n "$r" ]] && printf '%s\n' "$r"; done
}
# CLI dispatch — only when executed directly, never when sourced.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
set -o pipefail
# Propagate a fail-closed manifest_load (missing/empty/malformed) as a non-zero
# exit instead of continuing to resolve against empty compiled arrays — that is
# what lets the parity test assert bash and TS reject the same bad inputs (#791 B2).
manifest_load "${MANIFEST_FILE:-}" || exit 1
cmd="${1:-}"
case "$cmd" in
resolve) manifest_resolve "${2:?path required}" ;;
subtree-roots) manifest_subtree_roots ;;
classify)
while IFS= read -r p; do
[[ -z "$p" ]] && continue
printf '%s\t%s\n' "$(manifest_resolve "$p")" "$p"
done
;;
*)
echo "usage: manifest.sh {resolve <path>|subtree-roots|classify}" >&2
exit 2
;;
esac
fi

View File

@@ -161,7 +161,6 @@ link_targets=(
)
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")"
# Build an associative array from the colon-separated whitelist for O(1) lookup.
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
@@ -204,14 +203,7 @@ link_skill_into_target() {
link_path="$target_dir/$name"
if [[ -L "$link_path" ]]; then
local raw_target resolved_target
raw_target="$(readlink "$link_path")"
resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")"
if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then
ln -sfn "$skill_path" "$link_path"
else
echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path"
fi
return
fi
@@ -242,10 +234,14 @@ prune_stale_links_in_target() {
continue
fi
# -m resolves lexical dangling targets too. If resolution fails, ownership
# is unproven and the link must be preserved.
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
resolved="$(readlink -f "$link_path" 2>/dev/null || true)"
if [[ -z "$resolved" ]]; then
rm -f "$link_path"
echo "[mosaic-skills] Removed stale broken skill link: $link_path"
continue
fi
if [[ "$resolved" == "$MOSAIC_HOME/"* ]]; then
rm -f "$link_path"
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
fi

View File

@@ -79,26 +79,9 @@ function Link-SkillIntoTarget {
$linkPath = Join-Path $TargetDir $name
# Recreate only Mosaic-owned junctions/symlinks. Foreign reparse points are
# runtime-owned and must never be clobbered by install/upgrade auto-sync.
# Already a junction/symlink — recreate
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
$rawTarget = @($existing.Target)[0]
$candidate = if ([System.IO.Path]::IsPathRooted($rawTarget)) {
$rawTarget
}
else {
Join-Path (Split-Path $linkPath -Parent) $rawTarget
}
$resolvedTarget = [System.IO.Path]::GetFullPath($candidate)
$canonicalRoot = [System.IO.Path]::GetFullPath($MosaicSkillsDir).TrimEnd('\') + '\'
$localRoot = [System.IO.Path]::GetFullPath($MosaicLocalSkillsDir).TrimEnd('\') + '\'
$owned = $resolvedTarget.StartsWith($canonicalRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
$resolvedTarget.StartsWith($localRoot, [System.StringComparison]::OrdinalIgnoreCase)
if (-not $owned) {
Write-Host "[mosaic-skills] Preserve foreign runtime symlink: $linkPath"
return
}
Remove-Item $linkPath -Force
}
elseif ($existing) {

View File

@@ -70,8 +70,6 @@ Security vulnerability review focusing on:
~/.config/mosaic/tools/codex/codex-security-review.sh -n 42
```
PR mode resolves the provider's PR diff rather than relying on the caller's checked-out branch. On Gitea, it fetches the base and `refs/pull/<number>/head` refs and diffs those explicit refs. If the refs cannot be fetched or the resulting diff is empty, the command exits nonzero before Codex runs or a review is posted.
### Review Against Base Branch
```bash
@@ -255,7 +253,7 @@ Run the script from inside a git repository.
### "No changes found to review"
The specified non-PR mode (`--uncommitted`, `--base`, etc.) found no changes to review. PR mode instead fails closed with an actionable error when it cannot construct a non-empty provider diff; verify the PR number, remote, provider login, and ref access before retrying.
The specified mode (--uncommitted, --base, etc.) found no changes to review.
### "Codex produced no output"

View File

@@ -44,47 +44,38 @@ build_diff_context() {
diff_text=$(git show "$value" 2>/dev/null)
;;
pr)
# Provider detection writes its result to stdout; suppress it so it cannot
# be mistaken for diff content when this function is used in a substitution.
detect_platform >/dev/null
# For PRs, we need to fetch the PR diff
detect_platform
if [[ "$PLATFORM" == "github" ]]; then
diff_text=$(gh pr diff "$value" 2>/dev/null) || {
echo "Error: Failed to fetch the diff for PR #${value}." >&2
return 1
}
diff_text=$(gh pr diff "$value" 2>/dev/null)
elif [[ "$PLATFORM" == "gitea" ]]; then
local pr_base base_ref pr_head_ref
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | awk -v pr="$value" '$1 == pr { print $2; exit }')
if [[ -z "$pr_base" ]]; then
echo "Error: Could not resolve the base branch for Gitea PR #${value}." >&2
return 1
fi
base_ref="refs/remotes/origin/${pr_base}"
pr_head_ref="refs/remotes/origin/pr/${value}/head"
if ! git fetch --quiet origin \
"+refs/heads/${pr_base}:${base_ref}" \
"+refs/pull/${value}/head:${pr_head_ref}"; then
echo "Error: Failed to fetch the base and head refs for Gitea PR #${value}." >&2
return 1
fi
diff_text=$(git diff "${base_ref}...${pr_head_ref}") || {
echo "Error: Failed to diff the fetched refs for Gitea PR #${value}." >&2
return 1
}
# tea doesn't have a direct pr diff command, use git
local pr_base
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | grep "^${value}" | awk '{print $2}')
if [[ -n "$pr_base" ]]; then
diff_text=$(git diff "${pr_base}...HEAD" 2>/dev/null)
else
echo "Error: Unsupported git platform while resolving PR #${value}." >&2
return 1
# Fallback: fetch PR info via API
local repo_info
repo_info=$(get_repo_info)
local remote_url
remote_url=$(git remote get-url origin 2>/dev/null)
local host
host=$(echo "$remote_url" | sed -E 's|.*://([^/]+).*|\1|; s|.*@([^:]+).*|\1|')
diff_text=$(curl -s "https://${host}/api/v1/repos/${repo_info}/pulls/${value}" \
-H "Authorization: token $(tea login list --output simple 2>/dev/null | head -1 | awk '{print $2}')" \
2>/dev/null | jq -r '.diff_url // empty')
if [[ -n "$diff_text" && "$diff_text" != "null" ]]; then
diff_text=$(curl -s "$diff_text" 2>/dev/null)
else
diff_text=$(git diff "main...HEAD" 2>/dev/null)
fi
fi
fi
;;
esac
if [[ "$mode" == "pr" && -z "${diff_text//[[:space:]]/}" ]]; then
echo "Error: Unable to construct a non-empty diff for PR #${value}; verify the PR refs and provider access." >&2
return 1
fi
printf '%s\n' "$diff_text"
echo "$diff_text"
}
# Format JSON findings as markdown for PR comments

View File

@@ -1,158 +0,0 @@
#!/bin/bash
# Hermetic regression coverage for Gitea PR diff construction and fail-closed reviews.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "not ok - $*" >&2
exit 1
}
assert_contains() {
local haystack="$1" needle="$2"
if [[ "$haystack" != *"$needle"* ]]; then
printf 'actual output:\n%s\n' "$haystack" >&2
fail "expected output to contain: $needle"
fi
}
# Prevent CI-provided repository context from leaking into the fixture repositories.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \
GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR
export GIT_AUTHOR_NAME="Codex Fixture"
export GIT_AUTHOR_EMAIL="codex-fixture@example.test"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
export GITEA_LOGIN="fixture"
export GITEA_TOKEN="fixture-token"
export GITEA_URL="file://$TMP_DIR"
create_pr_fixture() {
local fixture_root="$1" head_mode="$2"
local origin="$fixture_root/origin.git"
local seed="$fixture_root/seed"
local work="$fixture_root/work"
local base_sha head_sha
mkdir -p "$fixture_root"
git init --quiet --bare "$origin"
git init --quiet --initial-branch=release/next "$seed"
printf 'base\n' > "$seed/pr-change.ts"
git -C "$seed" add pr-change.ts
git -C "$seed" commit --quiet -m "fixture base"
base_sha=$(git -C "$seed" rev-parse HEAD)
git -C "$seed" remote add origin "$origin"
git -C "$seed" push --quiet origin release/next
git --git-dir="$origin" symbolic-ref HEAD refs/heads/release/next
if [[ "$head_mode" == "changed" ]]; then
git -C "$seed" switch --quiet -c feature/pr-795
printf 'actual-pr-change\n' > "$seed/pr-change.ts"
git -C "$seed" commit --quiet -am "fixture PR head"
head_sha=$(git -C "$seed" rev-parse HEAD)
git -C "$seed" push --quiet origin HEAD:refs/pull/795/head
else
head_sha="$base_sha"
git --git-dir="$origin" update-ref refs/pull/795/head "$head_sha"
fi
# Gitea's provider-owned PR head ref now exists in the local bare origin.
git clone --quiet "$origin" "$work"
printf '%s\n' "$work"
}
FAKE_BIN="$TMP_DIR/bin"
mkdir -p "$FAKE_BIN"
cat > "$FAKE_BIN/tea" <<'STUB'
#!/bin/bash
if [[ "$*" == "pr list --fields index,base --output simple" ]]; then
printf '795 release/next\n'
exit 0
fi
exit 1
STUB
cat > "$FAKE_BIN/codex" <<'STUB'
#!/bin/bash
printf 'CODEX %s\n' "$*" >> "$CODEX_LOG"
exit 99
STUB
chmod +x "$FAKE_BIN/tea" "$FAKE_BIN/codex"
export PATH="$FAKE_BIN:$PATH"
# The valid fixture is a fresh clone on the non-main base. The PR head exists only
# at refs/pull/795/head, so local HEAD cannot accidentally satisfy the assertion.
if [[ "${1:-all}" != "fail-closed" ]]; then
VALID_WORK=$(create_pr_fixture "$TMP_DIR/valid" changed)
(
cd "$VALID_WORK"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
diff_context=$(build_diff_context pr 795)
assert_contains "$diff_context" "actual-pr-change"
base_sha=$(git rev-parse refs/remotes/origin/release/next)
head_sha=$(git rev-parse refs/remotes/origin/pr/795/head)
local_sha=$(git rev-parse HEAD)
[[ "$local_sha" == "$base_sha" ]] || fail "fixture clone is not on the PR base"
[[ "$head_sha" != "$base_sha" ]] || fail "fixture PR head does not differ from its base"
git show-ref --verify --quiet refs/remotes/origin/pr/795/head || \
fail "fetched PR head ref is missing"
[[ "$(git diff --name-only "${base_sha}...${head_sha}")" == "pr-change.ts" ]] || \
fail "explicit PR refs do not contain the fixture change"
if git show-ref --verify --quiet refs/heads/main || \
git show-ref --verify --quiet refs/remotes/origin/main; then
fail "fixture unexpectedly contains a main ref"
fi
)
echo "ok - Gitea PR mode fetches and diffs explicit non-main base and PR head refs"
fi
# Build an empty PR entirely inside another local repository. Both review wrappers
# must emit the PR-numbered error before Codex or the stubbed post path can execute.
if [[ "${1:-all}" != "pr-head" ]]; then
EMPTY_WORK=$(create_pr_fixture "$TMP_DIR/empty" empty)
SANDBOX="$TMP_DIR/sandbox"
mkdir -p "$SANDBOX/tools/codex/schemas" "$SANDBOX/tools/git"
cp "$SCRIPT_DIR/common.sh" \
"$SCRIPT_DIR/codex-code-review.sh" \
"$SCRIPT_DIR/codex-security-review.sh" \
"$SANDBOX/tools/codex/"
cp "$SCRIPT_DIR/schemas/code-review-schema.json" \
"$SCRIPT_DIR/schemas/security-review-schema.json" \
"$SANDBOX/tools/codex/schemas/"
cp "$SCRIPT_DIR/../git/detect-platform.sh" "$SANDBOX/tools/git/"
cat > "$SANDBOX/tools/git/pr-review.sh" <<'STUB'
#!/bin/bash
printf 'POST %s\n' "$*" >> "$POST_LOG"
STUB
chmod +x "$SANDBOX/tools/git/pr-review.sh"
POST_LOG="$TMP_DIR/post.log"
CODEX_LOG="$TMP_DIR/codex.log"
export POST_LOG CODEX_LOG
for review_kind in code security; do
: > "$POST_LOG"
: > "$CODEX_LOG"
review_script="$SANDBOX/tools/codex/codex-${review_kind}-review.sh"
set +e
(
cd "$EMPTY_WORK"
"$review_script" -n 795
) >"$TMP_DIR/${review_kind}.stdout" 2>"$TMP_DIR/${review_kind}.stderr"
review_status=$?
set -e
stderr_text=$(cat "$TMP_DIR/${review_kind}.stderr")
[[ "$review_status" -ne 0 ]] || fail "${review_kind} review returned success for an empty PR diff"
[[ ! -s "$CODEX_LOG" ]] || fail "Codex ran for an empty ${review_kind} PR diff"
[[ ! -s "$POST_LOG" ]] || fail "${review_kind} review auto-post ran for an empty PR diff"
assert_contains "$stderr_text" "Error:"
assert_contains "$stderr_text" "PR #795"
echo "ok - empty ${review_kind} PR diff fails closed before Codex and auto-post"
done
fi

View File

@@ -37,7 +37,7 @@ response=$(curl -sk -w "\n%{http_code}" \
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list computers (HTTP $http_code)" >&2
exit 1
fi

View File

@@ -1,84 +0,0 @@
#!/usr/bin/env bash
# Regression harness for #807: ranged GLPI list requests may return HTTP 206.
#
# Each shipped list wrapper must render a healthy 206 response and must retain
# its non-zero error behavior for a genuine HTTP failure.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/glpi-list-http-status}"
TOOL_DIR="$WORK_DIR/tools/glpi"
BIN_DIR="$WORK_DIR/bin"
rm -rf "$WORK_DIR"
mkdir -p "$TOOL_DIR" "$BIN_DIR" "$WORK_DIR/tools/_lib"
trap 'rm -rf "$WORK_DIR"' EXIT
for wrapper in ticket-list.sh computer-list.sh user-list.sh; do
cp "$SCRIPT_DIR/$wrapper" "$TOOL_DIR/$wrapper"
done
cat > "$WORK_DIR/tools/_lib/credentials.sh" <<'SH'
load_credentials() {
export GLPI_URL="https://glpi.test/apirest.php"
export GLPI_APP_TOKEN="test-app-token"
}
SH
cat > "$TOOL_DIR/session-init.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' 'test-session-token'
SH
chmod +x "$TOOL_DIR/session-init.sh"
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '[{"id":42,"priority":3,"status":1,"name":"Regression fixture","date_mod":"2026-07-16 10:00:00","serial":"SER-42","states_id":1,"realname":"Fixture","firstname":"GLPI","is_active":1}]'
printf '%s\n' "${GLPI_TEST_HTTP_CODE:?GLPI_TEST_HTTP_CODE is required}"
SH
chmod +x "$BIN_DIR/curl"
export MOSAIC_HOME="$WORK_DIR"
export PATH="$BIN_DIR:$PATH"
wrappers=(ticket-list.sh computer-list.sh user-list.sh)
resources=(tickets computers users)
headings=(PRIORITY SERIAL USERNAME)
fail=0
for index in "${!wrappers[@]}"; do
wrapper="${wrappers[$index]}"
resource="${resources[$index]}"
heading="${headings[$index]}"
path="$TOOL_DIR/$wrapper"
if ! output=$(GLPI_TEST_HTTP_CODE=206 bash "$path" 2>&1); then
echo "FAIL: $wrapper rejected healthy HTTP 206" >&2
fail=1
elif [[ "$output" != *"$heading"* || "$output" != *"Regression fixture"* ]]; then
echo "FAIL: $wrapper did not render the HTTP 206 list response" >&2
fail=1
else
echo "PASS: $wrapper renders HTTP 206"
fi
rc=0
output=$(GLPI_TEST_HTTP_CODE=401 bash "$path" 2>&1) || rc=$?
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: $wrapper accepted HTTP 401" >&2
fail=1
elif [[ "$output" != *"Error: Failed to list $resource (HTTP 401)"* ]]; then
echo "FAIL: $wrapper changed the HTTP failure diagnostic" >&2
fail=1
else
echo "PASS: $wrapper rejects HTTP 401"
fi
done
if [[ "$fail" -eq 0 ]]; then
echo "ALL PASS: test-list-http-status.sh"
fi
exit "$fail"

View File

@@ -55,7 +55,7 @@ response=$(curl -sk -w "\n%{http_code}" \
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list tickets (HTTP $http_code)" >&2
exit 1
fi

View File

@@ -37,7 +37,7 @@ response=$(curl -sk -w "\n%{http_code}" \
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list users (HTTP $http_code)" >&2
exit 1
fi

View File

@@ -61,36 +61,25 @@ MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve ALL user-owned
# fleet state — including an unanticipated file the manifest never names, which
# resolves to operator-owned by the #791 fail-safe — while refreshing the
# framework-owned schema/examples.
# F6 — keep-mode re-seed (the `mosaic update` path) MUST NOT wipe user fleet data.
# Regression for the roster-loss bug: fleet/ was not in PRESERVE_PATHS.
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
printf '{"version":1,"agents":[{"name":"json-user"}]}\n' > "$T6/fleet/roster.json"
printf 'version: 1\nagents:\n - name: not-active-roster\n' > "$T6/fleet/my-fleet.yaml"
printf 'version: 1\nagents:\n - name: custom\n' > "$T6/fleet/my-fleet.yaml"
printf 'ts=x\n' > "$T6/fleet/run/coder0.hb"
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$T6/fleet/agents/coder0.env"
printf '# stale preset\n' > "$T6/fleet/examples/general.yaml"
printf '{"stale":true}\n' > "$T6/fleet/roster.schema.json"
E6=$(mktemp -d)
cp "$T6/fleet/roster.yaml" "$E6/roster-yaml.expected"
cp "$T6/fleet/roster.json" "$E6/roster-json.expected"
cp "$T6/fleet/my-fleet.yaml" "$E6/my-fleet.expected"
cp "$T6/fleet/run/coder0.hb" "$E6/run.expected"
cp "$T6/fleet/agents/coder0.env" "$E6/agent.expected"
echo 3 > "$T6/.framework-version"
run "$T6" keep
chk "F6 reseed: exact roster.yaml bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.yaml' '$E6/roster-yaml.expected'"
chk "F6 reseed: exact roster.json bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.json' '$E6/roster-json.expected'"
chk "F6 reseed: unanticipated operator fleet file survives (fail-safe, #791)" "cmp -s '$T6/fleet/my-fleet.yaml' '$E6/my-fleet.expected'"
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'"
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
chk "F6 reseed: user roster.yaml SURVIVES keep-mode sync" "grep -q coder0 '$T6/fleet/roster.yaml'"
chk "F6 reseed: other user fleet/*.yaml survives (glob)" "[ -f '$T6/fleet/my-fleet.yaml' ]"
chk "F6 reseed: per-agent env (fleet/agents) survives" "[ -f '$T6/fleet/agents/coder0.env' ]"
chk "F6 reseed: heartbeat run dir (fleet/run) survives" "[ -f '$T6/fleet/run/coder0.hb' ]"
chk "F6 reseed: framework examples ARE refreshed (not preserved stale)" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
chk "F6 reseed: framework roster.schema.json seeded" "[ -f '$T6/fleet/roster.schema.json' ]"
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6"
echo
echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ]

View File

@@ -1,110 +0,0 @@
#!/usr/bin/env python3
"""Regression checks for connector-kind-conditional fleet roster schema."""
import json
import sys
from pathlib import Path
from jsonschema import Draft202012Validator
schema_path = Path(__file__).resolve().parents[3] / "fleet" / "roster.schema.json"
schema = json.loads(schema_path.read_text(encoding="utf-8"))
validator = Draft202012Validator(schema)
base = {
"version": 1,
"transport": "tmux",
"agents": [{"name": "orchestrator", "runtime": "pi"}],
}
valid = [
{"kind": "tmux"},
{"kind": "discord", "discord": {"channel_id": "123"}},
{
"kind": "matrix",
"matrix": {
"homeserver_url": "https://matrix.example",
"user_id": "@mosaic:example",
"room_id": "!room:example",
},
},
]
invalid = [
{"kind": "tmux", "discord": {"channel_id": "123"}},
{"kind": "tmux", "matrix": {}},
{"kind": "discord"},
{"kind": "discord", "matrix": {}},
{
"kind": "discord",
"discord": {"channel_id": "123"},
"matrix": {
"homeserver_url": "https://matrix.example",
"user_id": "@mosaic:example",
"room_id": "!room:example",
},
},
{"kind": "matrix"},
{"kind": "matrix", "discord": {"channel_id": "123"}},
{"kind": "discord", "discord": {"channel_id": ""}},
{"kind": "discord", "discord": {"channel_id": " "}},
{
"kind": "matrix",
"matrix": {
"homeserver_url": "",
"user_id": "@mosaic:example",
"room_id": "!room:example",
},
},
{
"kind": "matrix",
"matrix": {
"homeserver_url": "\t",
"user_id": "@mosaic:example",
"room_id": "!room:example",
},
},
{
"kind": "matrix",
"matrix": {
"homeserver_url": "https://matrix.example",
"user_id": "",
"room_id": "!room:example",
},
},
{
"kind": "matrix",
"matrix": {
"homeserver_url": "https://matrix.example",
"user_id": " ",
"room_id": "!room:example",
},
},
{
"kind": "matrix",
"matrix": {
"homeserver_url": "https://matrix.example",
"user_id": "@mosaic:example",
"room_id": "",
},
},
{
"kind": "matrix",
"matrix": {
"homeserver_url": "https://matrix.example",
"user_id": "@mosaic:example",
"room_id": "\n",
},
},
]
for connector in valid:
errors = list(validator.iter_errors({**base, "connector": connector}))
if errors:
print(f"expected valid connector {connector}: {errors}", file=sys.stderr)
raise SystemExit(1)
for connector in invalid:
if not list(validator.iter_errors({**base, "connector": connector})):
print(f"expected invalid connector: {connector}", file=sys.stderr)
raise SystemExit(1)
print("connector schema regression: PASS")

View File

@@ -1,313 +0,0 @@
#!/usr/bin/env bash
# test-upgrade-durable-snapshot.sh — the #791 PR2 regression gate.
#
# PR1 gave keep-mode upgrades two protections: the manifest (a keep-sync only
# ever writes framework-owned paths — operator config is structurally untouched)
# and an EPHEMERAL /tmp snapshot that rolls the whole target back if the sync
# CRASHES mid-write. PR2 adds a third, independent layer for the case neither
# covers: a "successful" upgrade that a manifest/logic bug silently let touch an
# operator file. That layer is a DURABLE, operator-scoped pre-update snapshot:
#
# Part 1 (scope): before any mutation, the installer copies exactly the
# operator-owned files that exist into a retained backup
# under $XDG_STATE_HOME/mosaic/backups/pre-update-<ts>/ —
# framework files are NOT captured.
# Part 2 (perms): the backup root, snapshot dir and every nested dir are
# 0700; every backed-up file is 0600 (never world-readable,
# even though operator config may hold secrets).
# Part 3 (no leak): a secret seeded into credentials.json is copied into the
# snapshot (proving coverage) but its value never appears
# on stdout/stderr — the snapshot reports counts/paths only.
# Part 4 (retention): only the newest MOSAIC_BACKUP_RETENTION snapshots survive;
# older ones are pruned.
# Part 5 (verify net): if the upgrade DID modify an operator file (injected here
# with a cp shim that scribbles on SOUL.md while a framework
# file is copied), the post-sync verify restores that file
# from the durable snapshot and warns loudly. The control —
# the same installer with the verify call stripped — leaves
# the corruption in place, proving the net is load-bearing.
#
# Usage: bash test-upgrade-durable-snapshot.sh
set -uo pipefail
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
INSTALL="$FW/install.sh"
ORIG_PATH="$PATH"
FRAMEWORK_VERSION="$(grep -m1 '^FRAMEWORK_VERSION=' "$INSTALL" | cut -d= -f2)"
# Control installers must live INSIDE $FW: install.sh derives SOURCE_DIR from its
# own path and sources tools/_lib/manifest.sh relative to it, so a copy anywhere
# else aborts before the sync. Each control is a shipped installer with one guard
# line stripped (keyed off a `# <MARKER>` anchor), proving that guard load-bearing.
# All controls share the .install-*.tmp.sh glob so one trap sweeps them on exit.
VERIFYCTRL="$FW/.install-verifynet-control.tmp.sh"
rm -f "$FW"/.install-*.tmp.sh
trap 'rm -f "$FW"/.install-*.tmp.sh' EXIT
# mk_control <marker-regex> <name> — echo a control installer path ($FW-local) that
# is $INSTALL with every line matching /<marker-regex>/ deleted.
mk_control() {
local path="$FW/.install-$2.tmp.sh"
sed "/$1/d" "$INSTALL" > "$path"
printf '%s' "$path"
}
pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
SECRET='SUPER-SECRET-TOKEN-do-not-log-pr2'
SOUL_ORIG='# persona'
# A framework file the sync copies (source ships it; the seeded target omits it,
# so the bytes differ and cp is attempted). The Part-5 shim keys off this path.
POISON_REL='guides/E2E-DELIVERY.md'
# Seed a recognized keep-mode install holding four operator-owned files across
# the identity file, an operator subtree, memory, and the credentials carve-out.
seed_home() {
local H="$1"
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory"
printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md" # recognized install → keep mode
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
printf '# operator memory\n' > "$H/memory/note.md"
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
echo 3 > "$H/.framework-version"
# Deliberately NO guides/E2E-DELIVERY.md so the sync copies it (framework file,
# bytes differ) — that copy is where the Part-5 corruption shim fires.
}
# A pre-v2 (legacy) keep-mode install: SOUL.md marks it recognized, and a bin/
# tree with NO .framework-version makes installed_framework_version() report 1, so
# the v1→v2 migration (which deletes bin/) runs. bin/ is unknown⇒operator, so the
# durable snapshot captures it — the verify net must NOT heal the intended removal.
seed_home_v1() {
local H="$1"
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" "$H/bin"
printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md"
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
printf '#!/bin/sh\necho legacy\n' > "$H/bin/tool.sh"; chmod +x "$H/bin/tool.sh"
# Deliberately NO .framework-version and NO guides/E2E-DELIVERY.md (see seed_home).
}
# A cp shim that, while the framework POISON file is being copied during sync,
# swaps the operator credentials file for a symlink pointing at an attacker-
# readable file OUTSIDE the target — simulating post-snapshot tampering (CWE-59).
# The durable snapshot already holds the real credentials (it is taken before any
# sync), so the verify net must restore a REAL file in place WITHOUT following the
# link (which would write the snapshot's secret out through it). $EXFIL_TARGET is
# expanded at shim-write time from the caller's environment.
#
# PORTABILITY (why this shim, not the real `cp`): the CWE-59 leak this exercises is
# `cp` writing THROUGH a symlinked destination. GNU/BSD cp — what a real operator
# runs `mosaic update` under — follows the dest symlink and leaks. busybox cp (the
# Alpine CI image) REPLACES a symlinked dest instead of following it, so under the
# CI harness the leak vector simply does not exist and the negative control could
# never reproduce it. This shim therefore emulates the real-target GNU cp behavior
# PORTABLY: when the destination is a symlink it writes the source bytes through the
# link via redirection (which follows symlinks on every coreutils, busybox included);
# otherwise it delegates to the host's real cp unchanged. Both the shipped-case and
# the negative control run through this identical shim, so the ONLY difference
# between them remains the SYMLINK-LEAF-GUARD — the control stays load-bearing and
# non-tautological. It does NOT touch install.sh (approved) or the real assertions:
# with the guard present the symlinked leaf is dropped BEFORE this cp runs, so the
# dest is a real file and the delegate path is taken exactly as on a GNU host.
make_symlink_leaf_shim() {
local dir="$1" home="$2"
cat > "$dir/cp" <<SHIM
#!/usr/bin/env bash
dest="\${@: -1}"
src="\${@:(-2):1}"
case "\$dest" in
*/$POISON_REL)
rm -f "$home/tools/_lib/credentials.json"
ln -s "$EXFIL_TARGET" "$home/tools/_lib/credentials.json"
;;
esac
# Coreutils-agnostic emulation of GNU cp's follow-through-dest-symlink behavior.
if [[ -L "\$dest" && -f "\$src" ]]; then
cat "\$src" > "\$dest"
exit \$?
fi
exec env PATH="$ORIG_PATH" cp "\$@"
SHIM
chmod +x "$dir/cp"
}
# A cp shim that, while the framework POISON file is being copied during sync,
# also appends garbage to the operator SOUL.md — simulating a manifest bug that
# writes outside the framework lane. The framework copy itself still succeeds
# (real cp runs), so the sync completes 0 and the post-sync verify is what must
# catch and undo the operator-file damage. The snapshot's own cp only ever
# targets operator files (never guides/…), so it is never corrupted by this shim.
make_corrupt_shim() {
local dir="$1" home="$2"
cat > "$dir/cp" <<SHIM
#!/usr/bin/env bash
dest="\${@: -1}"
case "\$dest" in
*/$POISON_REL) printf 'CORRUPTION-mid-sync\n' >> "$home/SOUL.md" 2>/dev/null || true ;;
esac
exec env PATH="$ORIG_PATH" cp "\$@"
SHIM
chmod +x "$dir/cp"
}
# Run one keep-mode, sync-only upgrade with $XDG_STATE_HOME redirected to a
# throwaway dir (so the real ~/.local/state is never touched). Optional args:
# $2 shim-maker (default none), $3 MOSAIC_BACKUP_RETENTION (default unset).
# Echoes: "<exit>\t<out>\t<state-dir>\t<home>".
# $4 seeder (default seed_home) — swap in seed_home_v1 for the migration case.
run_snap() {
local installer="$1" shim_maker="${2:-}" retention="${3:-}" seeder="${4:-seed_home}" H STATE OUT SHIM rc pathpre
H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp); pathpre="$ORIG_PATH"
"$seeder" "$H"
if [[ -n "$shim_maker" ]]; then
SHIM=$(mktemp -d); "$shim_maker" "$SHIM" "$H"; pathpre="$SHIM:$ORIG_PATH"
fi
set +e
env PATH="$pathpre" XDG_STATE_HOME="$STATE" \
${retention:+MOSAIC_BACKUP_RETENTION="$retention"} \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \
bash "$installer" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
[[ -n "$shim_maker" ]] && rm -rf "$SHIM"
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H"
}
# Resolve the single pre-update-* snapshot dir under a state dir (newest if many).
snap_dir() {
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
| LC_ALL=C sort -r | head -1
}
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL")
SNAP="$(snap_dir "$STATE")"
chk "upgrade succeeds" "[ '$rc' -eq 0 ]"
chk "exactly one pre-update snapshot created" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 1 ]"
chk "snapshot: SOUL.md captured" "[ -f '$SNAP/SOUL.md' ]"
chk "snapshot: operator subtree captured" "[ -f '$SNAP/agents/coder0.conf' ]"
chk "snapshot: memory captured" "[ -f '$SNAP/memory/note.md' ]"
chk "snapshot: credentials carve-out captured" "[ -f '$SNAP/tools/_lib/credentials.json' ]"
chk "snapshot: SOUL.md bytes preserved" "[ \"\$(cat '$SNAP/SOUL.md')\" = '$SOUL_ORIG' ]"
chk "snapshot: framework file NOT captured" "[ ! -e '$SNAP/CONSTITUTION.md' ] && [ ! -e '$SNAP/$POISON_REL' ]"
# Part 2 — permissions (0700 dirs, 0600 files); never world-readable.
chk "perms: backup root is 0700" "[ \$(stat -c '%a' '$STATE/mosaic/backups') -eq 700 ]"
chk "perms: snapshot dir is 0700" "[ \$(stat -c '%a' '$SNAP') -eq 700 ]"
chk "perms: nested dir is 0700" "[ \$(stat -c '%a' '$SNAP/agents') -eq 700 ]"
chk "perms: credentials backup is 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]"
chk "perms: SOUL.md backup is 0600" "[ \$(stat -c '%a' '$SNAP/SOUL.md') -eq 600 ]"
# Part 3 — the secret is backed up but never emitted to stdout/stderr.
chk "no-leak: secret IS in the backup file" "grep -q '$SECRET' '$SNAP/tools/_lib/credentials.json'"
chk "no-leak: secret NOT on stdout/stderr" "! grep -q '$SECRET' '$OUT'"
rm -rf "$STATE" "$H"; rm -f "$OUT"
echo "── Part 4: retention prune (MOSAIC_BACKUP_RETENTION) ───────────────────"
# Pre-seed four dated snapshots, then take one real snapshot with retention=2:
# only the two newest (the fresh real one + the newest pre-seeded) must survive.
IFS=$'\t' read -r rc OUT STATE H < <(
H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp)
seed_home "$H"
mkdir -p "$STATE/mosaic/backups"
for ts in 20200101T000000Z 20210101T000000Z 20220101T000000Z 20230101T000000Z; do
mkdir -p "$STATE/mosaic/backups/pre-update-$ts"
done
set +e
env PATH="$ORIG_PATH" XDG_STATE_HOME="$STATE" MOSAIC_BACKUP_RETENTION=2 \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \
bash "$INSTALL" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H"
)
chk "retention: upgrade succeeds" "[ '$rc' -eq 0 ]"
chk "retention: pruned to exactly 2 snapshots" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 2 ]"
chk "retention: newest pre-seeded survives" "[ -d '$STATE/mosaic/backups/pre-update-20230101T000000Z' ]"
chk "retention: oldest pre-seeded pruned" "[ ! -d '$STATE/mosaic/backups/pre-update-20200101T000000Z' ]"
rm -rf "$STATE" "$H"; rm -f "$OUT"
echo "── Part 5: post-sync verify restores an operator file (+ control) ──────"
# Shipped installer: the cp shim corrupts SOUL.md mid-sync; verify must restore it.
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_corrupt_shim)
chk "verify: upgrade still succeeds" "[ '$rc' -eq 0 ]"
chk "verify: SOUL.md restored to original" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]"
chk "verify: no corruption remains in SOUL.md" "! grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'"
chk "verify: loud restore warning emitted" "grep -qi 'restored from the pre-update snapshot' '$OUT'"
chk "verify: secret still not leaked" "! grep -q '$SECRET' '$OUT'"
rm -rf "$STATE" "$H"; rm -f "$OUT"
# Control: strip the verify call → the corruption must SURVIVE (net is load-bearing).
sed '/# VERIFY-NET/d' "$INSTALL" > "$VERIFYCTRL"
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$VERIFYCTRL" make_corrupt_shim)
chk "control: SOUL.md corruption survives" "grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'"
chk "control: no restore warning emitted" "! grep -qi 'restored from the pre-update snapshot' '$OUT'"
rm -rf "$STATE" "$H"; rm -f "$OUT"
echo "── Part 6: verify net honors an intentional migration removal (+ control) ─"
# BLOCKER regression: on a pre-v2 install, bin/ is operator-classified so the durable
# snapshot captures it — but the v1→v2 migration deletes bin/ ON PURPOSE. The verify
# net must SKIP that removal (is_migration_removed), or it heals bin/ back and the
# migration is silently undone forever once the version is stamped.
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" "" "" seed_home_v1)
chk "migration: upgrade succeeds" "[ '$rc' -eq 0 ]"
chk "migration: legacy bin/ stays removed" "[ ! -e '$H/bin' ]"
chk "migration: operator SOUL.md untouched" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]"
chk "migration: version stamped to $FRAMEWORK_VERSION" "[ \"\$(cat '$H/.framework-version')\" = '$FRAMEWORK_VERSION' ]"
rm -rf "$STATE" "$H"; rm -f "$OUT"
# Control: strip the MIGRATION-SKIP-GUARD → the verify net restores bin/ from the
# snapshot, silently undoing the migration (proves the guard is load-bearing).
MIGCTRL="$(mk_control 'MIGRATION-SKIP-GUARD' migration-control)"
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$MIGCTRL" "" "" seed_home_v1)
chk "control: bin/ wrongly restored by verify" "[ -e '$H/bin/tool.sh' ]"
rm -rf "$STATE" "$H"; rm -f "$OUT"
echo "── Part 7: verify net never restores a secret through a symlink (+ control) ─"
# HIGH (CWE-59) regression: an attacker who swaps an operator file for a symlink
# AFTER the durable snapshot must not cause the verify net's restore to write the
# snapshot's secret out THROUGH that link. The shipped net drops a symlinked leaf and
# writes a real file in its place, leaving the external target untouched.
EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen"
printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET"
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_symlink_leaf_shim)
chk "symlink-leaf: upgrade succeeds" "[ '$rc' -eq 0 ]"
chk "symlink-leaf: secret NOT written through link" "! grep -q '$SECRET' '$EXFIL_TARGET'"
chk "symlink-leaf: credentials.json is a real file" "[ -f '$H/tools/_lib/credentials.json' ] && [ ! -L '$H/tools/_lib/credentials.json' ]"
chk "symlink-leaf: credentials.json restored intact" "grep -q '$SECRET' '$H/tools/_lib/credentials.json'"
chk "symlink-leaf: secret not leaked to stdout/stderr" "! grep -q '$SECRET' '$OUT'"
rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT"
# Control: strip the SYMLINK-LEAF-GUARD → cp follows the swapped-in link and writes
# the snapshot secret out through it (proves the guard is load-bearing).
EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen"
printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET"
LEAFCTRL="$(mk_control 'SYMLINK-LEAF-GUARD' symlinkleaf-control)"
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$LEAFCTRL" make_symlink_leaf_shim)
chk "control: secret leaked through the symlink" "grep -q '$SECRET' '$EXFIL_TARGET'"
rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT"
echo "── Part 8: snapshot umask 077 does not leak into synced files (+ control) ──"
# SHOULD-FIX regression: umask 077 is process-global. Scoped to the snapshot it keeps
# backups 0600; leaked past it, every later cp/mkdir inherits 0600/0700. A freshly-
# synced framework file must be 0644 (per the ambient 022 umask) while the backup of
# a secret stays 0600.
IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$INSTALL")
SNAP="$(snap_dir "$STATE")"
chk "umask: upgrade succeeds" "[ '$rc' -eq 0 ]"
chk "umask: synced framework file is 0644" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 644 ]"
chk "umask: backup of a secret stays 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]"
rm -rf "$STATE" "$H"; rm -f "$OUT"
# Control: strip the UMASK-RESTORE-NORMAL line → umask 077 leaks past the snapshot,
# so the newly-synced framework file is created 0600 (proves the restore matters).
UMASKCTRL="$(mk_control 'UMASK-RESTORE-NORMAL' umask-control)"
IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$UMASKCTRL")
chk "control: leaked umask makes synced file 0600" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 600 ]"
rm -rf "$STATE" "$H"; rm -f "$OUT"
echo ""
echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ]

View File

@@ -1,231 +0,0 @@
#!/usr/bin/env bash
# test-upgrade-manifest-guard.sh — the #791 HARD GATE.
#
# Proves that a keep-mode framework upgrade (the `mosaic update` path:
# install.sh with MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1) touches NO path
# outside the framework-owned manifest. Every operator-owned sentinel — including
# a deliberately UNANTICIPATED one the manifest never names — must survive
# byte-identical with an unchanged mtime (not even rewritten). Framework files
# must still update, and a retired framework file inside a shipped subtree must
# still be pruned. No operator secret value may appear in installer output.
#
# Keep mode is a SINGLE code path (sync_framework_keep, a manifest-driven cp
# overlay + scoped prune — no rsync). The matrix still runs twice, once with
# rsync on PATH and once with it hidden, to prove the keep path is genuinely
# rsync-independent: it must obey the manifest identically whether or not rsync
# happens to be installed (rsync --delete is only ever used by overwrite mode,
# which has no operator state to protect).
#
# It also runs a fail-closed matrix (#791 B2/B3): an empty, operator-only,
# malformed, or missing manifest must ABORT the upgrade loudly and leave every
# operator path untouched — never silently no-op to "complete".
#
# Usage: bash test-upgrade-manifest-guard.sh
set -uo pipefail
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
INSTALL="$FW/install.sh"
pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
# Redirect the #791 PR2 durable pre-update snapshot ($XDG_STATE_HOME/mosaic/backups)
# into a throwaway so a keep-mode upgrade under test never writes into the real
# ~/.local/state. This test asserts operator-surface fidelity, not backup content.
export XDG_STATE_HOME
XDG_STATE_HOME="$(mktemp -d)"
trap 'rm -rf "$XDG_STATE_HOME"' EXIT
SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a'
# Seed a throwaway MOSAIC_HOME with an operator sentinel per ownership class.
seed_home() {
local H="$1"
mkdir -p "$H/agents" "$H/policy" "$H/memory" "$H/tools/_lib" \
"$H/fleet/agents" "$H/fleet/run/sessions" "$H/harvester" \
"$H/unknown-operator-dir" "$H/guides"
printf '# persona\n' > "$H/SOUL.md" # marks a recognized existing install → keep mode
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
printf '# operator policy\n' > "$H/policy/custom.md"
printf '# soul overlay\n' > "$H/SOUL.local.md"
printf '# operator memory\n' > "$H/memory/note.md"
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$H/fleet/agents/coder0.env"
printf 'version: 2\nagents:\n - name: coder0\n' > "$H/fleet/roster.yaml"
printf '# harvester SOP\n' > "$H/harvester/sop.md"
printf 'operator data the manifest never anticipated\n' > "$H/unknown-operator-dir/x"
printf 'version: 1\nagents:\n - name: mine\n' > "$H/fleet/my-fleet.yaml"
# #797 Runtime Session Ledger (Mos-elevated to a #791 PR1 merge-blocker): a
# populated ledger under fleet/run/sessions/ must survive the upgrade — a
# runtime ledger an upgrade rsync can wipe is worthless. Seed it exactly as
# #797 writes it: a non-empty append journal + a non-empty compacted
# projection, files 0600 under a 0700 dir.
printf '%s\n%s\n%s\n' \
'{"seq":1,"kind":"session.spawn","node":"sess-42","generation":7}' \
'{"seq":2,"kind":"lease.grant","node":"sess-42","lease":"web1"}' \
'{"seq":3,"kind":"dispatch.create","from":"sess-42","to":"disp-9"}' \
> "$H/fleet/run/sessions/events.ndjson"
printf '%s\n' \
'{"generation":7,"nodes":[{"id":"sess-42","kind":"session"}],"edges":[{"from":"sess-42","to":"disp-9","kind":"dispatch"}]}' \
> "$H/fleet/run/sessions/ledger.json"
chmod 0700 "$H/fleet/run" "$H/fleet/run/sessions"
chmod 0600 "$H/fleet/run/sessions/events.ndjson" "$H/fleet/run/sessions/ledger.json"
# A retired framework file inside a shipped subtree (absent from source) — must be pruned.
printf '# retired guide\n' > "$H/guides/RETIRED-OLD-GUIDE.md"
echo 3 > "$H/.framework-version"
}
OPERATOR_SENTINELS=(
"agents/coder0.conf"
"policy/custom.md"
"SOUL.local.md"
"memory/note.md"
"tools/_lib/credentials.json"
"fleet/agents/coder0.env"
"fleet/roster.yaml"
"harvester/sop.md"
"unknown-operator-dir/x"
"fleet/my-fleet.yaml"
# #797 Runtime Session Ledger — populated journal + projection must survive.
"fleet/run/sessions/events.ndjson"
"fleet/run/sessions/ledger.json"
)
run_matrix() {
local label="$1"; shift # extra env / PATH override applied to the run
local H E OUT rel before_hash after_hash before_mt after_mt
H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
seed_home "$H"
# Snapshot hash + mtime of every operator sentinel before the upgrade.
for rel in "${OPERATOR_SENTINELS[@]}"; do
sha256sum "$H/$rel" | awk '{print $1}' > "$E/$(echo "$rel" | tr / _).hash"
stat -c %Y "$H/$rel" > "$E/$(echo "$rel" | tr / _).mt"
done
# Snapshot the ledger directory permission bits (#797 assert: perms unchanged).
local before_dirperm after_dirperm
before_dirperm=$(stat -c %a "$H/fleet/run/sessions")
# The upgrade under test (keep + sync-only = the `mosaic update` reseed path).
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 "$@" bash "$INSTALL" >"$OUT" 2>&1
# HARD GATE: every operator sentinel survives byte-identical AND mtime-unchanged.
for rel in "${OPERATOR_SENTINELS[@]}"; do
before_hash=$(cat "$E/$(echo "$rel" | tr / _).hash")
before_mt=$(cat "$E/$(echo "$rel" | tr / _).mt")
after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
after_mt=$(stat -c %Y "$H/$rel" 2>/dev/null || echo MISSING)
chk "[$label] operator sentinel survives byte-identical: $rel" \
"[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
chk "[$label] operator sentinel not rewritten (mtime unchanged): $rel" \
"[ '$before_mt' = '$after_mt' ]"
done
# #797 assert 7: the ledger directory's permission bits are unchanged.
after_dirperm=$(stat -c %a "$H/fleet/run/sessions" 2>/dev/null || echo MISSING)
chk "[$label] ledger dir perms unchanged (#797): $before_dirperm" \
"[ '$before_dirperm' = '$after_dirperm' ]"
# Positive controls / negative controls — prove the test discriminates: the
# upgrade DOES write and prune framework-owned paths, so the operator sentinels
# (incl. the #797 ledger) survive because of the manifest, not because the
# upgrade is a no-op.
chk "[$label] positive control: framework file present after upgrade (guides synced)" \
"[ -f '$H/guides/E2E-DELIVERY.md' ]"
chk "[$label] negative control: retired framework file inside a subtree IS pruned" \
"[ ! -f '$H/guides/RETIRED-OLD-GUIDE.md' ]"
chk "[$label] manifest itself is installed" "[ -f '$H/framework-manifest.txt' ]"
# Secret-safety: the operator secret value never appears in installer output.
chk "[$label] operator secret value absent from installer stdout/stderr" \
"! grep -q '$SECRET' '$OUT'"
rm -rf "$H" "$E" "$OUT"
}
# Fail-closed matrix (#791 B2/B3 + blocker-1): run install.sh from a COPY of the
# framework so the shipped manifest can be corrupted. Every corruption must abort
# the upgrade non-zero with a manifest error, leaving all operator sentinels
# byte-identical AND on the SAME inode. The inode check is the load-bearing part:
# manifest validation is hoisted BEFORE make_snapshot/the restore trap, so a bad
# manifest must abort without ever snapshotting, deleting, and restoring the
# target. Were validation still armed under the ERR trap, restore_snapshot would
# rm -rf + rebuild the target — same bytes but a NEW inode (broken hard links,
# changed ctime), which a content-only hash would miss (#791 blocker-1).
run_failclosed() {
local label="$1" mutate="$2"
local SRC H E OUT rc rel before_hash after_hash before_ino after_ino key
SRC=$(mktemp -d); H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
cp -a "$FW/." "$SRC/"
case "$mutate" in
empty) : > "$SRC/framework-manifest.txt" ;;
operator-only) printf '[operator]\nSOUL.md\n*.local.md\n' > "$SRC/framework-manifest.txt" ;;
malformed) printf 'stray.md\n[framework]\nguides/**\n' > "$SRC/framework-manifest.txt" ;;
degenerate) printf '[framework]\n/\n./\n[operator]\nSOUL.md\n' > "$SRC/framework-manifest.txt" ;;
missing) rm -f "$SRC/framework-manifest.txt" ;;
esac
seed_home "$H"
for rel in "${OPERATOR_SENTINELS[@]}"; do
key=$(echo "$rel" | tr / _)
sha256sum "$H/$rel" | awk '{print $1}' > "$E/$key.hash"
stat -c '%i' "$H/$rel" > "$E/$key.ino"
done
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$SRC/install.sh" >"$OUT" 2>&1
rc=$?
chk "[fail-closed:$label] upgrade aborts non-zero" "[ '$rc' -ne 0 ]"
chk "[fail-closed:$label] refuses loudly with a manifest error" \
"grep -qi 'manifest' '$OUT'"
for rel in "${OPERATOR_SENTINELS[@]}"; do
key=$(echo "$rel" | tr / _)
before_hash=$(cat "$E/$key.hash")
after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
chk "[fail-closed:$label] operator sentinel untouched: $rel" \
"[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
before_ino=$(cat "$E/$key.ino")
after_ino=$(stat -c '%i' "$H/$rel" 2>/dev/null)
chk "[fail-closed:$label] operator sentinel not deleted/recreated (inode stable): $rel" \
"[ -n '$after_ino' ] && [ '$before_ino' = '$after_ino' ]"
done
chk "[fail-closed:$label] operator secret value absent from output" \
"! grep -q '$SECRET' '$OUT'"
chmod -R u+w "$SRC" "$H" 2>/dev/null || true
rm -rf "$SRC" "$H" "$E" "$OUT"
}
echo "#791 upgrade manifest guard (HARD GATE):"
# 1) rsync path (if available on this host).
if command -v rsync >/dev/null 2>&1; then
run_matrix "rsync"
else
echo " · rsync not installed — skipping rsync-path matrix"
fi
# 2) rsync-absent path — hide rsync behind a scratch PATH. Keep mode never calls
# rsync, so this must resolve identically to run (1); it proves the keep path
# does not silently depend on rsync being installed. (Provide the coreutils the
# installer needs on the stripped PATH.)
FBIN=$(mktemp -d)
for t in bash cp find mktemp rm mkdir chmod cmp sed grep cat dirname basename stat sha256sum awk tr date sort; do
p=$(command -v "$t" 2>/dev/null) && ln -s "$p" "$FBIN/$t"
done
run_matrix "rsync-absent" env "PATH=$FBIN"
rm -rf "$FBIN"
# 3) fail-closed matrix (#791 B2/B3) — corrupt the shipped manifest four ways.
run_failclosed "empty-manifest" empty
run_failclosed "operator-only" operator-only
run_failclosed "malformed-manifest" malformed
run_failclosed "degenerate-framework" degenerate
run_failclosed "missing-manifest" missing
echo
echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ]

View File

@@ -1,319 +0,0 @@
#!/usr/bin/env bash
# test-upgrade-rollback.sh — the #791 B1 regression gate.
#
# A keep-mode upgrade takes a pre-update snapshot and installs an ERR/INT/TERM
# trap that restores it if the sync aborts midway (install.sh: make_snapshot +
# `trap restore_snapshot`). That trap is only reached if `set -E` (errtrace) is
# active — otherwise a failure INSIDE sync_framework_keep() (which runs entirely
# in a function) never fires the trap, and the upgrade aborts leaving a
# half-written target with NO rollback. This test proves:
#
# Part A (the gate): the shipped installer rolls back a mid-sync failure —
# the restore message fires, the corrupted file is put
# back, AND the whole target is byte-identical to its
# pre-upgrade state.
# Part B (the control): the SAME installer with `-E` stripped does NOT roll back
# (dead trap) — the mid-sync corruption survives, proving
# errtrace is load-bearing. If anyone removes `set -E`,
# Part A goes red.
#
# The mid-sync failure is injected with a PATH-shadowing `cp` shim rather than
# file permissions. The earlier 0400/EACCES approach was NOT portable: Woodpecker
# runs steps as root (node:24-alpine has no USER directive), and root overwrites a
# 0400 file, so the failure never fired and this gate silently passed (#791
# blocker-3). The shim fails deterministically for one framework-owned
# destination regardless of uid, and — like a real interrupted cp (disk-full
# mid-write) — leaves a partially-written target behind, so rollback has real
# damage to undo and the control has real damage to expose.
#
# Usage: bash test-upgrade-rollback.sh
set -uo pipefail
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
INSTALL="$FW/install.sh"
ORIG_PATH="$PATH"
# The `-E`-stripped control installer must live INSIDE $FW: install.sh derives
# SOURCE_DIR from its own path and `source`s $SOURCE_DIR/tools/_lib/manifest.sh,
# so a copy anywhere else aborts at the source line before ever reaching the sync
# loop — which would make the control a false negative. A root dotfile is
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
STRIPPED="$FW/.install-rollback-control.tmp.sh"
NOEXIT="$FW/.install-noexit-control.tmp.sh"
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
SECRET='SUPER-SECRET-TOKEN-do-not-log-b1'
# A framework-owned file the shim fails the copy of. The seeded target holds GOOD
# bytes; source ships different bytes, so sync_framework_keep() attempts the copy
# and the shim intercepts it. Root-level framework files sort before guides/, so
# several framework files are already refreshed when the copy reaches this one.
POISON_REL='guides/E2E-DELIVERY.md'
GOOD='GOOD-REFERENCE-CONTENT-pre-upgrade-b1'
GARBAGE='PARTIAL-WRITE-GARBAGE-mid-sync-b1'
# A `cp` shim: for the poisoned destination, simulate an interrupted copy — write
# partial garbage to the target, then fail — otherwise delegate to the real cp
# (resolved via the ORIGINAL PATH so make_snapshot/restore still work).
make_cp_shim() {
local dir="$1"
cat > "$dir/cp" <<SHIM
#!/usr/bin/env bash
dest="\${@: -1}"
case "\$dest" in
*/$POISON_REL)
printf '%s' '$GARBAGE' > "\$dest" 2>/dev/null || true
exit 1 ;;
esac
exec env PATH="$ORIG_PATH" cp "\$@"
SHIM
chmod +x "$dir/cp"
}
# A `find` shim that fails every enumeration scan (`-print0`) as if it hit an
# EACCES/I/O error partway — it emits the real (here: complete) list first, then
# exits non-zero, exactly the class of failure a `< <(find …)` process
# substitution silently swallows. All non-`-print0` finds (e.g. the -delete
# sweep) delegate to the real find on the original PATH. Used to prove #791
# blocker-D1: the shipped installer must honor find's exit status and roll back.
make_find_fail_shim() {
local dir="$1"
cat > "$dir/find" <<SHIM
#!/usr/bin/env bash
for a in "\$@"; do
if [ "\$a" = "-print0" ]; then
env PATH="$ORIG_PATH" find "\$@" # emit the real list…
exit 1 # …then fail as if the scan hit EACCES
fi
done
exec env PATH="$ORIG_PATH" find "\$@"
SHIM
chmod +x "$dir/find"
}
# An `rm` shim that fails ONLY `rm -rf <FAIL_RM_TARGET>` (the restore's target
# reset) and delegates every other rm to the real one. Used to prove #791
# blocker-D2: when the target reset inside restore_snapshot fails, the installer
# must emit the manual-recovery pointer (snapshot path) instead of exiting
# silently under `set -e`. FAIL_RM_TARGET is exported into the installer env.
make_rm_fail_shim() {
local dir="$1"
cat > "$dir/rm" <<'SHIM'
#!/usr/bin/env bash
last="${@: -1}"
if [ -n "${FAIL_RM_TARGET:-}" ] && [ "$last" = "$FAIL_RM_TARGET" ]; then
exit 1
fi
exec env PATH="$ORIG_PATH_FOR_RM" rm "$@"
SHIM
chmod +x "$dir/rm"
}
seed_home() {
local H="$1"
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" "$H/guides"
printf '# persona\n' > "$H/SOUL.md" # recognized install → keep mode + snapshot
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
printf '# operator memory\n' > "$H/memory/note.md"
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
echo 3 > "$H/.framework-version"
# Good pre-upgrade bytes; source ships different bytes, so cp is attempted.
printf '%s' "$GOOD" > "$H/$POISON_REL"
}
# Run one keep-mode upgrade against $1=installer, seeding a fresh home and a
# byte-for-byte reference of the pre-upgrade state, with the cp shim first on
# PATH. Echoes: "<exit>\t<out>\t<ref>\t<home>".
run_upgrade() {
local installer="$1" shim_maker="${2:-make_cp_shim}" H REF OUT SHIM rc
H=$(mktemp -d); REF=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
seed_home "$H"
env PATH="$ORIG_PATH" cp -a "$H/." "$REF/" # pre-upgrade reference (real cp)
"$shim_maker" "$SHIM"
set +e
PATH="$SHIM:$ORIG_PATH" \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
rm -rf "$SHIM"
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$REF" "$H"
}
echo "#791 upgrade rollback (B1 regression gate):"
# ── Part A: the shipped installer must roll back a mid-sync failure ───────────
IFS=$'\t' read -r rcA OUTA REFA HA < <(run_upgrade "$INSTALL")
chk "[shipped] upgrade aborts non-zero on the injected mid-sync failure" \
"[ '$rcA' -ne 0 ]"
chk "[shipped] restore_snapshot fires (rollback message present)" \
"grep -q 'restoring previous state from snapshot' '$OUTA'"
chk "[shipped] the corrupted file is restored to its pre-upgrade bytes" \
"[ \"\$(cat '$HA/$POISON_REL')\" = '$GOOD' ]"
chk "[shipped] target rolled back byte-identical to pre-upgrade state" \
"diff -r '$REFA' '$HA' >/dev/null 2>&1"
chk "[shipped] operator secret value absent from installer output" \
"! grep -q '$SECRET' '$OUTA'"
# ── Part B: control — strip `-E`, the trap is dead, no rollback happens ───────
# Proves errtrace is what makes the trap reachable. If `set -E` is ever removed
# from install.sh, Part A's rollback assertions fail exactly like this control.
sed 's/^set -Eeuo pipefail/set -euo pipefail/' "$INSTALL" > "$STRIPPED"
chk "[control] the -E strip actually changed the installer" \
"! cmp -s '$INSTALL' '$STRIPPED'"
IFS=$'\t' read -r rcB OUTB REFB HB < <(run_upgrade "$STRIPPED")
chk "[control] without -E the upgrade still aborts non-zero" \
"[ '$rcB' -ne 0 ]"
# The load-bearing, deterministic proof of B1: without errtrace the ERR trap
# never fires for a failure inside sync_framework_keep(), so no rollback runs.
chk "[control] without -E the rollback message does NOT fire (dead trap)" \
"! grep -q 'restoring previous state from snapshot' '$OUTB'"
chk "[control] without -E the mid-sync corruption survives (no rollback)" \
"[ \"\$(cat '$HB/$POISON_REL')\" = '$GARBAGE' ]"
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
# A bash signal trap that merely returns lets the script continue past the
# interrupt — restoring the snapshot, then resuming the sync and reporting
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
# fires and ONLY the signal path governs), and assert the shipped installer
# restores AND exits without reporting success. The control strips `exit 1` from
# the trap and shows the buggy resume-to-success.
make_term_shim() {
local dir="$1"
cat > "$dir/cp" <<SHIM
#!/usr/bin/env bash
dest="\${@: -1}"
case "\$dest" in
*/$POISON_REL)
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
exec env PATH="$ORIG_PATH" cp "\$@" ;;
esac
exec env PATH="$ORIG_PATH" cp "\$@"
SHIM
chmod +x "$dir/cp"
}
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
run_signal_upgrade() {
local installer="$1" H OUT SHIM rc
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
seed_home "$H"
make_term_shim "$SHIM"
set +e
PATH="$SHIM:$ORIG_PATH" \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
rm -rf "$SHIM"
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
}
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
"[ '$rcC' -ne 0 ]"
chk "[signal] restore_snapshot fires on the interrupt" \
"grep -q 'restoring previous state from snapshot' '$OUTC'"
chk "[signal] does NOT resume to report sync success after the interrupt" \
"! grep -q 'file phase complete' '$OUTC'"
# Control: strip `exit 1` from the signal trap → the handler returns, the script
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
"$INSTALL" > "$NOEXIT"
chk "[control] the exit-strip actually changed the installer" \
"! cmp -s '$INSTALL' '$NOEXIT'"
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
"grep -q 'file phase complete' '$OUTD'"
# ── Part D: a failed source/prune `find` scan must abort + roll back (D1) ─────
# A `< <(find …)` process substitution discards find's exit status, so an
# EACCES/I/O failure mid-scan would truncate the file list yet leave the reading
# loop exiting 0 — a partial upgrade committed and reported as success, with the
# ERR/restore trap never firing. The shipped installer captures the scan into a
# checked temp file (_scan_or_die) and aborts on failure. We inject a `find` that
# fails every `-print0` scan and assert the shipped installer rolls back.
IFS=$'\t' read -r rcE OUTE REFE HE < <(run_upgrade "$INSTALL" make_find_fail_shim)
chk "[find-fail] a failing framework scan aborts the upgrade non-zero" \
"[ '$rcE' -ne 0 ]"
chk "[find-fail] restore_snapshot fires on the aborted scan" \
"grep -q 'restoring previous state from snapshot' '$OUTE'"
chk "[find-fail] the abort is a fail-closed enumeration error (not a silent truncation)" \
"grep -q 'Could not enumerate framework files' '$OUTE'"
chk "[find-fail] target rolled back byte-identical to pre-upgrade state" \
"diff -r '$REFE' '$HE' >/dev/null 2>&1"
# Control: neuter the D1 guard (turn its `return 1` into a no-op) so a find
# failure is swallowed exactly as `< <(find …)` would — the scan appears to
# succeed and the upgrade reports completion with NO rollback.
sed 's/return 1 # D1-GUARD/: # D1-GUARD-DISABLED/' "$INSTALL" > "$D1CTRL"
chk "[control] the D1-guard strip actually changed the installer" \
"! cmp -s '$INSTALL' '$D1CTRL'"
IFS=$'\t' read -r _rcF OUTF REFF HF < <(run_upgrade "$D1CTRL" make_find_fail_shim)
chk "[control] with the D1 guard disabled the find failure is swallowed (no rollback)" \
"! grep -q 'restoring previous state from snapshot' '$OUTF'"
chk "[control] with the D1 guard disabled the upgrade wrongly reports success" \
"grep -q 'file phase complete' '$OUTF'"
# ── Part E: a failed target reset inside restore must not exit silently (D2) ──
# restore_snapshot resets the target (`rm -rf; mkdir -p`) before rebuilding from
# the snapshot. Under `set -e` (trap disarmed) a bare reset that fails would exit
# the whole script immediately — after `rm` may have deleted part of the target —
# WITHOUT printing where the snapshot lives. We trigger a rollback (cp poison) AND
# fail the target reset (rm shim), then assert the shipped installer emits the
# manual-recovery pointer and preserves the snapshot.
run_rmfail_upgrade() {
local installer="$1" H OUT SHIM rc
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
seed_home "$H"
make_cp_shim "$SHIM" # poison cp → triggers the abort + restore
make_rm_fail_shim "$SHIM" # rm -rf <H> fails → exercises the D2 reset guard
set +e
PATH="$SHIM:$ORIG_PATH" ORIG_PATH_FOR_RM="$ORIG_PATH" FAIL_RM_TARGET="$H" \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
rm -rf "$SHIM"
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
}
IFS=$'\t' read -r rcG OUTG HG < <(run_rmfail_upgrade "$INSTALL")
chk "[reset-fail] a failed target reset still aborts non-zero" \
"[ '$rcG' -ne 0 ]"
chk "[reset-fail] the manual-recovery pointer is emitted (not a silent set -e exit)" \
"grep -q 'Snapshot restore could not reset' '$OUTG'"
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
SNAP_E="$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG" | head -1)"
chk "[reset-fail] the named snapshot directory actually survives for recovery" \
"[ -n '$SNAP_E' ] && [ -d '$SNAP_E' ]"
chk "[reset-fail] operator secret value never appears in installer output" \
"! grep -q '$SECRET' '$OUTG'"
# Control: delete the D2 recovery line so a failed reset returns non-zero with NO
# operator pointer — the observable defect (half-reset target, snapshot orphaned
# in /tmp with no path told to the operator). Proves the message is load-bearing.
sed '/Snapshot restore could not reset/d' "$INSTALL" > "$D2CTRL"
chk "[control] the D2-recovery strip actually changed the installer" \
"! cmp -s '$INSTALL' '$D2CTRL'"
IFS=$'\t' read -r _rcH OUTH HH < <(run_rmfail_upgrade "$D2CTRL")
chk "[control] without the D2 recovery line the operator gets no snapshot pointer" \
"! grep -q 'Snapshot restore could not reset' '$OUTH'"
[ -n "${SNAP_E:-}" ] && rm -rf "$SNAP_E"
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
echo
echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ]

View File

@@ -122,11 +122,12 @@ fi
# Source label: this agent's host:session (auto-detected, overridable).
if [ -z "$SRC_LABEL" ]; then
src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=${MOSAIC_AGENT_NAME:-}
if [ -z "$src_sess" ]; then
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
tmux_cmd=(tmux)
if [ -n "$SOCKET_NAME" ]; then
tmux_cmd+=(-L "$SOCKET_NAME")
fi
src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=$("${tmux_cmd[@]}" display-message -p '#S' 2>/dev/null || echo "?")
SRC_LABEL="${src_host}:${src_sess}"
fi

View File

@@ -14,9 +14,6 @@
# 5. invalid class => exit 3, nothing sent.
# 6. --class with no value => exit 3.
# 7. the documented consumer regex parses producer output for every class.
# 8. MOSAIC_AGENT_NAME is authoritative for sender identity.
# 9. sender fallback queries local tmux, never the destination -L socket.
# 10. an undeterminable sender is stamped as "?".
set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
@@ -24,45 +21,22 @@ TOOL="$HERE/agent-send.sh"
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
STUB=$(mktemp)
FAKE_BIN=$(mktemp -d)
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT
trap 'rm -f "$STUB"' EXIT
cat >"$STUB" <<'STUB_EOF'
#!/usr/bin/env bash
set -uo pipefail
b64=""
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
while getopts "t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
printf '%s' "$b64" | base64 -d
STUB_EOF
chmod +x "$STUB"
# Fake tmux distinguishes the sender's default socket from a destination socket.
cat >"$FAKE_BIN/tmux" <<'TMUX_EOF'
#!/usr/bin/env bash
set -uo pipefail
case "${FAKE_TMUX_MODE:-sessions}" in
unavailable) exit 1 ;;
sessions)
if [ "${1:-}" = "-L" ]; then
printf '%s\n' 'destination-holder'
else
printf '%s\n' 'local-agent'
fi
;;
esac
TMUX_EOF
chmod +x "$FAKE_BIN/tmux"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
# Run the tool with the stub injected; echoes captured payload on stdout.
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
run_auto() {
env -u MOSAIC_AGENT_NAME \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -n dsthost "$@"
}
# Documented consumer grammar — the daemon will mirror exactly this.
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction)\] (.*)$'
@@ -118,30 +92,6 @@ classic=$(run -s mos -m "plain body")
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
# 8. Exported pane identity wins even when dispatch targets another tmux socket.
src_host=$(hostname -s)
got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity")
want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
got=$(FAKE_TMUX_MODE=sessions run_auto -L destination-socket -s mos -m "local fallback")
want="[$src_host:local-agent -> dsthost:mos] local fallback"
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
[[ "$got" != *":destination-holder ->"* ]] \
&& ok "cross-socket fallback rejects destination holder identity" \
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
got=$(FAKE_TMUX_MODE=unavailable run_auto -L destination-socket -s mos -m "unknown fallback")
want="[$src_host:? -> dsthost:mos] unknown fallback"
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
echo "---"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]

View File

@@ -24,8 +24,7 @@
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/codex/test-pr-diff-context.sh"
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -53,7 +52,6 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^2.0.0",
"@types/react": "^18.3.0",
"tsx": "^4.0.0",
"typescript": "^5.8.0",

View File

@@ -1,5 +1,3 @@
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { Command } from 'commander';
import { registerBrainCommand } from '@mosaicstack/brain';
@@ -18,8 +16,6 @@ import { registerConfigCommand } from './commands/config.js';
// without throwing. This is the "mosaic <cmd> --help exits 0" gate that
// guards the sub-package CLI surface (CU-05-01..08) from silent breakage.
const CLI_PATH = fileURLToPath(new URL('../dist/cli.js', import.meta.url));
const REGISTRARS: Array<[string, (program: Command) => void]> = [
['auth', registerAuthCommand],
['brain', registerBrainCommand],
@@ -50,40 +46,6 @@ describe('sub-package CLI smoke (CU-05-10)', () => {
});
}
it.each(['source', 'decisions', 'observations'] as const)(
'production CLI emits one blocked JSON object for bare --%s',
(bareOption) => {
const args = [
CLI_PATH,
'fleet',
'migrate-v1',
'preview',
'--source=source',
'--decisions=decisions',
'--observations=observations',
];
args[args.findIndex((argument) => argument.startsWith(`--${bareOption}=`))] =
`--${bareOption}`;
const result = spawnSync(process.execPath, args, { encoding: 'utf8' });
const outputLines = result.stdout.trim().split('\n');
expect(result.status).toBe(1);
expect(result.stderr).toBe('');
expect(outputLines).toHaveLength(1);
expect(JSON.parse(outputLines[0]!)).toEqual({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${bareOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
},
);
it('all nine sub-package commands coexist on a single program', () => {
const program = new Command();
for (const [, register] of REGISTRARS) register(program);

View File

@@ -17,8 +17,6 @@ import { registerConfigCommand } from './commands/config.js';
import { registerFleetCommand } from './commands/fleet.js';
import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js';
import { registerRestoreCommand } from './commands/restore.js';
import { registerSkillCommand } from './commands/skill.js';
// prdy is registered via launch.ts
import { registerLaunchCommands } from './commands/launch.js';
import { registerAuthCommand } from './commands/auth.js';
@@ -29,7 +27,6 @@ import {
checkForAllUpdates,
formatAllPackagesTable,
getInstallAllCommand,
repairFleetCommsTools,
runFrameworkReseed,
refreshActiveFleetUnits,
readRosterAgentNames,
@@ -68,7 +65,7 @@ Command Groups:
Runtime: tui, login, sessions
Gateway: gateway
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, skill, sync, upgrade, wizard, yolo
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
Platform: update
Runtimes: claude, codex, opencode, pi
`,
@@ -408,14 +405,6 @@ registerStorageCommand(program);
registerUninstallCommand(program);
// ─── restore ─────────────────────────────────────────────────────────────────
registerRestoreCommand(program);
// ─── skill ───────────────────────────────────────────────────────────────────
registerSkillCommand(program);
// ─── telemetry ───────────────────────────────────────────────────────────────
registerTelemetryCommand(program);
@@ -431,33 +420,7 @@ program
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
)
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
.option(
'--repair-tools',
'Restore the supported current-version TOOLS contract and executable fleet helper',
)
.action(
async (opts: {
check?: boolean;
reseed?: boolean;
relaunch?: boolean;
repairTools?: boolean;
}) => {
if (opts.repairTools) {
const repair = repairFleetCommsTools();
if (!repair.ok) {
console.error(`Fleet communications tools repair failed: ${repair.reason ?? 'unknown'}`);
process.exitCode = 1;
return;
}
console.log(
repair.changed
? 'Fleet communications tools repaired from the supported current framework.'
: 'Fleet communications tools already match the supported current framework.',
);
if (repair.backupPath) console.log(`Preserved previous TOOLS.md at ${repair.backupPath}.`);
console.log('No active context or session was rewritten.');
return;
}
.action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
// checkForAllUpdates imported statically above
const { execSync } = await import('node:child_process');
@@ -476,18 +439,6 @@ program
return;
}
console.log('✔ Framework re-seeded.');
if (reseed.skillSyncError) {
console.error(` ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`);
}
const skillConflicts = reseed.skillSync?.conflicts ?? [];
const skillChanges =
(reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0);
if (skillChanges > 0) {
console.log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`);
}
for (const conflict of skillConflicts) {
console.error(` ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
}
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
const units = refreshActiveFleetUnits();
@@ -577,8 +528,7 @@ program
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
);
}
},
);
});
// ─── wizard ─────────────────────────────────────────────────────────────

View File

@@ -1,862 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
CLAUDEX_PROXY_HOST,
CLAUDEX_PROXY_PORT,
CLAUDEX_PROXY_URL,
CLAUDEX_PROXY_BINARY,
CLAUDEX_HEALTH_PATH,
CLAUDEX_HEALTH_URL,
buildAuthStatusArgs,
buildDeviceAuthArgs,
buildServeArgs,
parseAuthStatus,
checkProxyBinary,
checkAuthStatus,
runDeviceReauth,
probeLiveness,
buildSystemdUnitContent,
systemdUnitPath,
installSystemdUnit,
startNohupProxy,
verifyListenerIdentity,
runProxyPreflight,
ensureProxyRunning,
type AuthStatus,
type ProxyRunResult,
type SpawnedChild,
type ListenerIdentity,
} from './claudex-proxy.js';
/**
* P1 — Proxy preflight + lifecycle helpers for `mosaic yolo claudex`.
*
* Security-relevant invariants exercised here:
* - Liveness probe hits the proxy's dedicated `GET /healthz` and treats only a
* 2xx as "alive" — a *proxy-specific* health contract, not arbitrary HTTP on
* the port (CWE-345: a local port-squatter must not be trusted as the proxy).
* This also honors spec gotcha #1 (never `curl -f` the root, which returns
* non-2xx): `/healthz` returns 2xx when the proxy is up, so a healthy proxy is
* never mistaken for dead and no duplicate proxy is spawned.
* - Auth-status parsing NEVER surfaces OAuth token material — only a coarse
* state + optional expiry — even if a token-shaped string appears in output.
* - The systemd unit's ExecStart never interpolates an unvalidated path
* (CWE-74: a CR/LF in the path could inject arbitrary systemd directives).
* - The nohup fallback captures spawn's *async* error event instead of crashing.
*/
describe('claudex-proxy constants', () => {
it('pins the proxy endpoint to loopback :18765 (spec table)', () => {
expect(CLAUDEX_PROXY_HOST).toBe('127.0.0.1');
expect(CLAUDEX_PROXY_PORT).toBe(18765);
expect(CLAUDEX_PROXY_URL).toBe('http://127.0.0.1:18765');
expect(CLAUDEX_PROXY_BINARY).toBe('claude-code-proxy');
});
it('exposes the dedicated /healthz liveness endpoint (not the root path)', () => {
expect(CLAUDEX_HEALTH_PATH).toBe('/healthz');
expect(CLAUDEX_HEALTH_URL).toBe('http://127.0.0.1:18765/healthz');
});
it('builds the documented codex subcommand argv', () => {
expect(buildAuthStatusArgs()).toEqual(['codex', 'auth', 'status']);
expect(buildDeviceAuthArgs()).toEqual(['codex', 'auth', 'device']);
expect(buildServeArgs()).toEqual(['serve', '--no-monitor']);
});
});
describe('parseAuthStatus', () => {
it('reports valid on exit 0 with an authenticated marker', () => {
const s = parseAuthStatus({
status: 0,
stdout: 'Authenticated as user; token valid',
stderr: '',
});
expect(s.state).toBe('valid');
});
it('reports expired when output mentions expiry', () => {
const s = parseAuthStatus({ status: 0, stdout: 'Token expired 2 days ago', stderr: '' });
expect(s.state).toBe('expired');
});
it('reports unauthenticated when output says not logged in', () => {
const s = parseAuthStatus({
status: 1,
stdout: '',
stderr: 'not authenticated: run codex auth device',
});
expect(s.state).toBe('unauthenticated');
});
it('reports unknown on an unrecognized non-zero exit', () => {
const s = parseAuthStatus({ status: 2, stdout: 'weird', stderr: '' });
expect(s.state).toBe('unknown');
});
it('does NOT trust a signal-terminated check (status null) even with an auth-looking line', () => {
// status: null means the process was killed by a signal — an INCOMPLETE
// check. An auth-looking line that happened to be flushed must not be read
// as valid, or preflight passes on a check that never finished.
const s = parseAuthStatus({ status: null, stdout: 'Authenticated', stderr: '' });
expect(s.state).toBe('unknown');
});
it('extracts a best-effort expiry in days when present', () => {
const s = parseAuthStatus({
status: 0,
stdout: 'Authenticated; expires in 9 days',
stderr: '',
});
expect(s.state).toBe('valid');
expect(s.expiresInDays).toBe(9);
});
it('treats a clean exit 0 with no explicit markers as valid', () => {
const s = parseAuthStatus({ status: 0, stdout: 'Session active for account foo', stderr: '' });
expect(s.state).toBe('valid');
expect(s.expiresInDays).toBeUndefined();
});
it('NEVER retains token-shaped material from output', () => {
const leaky = 'Authenticated. access_token=sk-abc123SECRETdeadbeef refresh_token=rt-9999';
const s: AuthStatus = parseAuthStatus({ status: 0, stdout: leaky, stderr: '' });
const serialized = JSON.stringify(s);
expect(serialized).not.toContain('sk-abc123SECRETdeadbeef');
expect(serialized).not.toContain('rt-9999');
expect(serialized).not.toContain('access_token');
expect(serialized).not.toContain('refresh_token');
});
});
describe('checkAuthStatus', () => {
it('runs the status subcommand and parses the result', () => {
const run = vi.fn(
(_cmd: string, _args: string[]): ProxyRunResult => ({
status: 0,
stdout: 'Authenticated; expires in 7 days',
stderr: '',
}),
);
const s = checkAuthStatus(run);
expect(run).toHaveBeenCalledWith(CLAUDEX_PROXY_BINARY, ['codex', 'auth', 'status']);
expect(s.state).toBe('valid');
expect(s.expiresInDays).toBe(7);
});
it('surfaces unknown when the default runner cannot find the binary', () => {
// Exercises the default spawnSync path against an absent binary: no throw,
// status is non-zero/null → unknown. Deterministic on a box without the proxy.
const s = checkAuthStatus();
expect(['unknown', 'unauthenticated', 'valid', 'expired']).toContain(s.state);
});
});
describe('runDeviceReauth', () => {
it('spawns the device flow with inherited stdio (never captures the code/token)', () => {
const calls: Array<{ cmd: string; args: string[]; opts: { stdio: string } }> = [];
const status = runDeviceReauth((cmd, args, opts) => {
calls.push({ cmd, args, opts });
return { status: 0 };
});
expect(status).toBe(0);
expect(calls).toHaveLength(1);
expect(calls[0]!.cmd).toBe(CLAUDEX_PROXY_BINARY);
expect(calls[0]!.args).toEqual(['codex', 'auth', 'device']);
// stdio 'inherit' is the security-critical bit: the device code streams to
// the user's TTY; the launcher never pipes/captures it.
expect(calls[0]!.opts.stdio).toBe('inherit');
});
it('returns 1 when the child yields no status (absent binary)', () => {
const status = runDeviceReauth(() => ({ status: null }));
expect(status).toBe(1);
});
});
describe('checkProxyBinary', () => {
it('resolves via the default `which` path (proxy absent → null)', () => {
// Covers the default resolver; on CI/dev the proxy is not installed.
const r = checkProxyBinary();
expect(typeof r.present).toBe('boolean');
if (!r.present) expect(r.path).toBeNull();
});
it('reports present with the resolved path', () => {
const r = checkProxyBinary(() => '/home/u/.local/bin/claude-code-proxy');
expect(r.present).toBe(true);
expect(r.path).toBe('/home/u/.local/bin/claude-code-proxy');
});
it('reports absent when the resolver finds nothing', () => {
const r = checkProxyBinary(() => null);
expect(r.present).toBe(false);
expect(r.path).toBeNull();
});
});
describe('probeLiveness (proxy-specific /healthz, not arbitrary HTTP)', () => {
it('defaults to probing the /healthz endpoint, never the root path', async () => {
const seen: string[] = [];
await probeLiveness(undefined, async (u) => {
seen.push(u);
return { status: 200 };
});
expect(seen[0]).toBe(CLAUDEX_HEALTH_URL);
expect(seen[0]).toContain('/healthz');
});
it('treats a 200 on /healthz as alive', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 200 }));
expect(live).toBe(true);
});
it('treats a 204 on /healthz as alive', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 204 }));
expect(live).toBe(true);
});
it('treats a 404 as DEAD — does not trust an arbitrary responder on the port (CWE-345)', async () => {
// The whole point: a random local process squatting :18765 will not honor the
// proxy's /healthz contract, so a non-2xx there must not be mistaken for the proxy.
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 404 }));
expect(live).toBe(false);
});
it('treats a 500 as DEAD (unhealthy / not the proxy health contract)', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 500 }));
expect(live).toBe(false);
});
it('treats a missing status as dead', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({}));
expect(live).toBe(false);
});
it('treats a connection failure (reject) as dead', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => {
throw new Error('ECONNREFUSED');
});
expect(live).toBe(false);
});
it('treats a timeout as dead', async () => {
const never = () => new Promise<{ status?: number }>(() => {});
const live = await probeLiveness(CLAUDEX_HEALTH_URL, never, 20);
expect(live).toBe(false);
});
});
describe('buildSystemdUnitContent', () => {
it('emits a user unit that execs the given binary with serve args', () => {
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
expect(unit).toContain('[Unit]');
expect(unit).toContain('[Service]');
expect(unit).toContain('[Install]');
expect(unit).toContain('/home/u/.local/bin/claude-code-proxy serve --no-monitor');
expect(unit).toContain('WantedBy=default.target');
});
it('never embeds credential material', () => {
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
expect(unit).not.toMatch(/token/i);
expect(unit).not.toMatch(/auth\.json/i);
});
it('rejects a path containing a newline (CWE-74 systemd directive injection)', () => {
// A raw newline in ExecStart would let an attacker append arbitrary unit
// directives — e.g. `ExecStartPost=curl evil`. Must be rejected outright.
expect(() =>
buildSystemdUnitContent('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /'),
).toThrow();
});
it('rejects a path containing a carriage return', () => {
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\rmalicious')).toThrow();
});
it('rejects a path with other control characters', () => {
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\x00nul')).toThrow();
});
it('rejects a non-absolute path', () => {
expect(() => buildSystemdUnitContent('claude-code-proxy')).toThrow();
expect(() => buildSystemdUnitContent('')).toThrow();
});
it('systemd-quotes a path that contains spaces', () => {
const unit = buildSystemdUnitContent('/home/u/my apps/claude-code-proxy');
expect(unit).toContain('ExecStart="/home/u/my apps/claude-code-proxy" serve --no-monitor');
});
it('escapes embedded quotes and backslashes when quoting', () => {
const unit = buildSystemdUnitContent('/home/u/we"ird\\dir/claude-code-proxy');
// No unescaped closing quote can terminate the token early.
expect(unit).toContain('ExecStart="/home/u/we\\"ird\\\\dir/claude-code-proxy" serve');
});
it('leaves a clean absolute path unquoted (no needless churn)', () => {
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
expect(unit).toContain('ExecStart=/home/u/.local/bin/claude-code-proxy serve --no-monitor');
});
});
describe('systemdUnitPath', () => {
it('targets the systemd --user unit dir', () => {
expect(systemdUnitPath('/home/u')).toBe(
'/home/u/.config/systemd/user/claude-code-proxy.service',
);
});
});
describe('installSystemdUnit', () => {
it('writes the unit and returns true when daemon-reload succeeds', () => {
let written: { path: string; content: string } | null = null;
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home: '/home/u',
writeUnit: (path, content) => {
written = { path, content };
},
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(true);
expect(written).not.toBeNull();
expect(written!.path).toBe('/home/u/.config/systemd/user/claude-code-proxy.service');
expect(written!.content).toContain('ExecStart=/bin/claude-code-proxy serve --no-monitor');
});
it('returns false when daemon-reload fails (systemd --user unavailable)', () => {
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home: '/home/u',
writeUnit: () => {},
run: () => ({ status: 1, stdout: '', stderr: 'Failed to connect to bus' }),
});
expect(ok).toBe(false);
});
it('returns false when writing the unit throws', () => {
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home: '/home/u',
writeUnit: () => {
throw new Error('EACCES');
},
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(false);
});
it('refuses to write a unit for an injection-bearing path (never writes a poisoned unit)', () => {
const writeUnit = vi.fn();
const ok = installSystemdUnit('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /', {
home: '/home/u',
writeUnit,
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(false);
// The poisoned unit content is never even produced, so nothing is written.
expect(writeUnit).not.toHaveBeenCalled();
});
it('writes to a real temp dir via the default writer', () => {
const home = mkdtempSync(join(tmpdir(), 'claudex-unit-'));
try {
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home,
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(true);
const written = readFileSync(systemdUnitPath(home), 'utf8');
expect(written).toContain('[Service]');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe('runProxyPreflight', () => {
const trustedListener = () => 'ok' as const;
it('is ok when binary present, auth valid, proxy live, and listener identity-verified', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(true);
expect(report.listenerVerdict).toBe('ok');
expect(report.problems).toEqual([]);
});
it('flags a missing binary', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: false, path: null }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.problems.some((p) => /binary/i.test(p))).toBe(true);
});
it('flags expired auth (re-auth needed)', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'expired' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.needsReauth).toBe(true);
expect(report.problems.some((p) => /auth/i.test(p))).toBe(true);
});
it('flags a dead proxy', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => false,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.live).toBe(false);
});
it('flags an unknown auth state without marking it for re-auth', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'unknown' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.needsReauth).toBe(false);
expect(report.problems.some((p) => /could not determine/i.test(p))).toBe(true);
});
it('does NOT pass preflight when the live responder fails identity verification (F2b)', async () => {
// A squatter answering /healthz-2xx must not yield ok:true just because the
// binary is installed and OAuth is valid — the identity gate holds here too.
for (const verdict of ['foreign-user', 'wrong-exe', 'unknown'] as const) {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => true,
verifyListener: () => verdict,
});
expect(report.ok).toBe(false);
expect(report.live).toBe(true);
expect(report.listenerVerdict).toBe(verdict);
expect(report.problems.some((p) => /identity could not be verified/i.test(p))).toBe(true);
// The identity problem is non-sensitive: port + verdict only, no token.
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
}
});
it('does not verify listener identity when the proxy is dead (no listener to trust)', async () => {
const verifyListener = vi.fn(() => 'ok' as const);
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => false,
verifyListener,
});
expect(verifyListener).not.toHaveBeenCalled();
expect(report.listenerVerdict).toBe('unknown');
expect(report.ok).toBe(false);
});
it('does not leak token material for any auth state', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'expired' }),
probe: async () => false,
verifyListener: trustedListener,
});
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
});
it('runs end-to-end with all real defaults (no proxy installed → not ok)', async () => {
// Exercises the default checkBinary/checkAuth/probe closures against a box
// with no proxy: absent binary, spawnSync status, real loopback probe that
// fast-fails with ECONNREFUSED. Asserts shape only (never token material).
const report = await runProxyPreflight();
expect(typeof report.ok).toBe('boolean');
expect(Array.isArray(report.problems)).toBe(true);
expect(['valid', 'expired', 'unauthenticated', 'unknown']).toContain(report.auth.state);
expect(JSON.stringify(report)).not.toMatch(/access_token|refresh_token|sk-/i);
});
});
/**
* A minimal fake ChildProcess for the nohup-fallback tests: records once()
* handlers so a test can drive the async 'spawn'/'error' events, and tracks
* whether the 'error' listener was already attached at the moment unref() ran
* (the security-critical ordering from finding #1).
*/
function fakeChild() {
const handlers: Record<string, (arg?: unknown) => void> = {};
const state = { unreffed: false, errorHandlerAtUnref: false };
const child = {
once(event: string, listener: (arg?: unknown) => void) {
handlers[event] = listener;
return child;
},
unref() {
state.unreffed = true;
state.errorHandlerAtUnref = typeof handlers.error === 'function';
},
emit(event: string, arg?: unknown) {
handlers[event]?.(arg);
},
};
return {
child: child as unknown as SpawnedChild & { emit(e: string, a?: unknown): void },
state,
};
}
describe('startNohupProxy (finding #1 — async spawn error must not crash)', () => {
it('resolves status 0 only after a confirmed spawn, and unrefs the child', async () => {
const { child, state } = fakeChild();
const spawnImpl = vi.fn((_cmd: string, _args: string[]) => {
queueMicrotask(() => child.emit('spawn'));
return child;
});
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(0);
expect(state.unreffed).toBe(true);
// The error listener MUST be registered before unref(), so an ENOENT that
// arrives asynchronously can never become an unhandled 'error' crash.
expect(state.errorHandlerAtUnref).toBe(true);
expect(spawnImpl).toHaveBeenCalledWith('/bin/claude-code-proxy', ['serve', '--no-monitor'], {
detached: true,
stdio: 'ignore',
});
});
it('captures an async spawn error (ENOENT) as a failed start instead of crashing', async () => {
const { child, state } = fakeChild();
const spawnImpl = () => {
queueMicrotask(() => child.emit('error', new Error('spawn claude-code-proxy ENOENT')));
return child;
};
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(1);
expect(r.stderr).toContain('ENOENT');
expect(state.unreffed).toBe(false); // never unref a child that failed to start
});
it('captures a synchronous spawn throw as a failed start', async () => {
const spawnImpl = () => {
throw new Error('EACCES');
};
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(1);
expect(r.stderr).toContain('EACCES');
});
it('ignores a late error after a successful spawn (settles once)', async () => {
const { child } = fakeChild();
const spawnImpl = () => {
queueMicrotask(() => {
child.emit('spawn');
child.emit('error', new Error('late boom'));
});
return child;
};
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(0); // first settle wins; the late error cannot flip it
});
});
describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE-345)', () => {
const me: ListenerIdentity = {
pid: 4242,
uid: 1000,
exePath: '/home/me/.local/bin/claude-code-proxy',
};
// Identity canonicalize for tests: fake paths don't exist on disk, so we map
// each path to itself and exercise symlink resolution explicitly where needed.
const idc = (p: string) => p;
it('accepts a listener owned by the current uid whose exe is the expected proxy path', () => {
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('ok');
});
it('resolves symlinks on BOTH sides before comparing (canonical match → ok)', () => {
// The listener exe and our resolved binary reach the same real file via
// different symlink paths — a canonical comparison must accept it.
const canon: Record<string, string> = {
'/var/run/proxy.link': '/opt/proxy/bin/claude-code-proxy',
'/home/me/.local/bin/claude-code-proxy': '/opt/proxy/bin/claude-code-proxy',
};
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: '/var/run/proxy.link' }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: (p) => canon[p] ?? null,
});
expect(verdict).toBe('ok');
});
it('does NOT trust a same-uid process at the WRONG path with the right basename (F2a)', () => {
// The squatter vector on a shared-uid host: right basename, wrong path. The
// basename must NEVER be a trust signal when an expected exact path resolved.
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: '/tmp/claude-code-proxy' }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('wrong-exe');
});
it('fails closed (unknown) when our own proxy binary path cannot be resolved (F2a)', () => {
// No expected path → we cannot assert identity → refuse to trust (no basename
// acceptance). Previously this returned `ok` by basename; that was a bypass.
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => 1000,
expectedExe: () => null,
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('fails closed (unknown) when a path cannot be canonicalized (F2a)', () => {
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: () => null, // e.g. binary deleted out from under the listener
});
expect(verdict).toBe('unknown');
});
it('rejects a listener owned by a DIFFERENT uid (foreign-user) — fail closed', () => {
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, uid: 0 }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('foreign-user');
});
it('rejects a same-user listener whose exe is NOT the proxy (wrong-exe)', () => {
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: '/usr/bin/nc' }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('wrong-exe');
});
it('returns unknown (fail closed) when the listener cannot be identified', () => {
const verdict = verifyListenerIdentity({
identify: () => null,
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('returns unknown when the current uid is unavailable (non-posix)', () => {
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => -1,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('returns unknown when the listener exe path cannot be read', () => {
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: null }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('runs with real defaults without throwing (identity may be unresolved → verdict)', () => {
const verdict = verifyListenerIdentity();
expect(['ok', 'foreign-user', 'wrong-exe', 'unknown']).toContain(verdict);
});
});
describe('ensureProxyRunning', () => {
const ok: ProxyRunResult = { status: 0, stdout: '', stderr: '' };
const nohupOk = async (): Promise<ProxyRunResult> => ok;
const trusted = () => 'ok' as const;
it('is a no-op when the proxy is already live AND identity-verified', async () => {
const startSystemd = vi.fn(() => ok);
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => true,
verifyListener: trusted,
startSystemd,
startNohup,
waitMs: async () => {},
});
expect(r.method).toBe('already');
expect(r.live).toBe(true);
expect(startSystemd).not.toHaveBeenCalled();
expect(startNohup).not.toHaveBeenCalled();
});
it('fails closed as untrusted when a responder holds :18765 but identity is NOT ours', async () => {
// A foreign process answers /healthz but the listener is not our proxy
// (foreign uid / wrong exe / unidentifiable). We must NOT trust it and must
// NOT start a second proxy (the port is already taken) — fail closed.
const startSystemd = vi.fn(() => ok);
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => true,
verifyListener: () => 'foreign-user',
startSystemd,
startNohup,
waitMs: async () => {},
});
expect(r.method).toBe('untrusted');
expect(r.live).toBe(false);
expect(startSystemd).not.toHaveBeenCalled();
expect(startNohup).not.toHaveBeenCalled();
});
it('starts via systemd when available and then becomes trusted-live', async () => {
let calls = 0;
const r = await ensureProxyRunning({
probe: async () => calls++ > 0, // dead first, live after start
verifyListener: trusted,
startSystemd: () => ok,
startNohup: async () => {
throw new Error('should not fall back');
},
waitMs: async () => {},
});
expect(r.method).toBe('systemd');
expect(r.live).toBe(true);
});
it('waits past a slow systemd bind before giving up (finding #2 — no duplicate proxy)', async () => {
// systemd `start` returns 0 (job accepted) but the socket only binds on the
// 4th probe — still well within the startup deadline. nohup must NOT run,
// or two proxies would contend for :18765.
let probes = 0;
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => probes++ >= 3,
verifyListener: trusted,
startSystemd: () => ok,
startNohup,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 200,
});
expect(r.method).toBe('systemd');
expect(r.live).toBe(true);
expect(startNohup).not.toHaveBeenCalled();
});
it('does NOT fall back to nohup after systemd accepts but never binds (finding #1 — dup-proxy race)', async () => {
// systemctl start exit 0 means the job was ACCEPTED, not bound. If it binds
// just after our deadline (or systemd restarts it), a nohup fallback would
// create a SECOND proxy contending for :18765. Once systemd has accepted the
// job we never spawn nohup — we report a managed-service startup failure.
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => false, // never becomes live within the deadline
verifyListener: trusted,
startSystemd: () => ok,
startNohup,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(startNohup).not.toHaveBeenCalled();
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
it('does NOT trust a systemd-started responder whose identity cannot be verified', async () => {
// Dead at first (so we reach the systemd start), then the socket binds — but
// identity never verifies (e.g. a squatter beat systemd to the port). A live
// responder that fails identity must never be reported as a successful start.
let calls = 0;
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => calls++ > 0,
verifyListener: () => 'wrong-exe',
startSystemd: () => ok,
startNohup,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(startNohup).not.toHaveBeenCalled();
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
it('falls back to nohup only when systemd start FAILS outright (not accepted)', async () => {
let calls = 0;
const r = await ensureProxyRunning({
// A failed systemd start skips its post-start poll, so probes are:
// #0 initial (dead), #1 after nohup (live). nohup fallback is reachable
// ONLY because systemd never accepted the job (status 1).
probe: async () => calls++ > 0,
verifyListener: trusted,
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
startNohup: nohupOk,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(r.method).toBe('nohup');
expect(r.live).toBe(true);
});
it('does NOT trust a nohup-started responder whose identity cannot be verified', async () => {
let calls = 0;
const r = await ensureProxyRunning({
probe: async () => calls++ > 0,
verifyListener: () => 'unknown',
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
startNohup: nohupOk,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
it('reports failed when nothing brings the proxy up', async () => {
const r = await ensureProxyRunning({
probe: async () => false,
verifyListener: trusted,
startSystemd: () => ({ status: 1, stdout: '', stderr: '' }),
startNohup: async () => ({ status: 1, stdout: '', stderr: '' }),
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
});

View File

@@ -1,700 +0,0 @@
/**
* Claudex proxy preflight + lifecycle (P1 of `mosaic yolo claudex`).
*
* `raine/claude-code-proxy` runs a local server on 127.0.0.1:18765 that speaks
* the Anthropic Messages API and translates to the ChatGPT/Codex backend using
* ChatGPT-subscription OAuth. This module owns the *preflight* and *lifecycle*
* concerns for the launcher: is the binary present, is OAuth valid, is the proxy
* listening, and — if not — bring it up (systemd user unit preferred, nohup
* fallback).
*
* Design: every function is pure or dependency-injected so the launch path is
* fully unit-testable without touching a real process, socket, or the OAuth
* token. Nothing here reads `~/.config/claude-code-proxy/codex/auth.json`; the
* proxy holds the real credential and Claude Code only ever sees
* `ANTHROPIC_AUTH_TOKEN=unused`. Parsed auth status is deliberately coarse
* (state + optional expiry) so no token material can be retained or surfaced.
*/
import { execFileSync, spawn, spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
// ─── Endpoint / command constants (spec table) ──────────────────────────────
export const CLAUDEX_PROXY_HOST = '127.0.0.1';
export const CLAUDEX_PROXY_PORT = 18765;
export const CLAUDEX_PROXY_URL = `http://${CLAUDEX_PROXY_HOST}:${CLAUDEX_PROXY_PORT}`;
export const CLAUDEX_PROXY_BINARY = 'claude-code-proxy';
export const CLAUDEX_SYSTEMD_UNIT = 'claude-code-proxy.service';
/**
* The proxy's dedicated liveness endpoint. We probe this — NOT the root path —
* for two reasons: (1) the root returns non-2xx (spec gotcha #1), which is why
* the original `curl -f` check spawned duplicate proxies; `/healthz` returns 2xx
* when the proxy is healthy. (2) It is a *proxy-specific* contract, so a 2xx here
* is a much stronger signal that the responder on :18765 is actually our proxy
* and not some other local process squatting the port (CWE-345).
*/
export const CLAUDEX_HEALTH_PATH = '/healthz';
export const CLAUDEX_HEALTH_URL = `${CLAUDEX_PROXY_URL}${CLAUDEX_HEALTH_PATH}`;
/** argv for `claude-code-proxy codex auth status`. */
export function buildAuthStatusArgs(): string[] {
return ['codex', 'auth', 'status'];
}
/** argv for `claude-code-proxy codex auth device` (device-code re-auth flow). */
export function buildDeviceAuthArgs(): string[] {
return ['codex', 'auth', 'device'];
}
/** argv for `claude-code-proxy serve --no-monitor`. */
export function buildServeArgs(): string[] {
return ['serve', '--no-monitor'];
}
// ─── Types ──────────────────────────────────────────────────────────────────
export type AuthState = 'valid' | 'expired' | 'unauthenticated' | 'unknown';
/**
* Coarse OAuth status. Intentionally carries NO token material — only a state
* and an optional best-effort expiry-in-days for user-facing messaging.
*/
export interface AuthStatus {
state: AuthState;
expiresInDays?: number;
}
export interface ProxyRunResult {
status: number | null;
stdout: string;
stderr: string;
}
/** Runs a command synchronously and returns its captured result. */
export type CommandRunner = (cmd: string, args: string[]) => ProxyRunResult;
/** Minimal fetch shape used for the liveness probe (any HTTP response = alive). */
export type FetchLike = (
url: string,
init?: { signal?: AbortSignal },
) => Promise<{ status?: number }>;
// ─── Binary presence ─────────────────────────────────────────────────────────
function defaultWhich(cmd: string): string | null {
try {
return execFileSync('which', [cmd], { encoding: 'utf8' }).trim() || null;
} catch {
return null;
}
}
export function checkProxyBinary(resolve: (cmd: string) => string | null = defaultWhich): {
present: boolean;
path: string | null;
} {
const path = resolve(CLAUDEX_PROXY_BINARY);
return { present: path !== null && path !== '', path: path || null };
}
// ─── Auth status ─────────────────────────────────────────────────────────────
/**
* Parse `claude-code-proxy codex auth status` output into a coarse state.
*
* The proxy's exact wording is not contractually pinned, so this matches
* tolerantly on well-known markers and falls back on the exit code. It never
* copies the raw output onto the result — only a state and an optional expiry —
* so token-shaped strings in the output cannot leak downstream.
*/
export function parseAuthStatus(result: ProxyRunResult): AuthStatus {
const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
const expired = /\bexpired\b|token has expired|expires?d? \d+ days? ago/.test(text);
const unauth =
/not authenticated|not logged in|no (?:auth|credentials|token)|please (?:log ?in|authenticate)|run .*auth device/.test(
text,
);
const authed = /\bauthenticated\b|logged in|token valid|valid until|expires? in/.test(text);
let state: AuthState;
if (expired) {
state = 'expired';
} else if (unauth) {
state = 'unauthenticated';
} else if (authed && result.status === 0) {
// A `null` status means the check was killed by a signal — an INCOMPLETE
// run. We require a clean exit 0 for `valid`; a partially-flushed auth line
// from a signal-terminated check must never be trusted (finding #3).
state = 'valid';
} else if (result.status === 0) {
state = 'valid';
} else {
state = 'unknown';
}
const status: AuthStatus = { state };
const days = /expires? in (\d+) days?/.exec(text);
if (state === 'valid' && days) {
status.expiresInDays = Number(days[1]);
}
return status;
}
function defaultRun(cmd: string, args: string[]): ProxyRunResult {
const r = spawnSync(cmd, args, { encoding: 'utf8' });
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}
export function checkAuthStatus(run: CommandRunner = defaultRun): AuthStatus {
return parseAuthStatus(run(CLAUDEX_PROXY_BINARY, buildAuthStatusArgs()));
}
/** Spawn shape for the interactive device re-auth flow. */
export type InheritSpawn = (
cmd: string,
args: string[],
opts: { stdio: 'inherit' },
) => { status: number | null };
function defaultInheritSpawn(cmd: string, args: string[], opts: { stdio: 'inherit' }) {
return spawnSync(cmd, args, opts);
}
/**
* Run the device-code re-auth flow (`claude-code-proxy codex auth device`).
*
* Deliberately `stdio: 'inherit'` so the device code the proxy prints goes
* straight to the user's terminal — the launcher NEVER captures, stores, or logs
* it, and never observes the resulting OAuth token (the proxy persists that to
* its own config). Returns the child's exit status; 1 on an absent binary.
*/
export function runDeviceReauth(spawnImpl: InheritSpawn = defaultInheritSpawn): number {
const r = spawnImpl(CLAUDEX_PROXY_BINARY, buildDeviceAuthArgs(), { stdio: 'inherit' });
return r.status ?? 1;
}
// ─── Liveness (probe the proxy-specific /healthz; require 2xx) ────────────────
/**
* Probe the proxy for liveness by hitting its dedicated `GET /healthz` endpoint
* and requiring a 2xx response.
*
* This is a LIVENESS check only — it answers "is a healthy proxy responding?",
* not "is that responder actually ours?". Requiring a 2xx on the proxy's own
* `/healthz` contract (rather than "any HTTP response = alive") resolves spec
* gotcha #1: the root path returns non-2xx, but `/healthz` returns 2xx when
* healthy, so a live proxy is never mistaken for dead and no duplicate proxy is
* spawned.
*
* Residual risk (CWE-345): the proxy binds loopback with NO client
* authentication, so on a shared host a local process could occupy :18765 and
* serve a 2xx here. A 2xx therefore does NOT by itself establish that the
* listener is our proxy. Identity is verified SEPARATELY and at every trust
* point by {@link verifyListenerIdentity} (OS-level uid + executable check),
* which fails closed when identity can't be established. See
* {@link ensureProxyRunning}. (Broader multi-user hardening — a persistent
* warning when a foreign listener is seen — is tracked for a later phase.)
*/
export async function probeLiveness(
url: string = CLAUDEX_HEALTH_URL,
fetchImpl: FetchLike = fetch as unknown as FetchLike,
timeoutMs = 1500,
): Promise<boolean> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
// Bound the probe with our own timeout race rather than trusting the fetch
// implementation to honor the abort signal — a hung socket (or a fetch that
// ignores the signal) must never wedge the launcher. We still abort() so a
// signal-aware fetch tears the request down promptly.
const timeout = new Promise<boolean>((resolve) => {
timer = setTimeout(() => {
controller.abort();
resolve(false);
}, timeoutMs);
});
const probe = fetchImpl(url, { signal: controller.signal })
.then((res) => typeof res.status === 'number' && res.status >= 200 && res.status < 300)
.catch(() => false); // connection refused / aborted → dead
try {
return await Promise.race([probe, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
// ─── Listener identity (OS-level, CWE-345 mitigation) ─────────────────────────
/**
* The result of verifying who actually owns the :18765 listener.
* - `ok` — same-user process running the expected proxy binary.
* - `foreign-user` — a process owned by a DIFFERENT uid holds the port.
* - `wrong-exe` — same-user, but the executable is not the proxy.
* - `unknown` — identity could not be established (fail closed).
*/
export type ListenerVerdict = 'ok' | 'foreign-user' | 'wrong-exe' | 'unknown';
/** OS-level identity of the process bound to the proxy port. */
export interface ListenerIdentity {
pid: number;
uid: number;
/** Absolute path of the process executable, or null if unreadable. */
exePath: string | null;
}
export interface VerifyListenerDeps {
/** Resolve the process bound to the proxy port (null → unidentifiable). */
identify?: () => ListenerIdentity | null;
/** The current process uid (-1 when unavailable, e.g. non-posix). */
currentUid?: () => number;
/** The expected proxy executable path (null when it can't be resolved). */
expectedExe?: () => string | null;
/** Canonicalize a path (resolve symlinks); null when it can't be resolved. */
canonicalize?: (p: string) => string | null;
}
/** Resolve a path through symlinks to its canonical form; null on any failure. */
function defaultCanonicalize(p: string): string | null {
try {
return realpathSync(p);
} catch {
return null;
}
}
/**
* Identify the process listening on the proxy port via `ss` + `/proc`. Every
* failure path returns null so the caller fails closed. Reads no credential
* material — only pid/uid/exe path of the listener.
*/
function defaultIdentifyListener(port: number = CLAUDEX_PROXY_PORT): ListenerIdentity | null {
try {
const out = execFileSync('ss', ['-H', '-ltnp', `sport = :${port}`], { encoding: 'utf8' });
const pidMatch = /pid=(\d+)/.exec(out);
if (!pidMatch) return null;
const pid = Number(pidMatch[1]);
if (!Number.isInteger(pid) || pid <= 0) return null;
const status = readFileSync(`/proc/${pid}/status`, 'utf8');
const uidLine = /^Uid:\s*(\d+)/m.exec(status);
if (!uidLine) return null;
const uid = Number(uidLine[1]);
let exePath: string | null = null;
try {
exePath = readlinkSync(`/proc/${pid}/exe`);
} catch {
exePath = null;
}
return { pid, uid, exePath };
} catch {
return null;
}
}
/**
* Verify that the process owning :18765 is genuinely OUR proxy before trusting
* it. The proxy binds loopback with NO client authentication, so on a shared
* host any local process could squat the port and a liveness 2xx alone does not
* prove identity (CWE-345). We FAIL CLOSED (`unknown`) whenever identity cannot
* be established. This needs no upstream shared-secret/unix-socket support from
* `claude-code-proxy`.
*
* The executable path is the trust boundary that matters: on a shared-uid host
* (every agent session runs as the same operator) same-uid is NOT sufficient, so
* we require an EXACT canonical-path match against our resolved proxy binary and
* canonicalize both sides for symlinks. There is deliberately NO basename
* fallback — a same-uid process running `/tmp/claude-code-proxy` (right name,
* wrong path) must never be trusted. If our own binary path can't be resolved,
* or either path can't be canonicalized, we fail closed rather than downgrade to
* a weaker check.
*/
export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerVerdict {
const identify = deps.identify ?? (() => defaultIdentifyListener());
const currentUid =
deps.currentUid ?? (() => (typeof process.getuid === 'function' ? process.getuid() : -1));
const expectedExe = deps.expectedExe ?? (() => checkProxyBinary().path);
const canonicalize = deps.canonicalize ?? defaultCanonicalize;
const id = identify();
if (!id) return 'unknown'; // can't see the listener → don't trust it
const uid = currentUid();
if (uid < 0) return 'unknown'; // can't establish our own identity → fail closed
if (id.uid !== uid) return 'foreign-user'; // someone else's process holds the port
if (!id.exePath) return 'unknown'; // can't confirm the executable → fail closed
const expected = expectedExe();
if (!expected) return 'unknown'; // can't resolve our own binary → fail closed
const expectedReal = canonicalize(expected);
const actualReal = canonicalize(id.exePath);
if (!expectedReal || !actualReal) return 'unknown'; // uncanonicalizable → fail closed
return actualReal === expectedReal ? 'ok' : 'wrong-exe';
}
// ─── systemd user unit ───────────────────────────────────────────────────────
export function systemdUnitPath(home: string = homedir()): string {
return join(home, '.config', 'systemd', 'user', CLAUDEX_SYSTEMD_UNIT);
}
/**
* Validate a path destined for a systemd `ExecStart=` line. A raw newline (or
* other control character) in the path would let an attacker inject arbitrary
* unit directives (e.g. an extra `ExecStartPost=`), a CWE-74 command injection.
* We require a plain absolute path and reject any control character outright.
*/
function validateExecPath(binaryPath: string): string {
if (typeof binaryPath !== 'string' || binaryPath.length === 0) {
throw new Error('systemd ExecStart: binary path is empty');
}
if (!binaryPath.startsWith('/')) {
throw new Error(
`systemd ExecStart: binary path must be absolute: ${JSON.stringify(binaryPath)}`,
);
}
if (/[\x00-\x1f\x7f]/.test(binaryPath)) {
throw new Error('systemd ExecStart: binary path contains control characters');
}
return binaryPath;
}
/**
* Encode a validated path for a systemd `ExecStart=` token. systemd only needs
* quoting when the token carries whitespace or quote/backslash characters; a
* clean path is emitted verbatim. When quoting, we escape backslashes and double
* quotes per systemd's C-style rules so the token cannot be terminated early.
*/
function systemdQuoteExec(path: string): string {
if (!/[\s"'\\]/.test(path)) {
return path;
}
const escaped = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}"`;
}
/**
* Render the `claude-code-proxy.service` user unit. Contains no credential
* material — the proxy reads its own OAuth token from its config dir at runtime.
* The binary path is validated (absolute, no control characters) and systemd-
* quoted so it cannot inject unit directives.
*/
export function buildSystemdUnitContent(binaryPath: string): string {
const exec = `${systemdQuoteExec(validateExecPath(binaryPath))} ${buildServeArgs().join(' ')}`;
return [
'[Unit]',
'Description=claude-code-proxy (Anthropic->Codex translation proxy for mosaic claudex)',
'After=network-online.target',
'Wants=network-online.target',
'',
'[Service]',
'Type=simple',
`ExecStart=${exec}`,
'Restart=on-failure',
'RestartSec=2',
'',
'[Install]',
'WantedBy=default.target',
'',
].join('\n');
}
/**
* Write the user unit and reload the systemd --user daemon. Returns false when
* systemd --user is unavailable (the caller then falls back to nohup).
*/
export function installSystemdUnit(
binaryPath: string,
deps: {
home?: string;
writeUnit?: (path: string, content: string) => void;
run?: CommandRunner;
} = {},
): boolean {
const home = deps.home ?? homedir();
const write =
deps.writeUnit ??
((path: string, content: string) => {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
});
const run = deps.run ?? defaultRun;
try {
write(systemdUnitPath(home), buildSystemdUnitContent(binaryPath));
const reload = run('systemctl', ['--user', 'daemon-reload']);
return reload.status === 0;
} catch {
return false;
}
}
// ─── Preflight report ────────────────────────────────────────────────────────
export interface PreflightReport {
binaryPresent: boolean;
binaryPath: string | null;
auth: AuthStatus;
live: boolean;
/** OS-level identity verdict for the :18765 listener (`unknown` when dead). */
listenerVerdict: ListenerVerdict;
needsReauth: boolean;
ok: boolean;
problems: string[];
}
export interface PreflightDeps {
checkBinary?: () => { present: boolean; path: string | null };
checkAuth?: () => AuthStatus;
probe?: () => Promise<boolean>;
verifyListener?: () => ListenerVerdict;
}
/**
* Compose the preflight checks into a single structured report. `ok` is true
* only when the binary is present, OAuth is valid, the proxy responds, AND the
* responding listener's OS-level identity verifies as our proxy.
*
* The identity gate lives here too, not only in {@link ensureProxyRunning}: any
* consumer of this report (notably the phase-2 launch path) would otherwise
* treat a `/healthz`-2xx squatter as healthy and route Claude traffic to it
* (CWE-345). A liveness 2xx is necessary but not sufficient — a live responder
* that fails identity fails the preflight.
*/
export async function runProxyPreflight(deps: PreflightDeps = {}): Promise<PreflightReport> {
const checkBinary = deps.checkBinary ?? (() => checkProxyBinary());
const checkAuth = deps.checkAuth ?? (() => checkAuthStatus());
const probe = deps.probe ?? (() => probeLiveness());
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
const bin = checkBinary();
const auth = checkAuth();
const live = await probe();
// Only meaningful when something is actually responding; a dead port has no
// listener identity to establish.
const listenerVerdict: ListenerVerdict = live ? verifyListener() : 'unknown';
const problems: string[] = [];
if (!bin.present) {
problems.push(
`claude-code-proxy binary not found in PATH. Install it before launching claudex.`,
);
}
const needsReauth = auth.state === 'expired' || auth.state === 'unauthenticated';
if (needsReauth) {
problems.push(
`claude-code-proxy OAuth is ${auth.state}. Re-auth with: ${CLAUDEX_PROXY_BINARY} ${buildDeviceAuthArgs().join(' ')}`,
);
} else if (auth.state === 'unknown') {
problems.push('Could not determine claude-code-proxy OAuth status.');
}
if (!live) {
problems.push(`No proxy responding on ${CLAUDEX_PROXY_URL}.`);
} else if (listenerVerdict !== 'ok') {
// Non-sensitive: names the port and the verdict only — never any listener
// command line, token, or other process detail.
problems.push(
`A process is listening on ${CLAUDEX_PROXY_URL} but its identity could not be verified as ${CLAUDEX_PROXY_BINARY} (${listenerVerdict}). Refusing to trust it.`,
);
}
const ok = bin.present && auth.state === 'valid' && live && listenerVerdict === 'ok';
return {
binaryPresent: bin.present,
binaryPath: bin.path,
auth,
live,
listenerVerdict,
needsReauth,
ok,
problems,
};
}
// ─── Lifecycle: ensure the proxy is running ──────────────────────────────────
export type ProxyStartMethod = 'already' | 'systemd' | 'nohup' | 'untrusted' | 'failed';
export interface EnsureProxyResult {
live: boolean;
method: ProxyStartMethod;
}
/** Minimal spawned-child shape used by the nohup fallback (testable seam). */
export interface SpawnedChild {
once(event: string, listener: (arg?: unknown) => void): unknown;
unref(): void;
}
/** Spawn shape for the detached fallback process. */
export type SpawnLike = (
cmd: string,
args: string[],
opts: { detached: boolean; stdio: 'ignore' },
) => SpawnedChild;
export interface StartNohupDeps {
resolveBin?: () => string;
spawnImpl?: SpawnLike;
}
/**
* Start the proxy as a detached background process (the fallback when no systemd
* user unit is available).
*
* `spawn()` reports launch failures (ENOENT/EACCES) ASYNCHRONOUSLY via the
* child's `error` event, which a `try/catch` cannot see. If left unhandled that
* event throws and crashes the launcher. So we: (1) attach the `error` listener
* BEFORE `unref()`, capturing a failed launch as a non-zero result instead of a
* crash; and (2) resolve success only after the child's `spawn` event fires —
* never optimistically before the process is known to have started.
*/
export function startNohupProxy(deps: StartNohupDeps = {}): Promise<ProxyRunResult> {
const resolveBin = deps.resolveBin ?? (() => checkProxyBinary().path ?? CLAUDEX_PROXY_BINARY);
const spawnImpl =
deps.spawnImpl ?? ((cmd, args, opts) => spawn(cmd, args, opts) as unknown as SpawnedChild);
return new Promise<ProxyRunResult>((resolve) => {
let settled = false;
const finish = (r: ProxyRunResult) => {
if (!settled) {
settled = true;
resolve(r);
}
};
let child: SpawnedChild;
try {
child = spawnImpl(resolveBin(), buildServeArgs(), { detached: true, stdio: 'ignore' });
} catch (err) {
finish({ status: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) });
return;
}
// Register error handling BEFORE unref so an async spawn failure is caught.
child.once('error', (err) => {
finish({
status: 1,
stdout: '',
stderr: err instanceof Error ? err.message : String(err),
});
});
child.once('spawn', () => {
child.unref();
finish({ status: 0, stdout: '', stderr: '' });
});
});
}
export interface EnsureProxyDeps {
probe?: () => Promise<boolean>;
/** OS-level identity check for the process holding the proxy port. */
verifyListener?: () => ListenerVerdict;
startSystemd?: () => ProxyRunResult;
startNohup?: () => Promise<ProxyRunResult>;
waitMs?: (ms: number) => Promise<void>;
/** Interval between liveness polls while waiting for a start to bind. */
settleMs?: number;
/** Total budget to wait for a started proxy to bind its socket. */
startupDeadlineMs?: number;
}
function defaultWait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function defaultStartSystemd(): ProxyRunResult {
return defaultRun('systemctl', ['--user', 'start', CLAUDEX_SYSTEMD_UNIT]);
}
/**
* Poll for a TRUSTED-live proxy up to a bounded startup deadline. A start command
* returning 0 only means the job was ACCEPTED, not that the socket is bound — so
* we keep probing at `intervalMs` until either the deadline elapses or the port
* both responds AND passes the OS-level identity check. Liveness alone is not
* enough: a responder that fails identity (a squatter) must never be trusted.
*/
async function waitForTrusted(
probe: () => Promise<boolean>,
verifyListener: () => ListenerVerdict,
waitMs: (ms: number) => Promise<void>,
intervalMs: number,
deadlineMs: number,
): Promise<boolean> {
let elapsed = 0;
while (elapsed < deadlineMs) {
await waitMs(intervalMs);
elapsed += intervalMs;
if ((await probe()) && verifyListener() === 'ok') {
return true;
}
}
return false;
}
/**
* Ensure a proxy is listening. No-op when already live. Otherwise prefer the
* systemd user unit, then fall back to a detached background process.
*
* Every trust point is gated on OS-level listener identity, not just liveness:
* the proxy has no client authentication, so on a shared host a local process
* could squat :18765 and a 2xx `/healthz` alone would not prove it is our proxy
* (CWE-345, finding #2). We only trust a responder whose owning process is the
* current uid running the expected proxy binary; otherwise we fail closed.
*
* If a responder is already present but its identity does NOT verify, we return
* `untrusted` WITHOUT starting anything — the port is taken, so spawning would
* only create contention, and we must never route Claude traffic through an
* unverified listener.
*
* After a start command is accepted we poll to a bounded startup deadline before
* giving up: `systemctl start` exit 0 means the job was accepted, not that the
* socket bound within one probe interval. Critically, once systemd ACCEPTS the
* job we do NOT fall back to nohup even if it never becomes trusted-live in the
* deadline (finding #1): the accepted unit may bind late or be restarted by
* systemd, and a second proxy would then contend for :18765 — the very
* duplicate-proxy outcome this function exists to prevent. nohup is reachable
* only when systemd never accepted the job at all.
*/
export async function ensureProxyRunning(deps: EnsureProxyDeps = {}): Promise<EnsureProxyResult> {
const probe = deps.probe ?? (() => probeLiveness());
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
const startSystemd = deps.startSystemd ?? defaultStartSystemd;
const startNohup = deps.startNohup ?? (() => startNohupProxy());
const waitMs = deps.waitMs ?? defaultWait;
const settleMs = deps.settleMs ?? 500;
const startupDeadlineMs = deps.startupDeadlineMs ?? 5000;
if (await probe()) {
// Something answers on :18765 — trust it ONLY if it is provably our proxy.
return verifyListener() === 'ok'
? { live: true, method: 'already' }
: { live: false, method: 'untrusted' };
}
const systemd = startSystemd();
if (systemd.status === 0) {
// systemd accepted the job. Wait for a trusted-live bind, but never fall
// back to nohup afterward — that would risk a duplicate proxy (finding #1).
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
return { live: true, method: 'systemd' };
}
return { live: false, method: 'failed' };
}
const nohup = await startNohup();
if (nohup.status === 0) {
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
return { live: true, method: 'nohup' };
}
}
return { live: false, method: 'failed' };
}

View File

@@ -1,732 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync, writeFileSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import {
CLAUDEX_CONFIG_DIR_ENV,
CLAUDEX_DEFAULT_PRIMARY_MODEL,
CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
CLAUDEX_CREDENTIAL_ENV_RE,
defaultClaudexConfigDir,
assertIsolatedConfigDir,
resolveClaudexConfigDir,
resolveClaudexModels,
buildClaudexEnv,
buildClaudexBanner,
buildClaudexContractNote,
runClaudexProxyGate,
launchClaudex,
type ClaudexHarnessAdapter,
} from './claudex.js';
import { CLAUDEX_PROXY_URL, type PreflightReport } from './claudex-proxy.js';
// ─── helpers ─────────────────────────────────────────────────────────────────
function makeReport(overrides: Partial<PreflightReport> = {}): PreflightReport {
return {
binaryPresent: true,
binaryPath: '/usr/bin/claude-code-proxy',
auth: { state: 'valid' },
live: true,
listenerVerdict: 'ok',
needsReauth: false,
ok: true,
problems: [],
...overrides,
};
}
function okAdapter(overrides: Partial<ClaudexHarnessAdapter> = {}): ClaudexHarnessAdapter {
return {
harnessPreflight: () => {},
composePrompt: () => '# Composed Claude contract',
exec: () => {},
...overrides,
};
}
// Identity canonicalizer + no-op FS deps so config-dir logic is tested purely.
const idCanon = (p: string): string => p;
const noFsDeps = { canonicalize: idCanon, mkdir: () => {}, isSymlink: () => false };
// ─── isolated config dir (HARD SECURITY REQ 1 — provable isolation) ───────────
describe('defaultClaudexConfigDir', () => {
it('is namespaced under the mosaic home, never ~/.claude', () => {
const dir = defaultClaudexConfigDir('/home/agent/.config/mosaic');
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
expect(dir).not.toBe(join(homedir(), '.claude'));
});
});
describe('assertIsolatedConfigDir — the isolation guard is provable', () => {
const realClaude = '/home/agent/.claude';
it('accepts a dir that does not resolve to ~/.claude', () => {
const safe = '/home/agent/.config/mosaic/claudex/home';
expect(
assertIsolatedConfigDir(safe, { realClaudeDir: realClaude, canonicalize: idCanon }),
).toBe(safe);
});
it('REJECTS a candidate that is literally ~/.claude', () => {
expect(() =>
assertIsolatedConfigDir(realClaude, { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow(/refusing/i);
});
it('REJECTS a descendant of ~/.claude (would pollute the real tree)', () => {
expect(() =>
assertIsolatedConfigDir('/home/agent/.claude/projects/x', {
realClaudeDir: realClaude,
canonicalize: idCanon,
}),
).toThrow(/refusing/i);
});
it('REJECTS a candidate that canonically resolves to ~/.claude (symlink, both sides canonicalized)', () => {
const canon = (p: string): string => (p === '/home/agent/link' ? realClaude : p);
expect(() =>
assertIsolatedConfigDir('/home/agent/link', {
realClaudeDir: realClaude,
canonicalize: canon,
}),
).toThrow(/refusing/i);
});
it('canonicalizes the ~/.claude side too (real dir itself may be a symlink)', () => {
// realClaudeDir is a symlink whose canonical target equals the candidate's target.
const canon = (p: string): string =>
p === '/home/agent/.claude' || p === '/home/agent/link' ? '/canonical/claude' : p;
expect(() =>
assertIsolatedConfigDir('/home/agent/link', {
realClaudeDir: realClaude,
canonicalize: canon,
}),
).toThrow(/refusing/i);
});
it('REJECTS an empty or whitespace candidate (fail closed)', () => {
expect(() =>
assertIsolatedConfigDir('', { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow();
expect(() =>
assertIsolatedConfigDir(' ', { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow();
});
it('REJECTS a relative candidate (must be absolute)', () => {
expect(() =>
assertIsolatedConfigDir('relative/dir', { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow(/absolute/i);
});
});
describe('resolveClaudexConfigDir', () => {
it('uses the namespaced default and never the ambient CLAUDE_CONFIG_DIR', () => {
// Ambient CLAUDE_CONFIG_DIR is deliberately ignored (it could be ~/.claude).
const env = { CLAUDE_CONFIG_DIR: join(homedir(), '.claude') };
const dir = resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
...noFsDeps,
});
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
});
it('honors the dedicated override env when it is safe', () => {
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' };
const dir = resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
...noFsDeps,
});
expect(dir).toBe('/home/agent/custom-claudex');
});
it('REJECTS a dedicated override that points at ~/.claude (before creating anything)', () => {
const mkdir = vi.fn();
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/.claude' };
expect(() =>
resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
canonicalize: idCanon,
mkdir,
isSymlink: () => false,
}),
).toThrow(/refusing/i);
expect(mkdir).not.toHaveBeenCalled();
});
it('TOCTOU: REJECTS when the created target is itself a symlink (pre-created race)', () => {
const env = {};
expect(() =>
resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
canonicalize: idCanon,
mkdir: () => {},
isSymlink: () => true, // the just-ensured dir is a symlink → fail closed
}),
).toThrow(/refusing|symlink/i);
});
it('real-FS: creates the isolated dir 0700 and returns its canonical path', () => {
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
try {
const mosaicHome = join(root, '.config', 'mosaic');
const dir = resolveClaudexConfigDir({}, { mosaicHome, realClaudeDir: join(root, '.claude') });
expect(dir).toBe(join(mosaicHome, 'claudex', 'home'));
const st = lstatSync(dir);
expect(st.isDirectory()).toBe(true);
// 0700 (owner-only) — mask off the type bits.
expect(st.mode & 0o777).toBe(0o700);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('real-FS: catches an override whose ancestor symlinks into ~/.claude', () => {
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
try {
const realClaudeDir = join(root, 'dot-claude');
mkdirSync(realClaudeDir, { recursive: true });
const link = join(root, 'link'); // link -> dot-claude
symlinkSync(realClaudeDir, link, 'dir');
const override = join(link, 'sub'); // resolves under ~/.claude
expect(() =>
resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: override },
{ mosaicHome: join(root, '.config', 'mosaic'), realClaudeDir },
),
).toThrow(/refusing/i);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('FAIL CLOSED: default canonicalizer rethrows a non-ENOENT error (ELOOP) instead of a literal fallback', () => {
// A symlink loop makes realpathSync throw ELOOP. The guard must NOT swallow
// it as "does not exist yet, keep walking up" and return a literal path —
// it must fail closed. (REQ 1: fails CLOSED on any uncertainty.)
const root = mkdtempSync(join(tmpdir(), 'claudex-loop-'));
try {
const a = join(root, 'a');
const b = join(root, 'b');
symlinkSync(b, a, 'dir'); // a -> b
symlinkSync(a, b, 'dir'); // b -> a (loop)
const looped = join(a, 'home'); // canonicalizing this hits ELOOP
// No canonicalize dep → the real defaultCanonicalizeIntended runs.
expect(() =>
assertIsolatedConfigDir(looped, { realClaudeDir: join(root, '.claude') }),
).toThrow();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('FAIL CLOSED: default isSymlink rethrows a non-ENOENT error (ENOTDIR) rather than reporting "not a symlink"', () => {
// A candidate whose parent is a regular FILE makes lstat throw ENOTDIR.
// The post-create symlink check must fail closed, not treat it as safe.
const root = mkdtempSync(join(tmpdir(), 'claudex-notdir-'));
try {
const file = join(root, 'afile');
writeFileSync(file, 'x');
const candidate = join(file, 'child'); // parent is a file → ENOTDIR on lstat
expect(() =>
// Bypass the guard/mkdir side-effects; only the default isSymlink runs live.
resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: candidate },
{
realClaudeDir: join(root, '.claude'),
canonicalize: idCanon,
mkdir: () => {},
// isSymlink omitted → real defaultIsSymlink runs on the ENOTDIR path.
},
),
).toThrow();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('FAIL CLOSED: an injected canonicalize throwing EACCES is not swallowed', () => {
const eacces = Object.assign(new Error('permission denied'), { code: 'EACCES' });
expect(() =>
resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' },
{
realClaudeDir: '/home/agent/.claude',
canonicalize: () => {
throw eacces;
},
mkdir: () => {},
isSymlink: () => false,
},
),
).toThrow(/permission denied/);
});
});
// ─── model-tier map (P3) ──────────────────────────────────────────────────────
describe('resolveClaudexModels', () => {
it('defaults primary=sol / smallFast=luna', () => {
expect(resolveClaudexModels({})).toEqual({
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
});
expect(CLAUDEX_DEFAULT_PRIMARY_MODEL).toBe('gpt-5.6-sol');
expect(CLAUDEX_DEFAULT_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
});
it('env-provided values WIN over defaults', () => {
expect(
resolveClaudexModels({ ANTHROPIC_MODEL: 'gpt-x', ANTHROPIC_SMALL_FAST_MODEL: 'gpt-y' }),
).toEqual({ primary: 'gpt-x', smallFast: 'gpt-y' });
});
it('ignores blank env values (falls back to defaults)', () => {
expect(
resolveClaudexModels({ ANTHROPIC_MODEL: ' ', ANTHROPIC_SMALL_FAST_MODEL: '' }),
).toEqual({
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
});
});
});
// ─── env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
describe('buildClaudexEnv — zero token leakage', () => {
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
const configDir = '/home/agent/.config/mosaic/claudex/home';
it('sets only ANTHROPIC_AUTH_TOKEN=unused and points at the loopback proxy', () => {
const env = buildClaudexEnv({}, { configDir, models });
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
expect(env.CLAUDE_CONFIG_DIR).toBe(configDir);
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
});
it('OVERWRITES an inherited real auth token with the literal "unused"', () => {
const env = buildClaudexEnv(
{ ANTHROPIC_AUTH_TOKEN: 'sk-ant-realsecret-should-never-flow' },
{ configDir, models },
);
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
});
it('DELETES ANTHROPIC_API_KEY so no real Anthropic key reaches the local proxy', () => {
const env = buildClaudexEnv(
{ ANTHROPIC_API_KEY: 'sk-ant-api03-realkey' },
{ configDir, models },
);
expect('ANTHROPIC_API_KEY' in env).toBe(false);
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
});
it('sweeps the WHOLE credential-bearing env family (token/api-key/secret/oauth), not just two', () => {
const env = buildClaudexEnv(
{
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-leak',
SOME_SERVICE_TOKEN: 'tok-leak',
VENDOR_API_KEY: 'key-leak',
DB_SECRET: 'secret-leak',
HARMLESS: 'kept',
PATH: '/usr/bin',
},
{ configDir, models },
);
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
expect(env.SOME_SERVICE_TOKEN).toBeUndefined();
expect(env.VENDOR_API_KEY).toBeUndefined();
expect(env.DB_SECRET).toBeUndefined();
// Non-credential vars the harness needs are preserved.
expect(env.HARMLESS).toBe('kept');
expect(env.PATH).toBe('/usr/bin');
});
it('neutralizes Bedrock/Vertex provider switches so Claude cannot bypass the proxy (REQ 2)', () => {
// CLAUDE_CODE_USE_BEDROCK / _USE_VERTEX are ROUTING switches: their mere
// presence makes Claude Code route to AWS Bedrock / GCP Vertex against the
// ambient cloud credential chain — reaching the real Anthropic API and
// bypassing ANTHROPIC_BASE_URL (the loopback proxy) entirely. They MUST be
// gone from the composed env regardless of the launching env.
const env = buildClaudexEnv(
{
CLAUDE_CODE_USE_BEDROCK: '1',
CLAUDE_CODE_USE_VERTEX: '1',
CLAUDE_CODE_SKIP_BEDROCK_AUTH: '1',
CLAUDE_CODE_SKIP_VERTEX_AUTH: '1',
AWS_ACCESS_KEY_ID: 'AKIAREAL',
AWS_SECRET_ACCESS_KEY: 'realsecret',
AWS_SESSION_TOKEN: 'realsession',
AWS_BEARER_TOKEN_BEDROCK: 'bearer-bedrock-real',
AWS_REGION: 'us-east-1',
GOOGLE_APPLICATION_CREDENTIALS: '/home/agent/gcp.json',
GOOGLE_CLOUD_ACCESS_TOKEN: 'gcp-token-real',
PATH: '/usr/bin',
},
{ configDir, models },
);
// Routing switches gone by construction.
expect('CLAUDE_CODE_USE_BEDROCK' in env).toBe(false);
expect('CLAUDE_CODE_USE_VERTEX' in env).toBe(false);
expect('CLAUDE_CODE_SKIP_BEDROCK_AUTH' in env).toBe(false);
expect('CLAUDE_CODE_SKIP_VERTEX_AUTH' in env).toBe(false);
// Cloud credentials swept — none of the Claude-capable creds survive.
expect(env.AWS_ACCESS_KEY_ID).toBeUndefined();
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
expect(env.AWS_SESSION_TOKEN).toBeUndefined();
expect(env.AWS_BEARER_TOKEN_BEDROCK).toBeUndefined();
expect(env.AWS_REGION).toBeUndefined();
expect(env.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined();
expect(env.GOOGLE_CLOUD_ACCESS_TOKEN).toBeUndefined();
// The proxy routing is still the only path.
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.PATH).toBe('/usr/bin');
});
it('closes the mid-string _KEY / _SECRET gap (STRIPE_SECRET_KEY, SSH_PRIVATE_KEY)', () => {
const env = buildClaudexEnv(
{
STRIPE_SECRET_KEY: 'sk-live-real',
SSH_PRIVATE_KEY: '-----BEGIN OPENSSH PRIVATE KEY-----',
HARMLESS: 'kept',
},
{ configDir, models },
);
expect(env.STRIPE_SECRET_KEY).toBeUndefined();
expect(env.SSH_PRIVATE_KEY).toBeUndefined();
expect(env.HARMLESS).toBe('kept');
});
it('no credential-NAMED key in the composed env carries a real-looking value', () => {
const env = buildClaudexEnv(
{
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
ANTHROPIC_AUTH_TOKEN: 'access_token_leak',
SOME_JWT_TOKEN: 'eyJhbGciOiJIUzI1NiJ9.payload.sig',
REFRESH_SECRET: 'refresh_token_value',
},
{ configDir, models },
);
for (const [name, value] of Object.entries(env)) {
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) {
// Any surviving credential-named var must carry only a safe sentinel value.
expect(value).not.toMatch(/sk-(ant|proj)-/);
expect(value).not.toMatch(/access_token|refresh_token/);
expect(value).not.toMatch(/eyJ[A-Za-z0-9_-]+\./); // JWT
}
}
});
it('honors a caller-provided baseUrl override (loopback default otherwise)', () => {
const env = buildClaudexEnv({}, { configDir, models, baseUrl: 'http://127.0.0.1:9999' });
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9999');
});
it('returns a fresh object without mutating the base env', () => {
const base = { EXISTING: 'kept' };
const env = buildClaudexEnv(base, { configDir, models });
expect(env.EXISTING).toBe('kept');
expect(base).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
});
});
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
describe('buildClaudexBanner / buildClaudexContractNote', () => {
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
it('banner marks EXPERIMENTAL and names the models + proxy', () => {
const banner = buildClaudexBanner(models);
expect(banner).toMatch(/EXPERIMENTAL/);
expect(banner).toMatch(/gpt-5\.6-sol/);
expect(banner).toMatch(/gpt-5\.6-luna/);
expect(banner).toMatch(/claude-code-proxy/);
expect(banner).not.toMatch(/unused/); // no token material in the banner
});
it('contract note classifies the runtime as EXPERIMENTAL GPT-via-proxy', () => {
const note = buildClaudexContractNote(models);
expect(note).toMatch(/EXPERIMENTAL/);
expect(note).toMatch(/gpt-5\.6-sol/);
expect(note).toMatch(/not.*Anthropic/i);
});
});
// ─── proxy gate ───────────────────────────────────────────────────────────────
describe('runClaudexProxyGate', () => {
it('is ok when the first preflight already passes', async () => {
const preflight = vi.fn().mockResolvedValue(makeReport());
const ensureProxy = vi.fn();
const reauth = vi.fn();
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
expect(gate.ok).toBe(true);
expect(ensureProxy).not.toHaveBeenCalled();
expect(reauth).not.toHaveBeenCalled();
});
it('fails fast when the binary is missing (no reauth, no start)', async () => {
const preflight = vi
.fn()
.mockResolvedValue(
makeReport({ binaryPresent: false, ok: false, problems: ['binary not found'] }),
);
const ensureProxy = vi.fn();
const reauth = vi.fn();
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
expect(gate.ok).toBe(false);
expect(reauth).not.toHaveBeenCalled();
expect(ensureProxy).not.toHaveBeenCalled();
});
it('runs device reauth then re-preflights when OAuth needs it', async () => {
const preflight = vi
.fn()
.mockResolvedValueOnce(
makeReport({
auth: { state: 'expired' },
needsReauth: true,
ok: false,
problems: ['expired'],
}),
)
.mockResolvedValueOnce(makeReport());
const reauth = vi.fn().mockReturnValue(0);
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
expect(reauth).toHaveBeenCalledTimes(1);
expect(preflight).toHaveBeenCalledTimes(2);
expect(gate.ok).toBe(true);
});
it('does NOT reauth when auth is already valid', async () => {
const preflight = vi.fn().mockResolvedValue(makeReport());
const reauth = vi.fn();
await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
expect(reauth).not.toHaveBeenCalled();
});
it('aborts when device reauth fails', async () => {
const preflight = vi.fn().mockResolvedValue(
makeReport({
auth: { state: 'unauthenticated' },
needsReauth: true,
ok: false,
problems: ['unauth'],
}),
);
const reauth = vi.fn().mockReturnValue(1);
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
expect(gate.ok).toBe(false);
expect(gate.problems.join(' ')).toMatch(/re-auth/i);
});
it('starts the proxy then re-preflights when nothing is live', async () => {
const preflight = vi
.fn()
.mockResolvedValueOnce(
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
)
.mockResolvedValueOnce(makeReport());
const ensureProxy = vi.fn().mockResolvedValue({ live: true, method: 'systemd' });
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
expect(ensureProxy).toHaveBeenCalledTimes(1);
expect(gate.ok).toBe(true);
});
it('aborts (non-sensitive) when the proxy cannot come up trusted', async () => {
const preflight = vi
.fn()
.mockResolvedValue(
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
);
const ensureProxy = vi.fn().mockResolvedValue({ live: false, method: 'untrusted' });
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
expect(gate.ok).toBe(false);
expect(gate.problems.join(' ')).toMatch(/untrusted/);
// Non-sensitive: no token material in surfaced problems.
expect(gate.problems.join(' ')).not.toMatch(/access_token|refresh_token|sk-/);
});
});
// ─── launch orchestration (fail-closed ordering) ──────────────────────────────
describe('launchClaudex', () => {
const baseDeps = {
baseEnv: {},
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
log: () => {},
errorLog: () => {},
fail: (() => {
throw new Error('exit');
}) as (code: number) => never,
};
it('yolo=true passes --dangerously-skip-permissions + injected env to claude', async () => {
const exec = vi.fn();
await launchClaudex(['--print', 'hi'], true, okAdapter({ exec }), baseDeps);
expect(exec).toHaveBeenCalledTimes(1);
const [cmd, args, env] = exec.mock.calls[0]!;
expect(cmd).toBe('claude');
expect(args[0]).toBe('--dangerously-skip-permissions');
expect(args).toContain('--append-system-prompt');
expect(args).toContain('--print');
expect(args).toContain('hi');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
expect(env.CLAUDE_CONFIG_DIR).toBe('/home/agent/.config/mosaic/claudex/home');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
});
it('non-yolo omits --dangerously-skip-permissions but still injects the proxy env', async () => {
const exec = vi.fn();
await launchClaudex([], false, okAdapter({ exec }), baseDeps);
const [, args, env] = exec.mock.calls[0]!;
expect(args).not.toContain('--dangerously-skip-permissions');
expect(args[0]).toBe('--append-system-prompt');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
});
it('appends the EXPERIMENTAL contract note to the composed prompt', async () => {
const exec = vi.fn();
await launchClaudex([], true, okAdapter({ exec, composePrompt: () => '# BASE' }), baseDeps);
const args = exec.mock.calls[0]![1] as string[];
const promptIdx = args.indexOf('--append-system-prompt') + 1;
expect(args[promptIdx]).toContain('# BASE');
expect(args[promptIdx]).toMatch(/EXPERIMENTAL/);
});
it('runs the harness preflight BEFORE the proxy gate and exec', async () => {
const order: string[] = [];
const adapter = okAdapter({
harnessPreflight: () => order.push('preflight'),
exec: () => order.push('exec'),
});
await launchClaudex([], true, adapter, {
...baseDeps,
proxyGate: () => {
order.push('gate');
return Promise.resolve({ ok: true, report: makeReport(), problems: [] });
},
});
expect(order).toEqual(['preflight', 'gate', 'exec']);
});
it('FAIL CLOSED: exits WITHOUT exec when the proxy gate fails', async () => {
const exec = vi.fn();
const errors: string[] = [];
await expect(
launchClaudex([], true, okAdapter({ exec }), {
...baseDeps,
proxyGate: () =>
Promise.resolve({ ok: false, report: makeReport({ ok: false }), problems: ['no proxy'] }),
errorLog: (m: string) => errors.push(m),
}),
).rejects.toThrow('exit');
expect(exec).not.toHaveBeenCalled();
expect(errors.join('\n')).toMatch(/no proxy/);
});
it('FAIL CLOSED: exits WITHOUT exec when the config-dir guard throws', async () => {
const exec = vi.fn();
await expect(
launchClaudex([], true, okAdapter({ exec }), {
...baseDeps,
resolveConfigDir: () => {
throw new Error('refusing to use ~/.claude');
},
}),
).rejects.toThrow('exit');
expect(exec).not.toHaveBeenCalled();
});
it('FAIL CLOSED: reports a non-Error throw via String() and still aborts', async () => {
const exec = vi.fn();
const errors: string[] = [];
await expect(
launchClaudex([], true, okAdapter({ exec }), {
...baseDeps,
resolveConfigDir: () => {
// A non-Error throw exercises the String(err) branch of the catch.
throw { toString: () => 'string-shaped failure' };
},
errorLog: (m: string) => errors.push(m),
}),
).rejects.toThrow('exit');
expect(exec).not.toHaveBeenCalled();
expect(errors.join('\n')).toMatch(/string-shaped failure/);
});
});
// ─── production DI defaults (fallback-branch coverage; no real proxy touched) ──
describe('production dependency defaults', () => {
it('assertIsolatedConfigDir defaults realClaudeDir to ~/.claude', () => {
const safe = join(tmpdir(), 'mosaic-claudex-default-real', 'home');
// Only canonicalize injected; realClaudeDir falls back to ~/.claude.
expect(assertIsolatedConfigDir(safe, { canonicalize: idCanon })).toBe(safe);
});
it('assertIsolatedConfigDir default canonicalizer resolves a non-existent path', () => {
// No canonicalize dep → exercises the real realpath-longest-ancestor walk
// (including the not-yet-existing tail), on a path safely outside ~/.claude.
const safe = join(tmpdir(), 'mosaic-claudex-canon', 'nested', 'home');
expect(assertIsolatedConfigDir(safe)).toContain('mosaic-claudex-canon');
});
it('resolveClaudexConfigDir defaults mosaicHome when not injected', () => {
const dir = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
try {
const target = join(dir, 'home');
// Override env points elsewhere; mosaicHome dep omitted → MOSAIC_HOME default path is exercised.
const out = resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: target },
{ canonicalize: idCanon },
);
expect(out).toBe(target);
expect(lstatSync(target).isDirectory()).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('runClaudexProxyGate defaults ensureProxy/reauth/log without invoking them on a missing binary', async () => {
// Only preflight injected; binary missing → returns before the default
// ensureProxy/reauth thunks could ever reach the real proxy.
const preflight = vi
.fn()
.mockResolvedValue(makeReport({ binaryPresent: false, ok: false, problems: ['missing'] }));
const gate = await runClaudexProxyGate({ preflight });
expect(gate.ok).toBe(false);
});
it('launchClaudex defaults log/errorLog/fail/baseEnv/models/buildEnv on the success path', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
const exec = vi.fn();
// Inject only the boundaries that would touch the real proxy/FS; let the
// rest default. Success path never calls fail/errorLog.
await launchClaudex([], true, okAdapter({ exec }), {
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
resolveConfigDir: () => join(tmpdir(), 'claudex-default-launch'),
});
expect(exec).toHaveBeenCalledTimes(1);
const [, , env] = exec.mock.calls[0]!;
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.ANTHROPIC_MODEL).toBe(CLAUDEX_DEFAULT_PRIMARY_MODEL);
} finally {
logSpy.mockRestore();
}
});
});

View File

@@ -1,464 +0,0 @@
/**
* Claudex launch composition (P2P4 of `mosaic yolo claudex`).
*
* Builds the isolated launch environment for running GPT models inside the
* Claude Code harness via `raine/claude-code-proxy` (ChatGPT-subscription OAuth).
* PR-1 (`claudex-proxy.ts`) owns the proxy preflight/lifecycle; this module owns
* the *composition* the launcher hands to Claude Code:
*
* P2 isolated CLAUDE_CONFIG_DIR (provably never the real ~/.claude) + env
* injection that leaks ZERO token material;
* P3 the model-tier map (primary → gpt-5.6-sol, small/fast → gpt-5.6-luna,
* operator env values win);
* P4 the EXPERIMENTAL classification banner + composed-contract note.
*
* Two hard security invariants (secrev-enforced):
* REQ 1 — Provable isolation. {@link assertIsolatedConfigDir} makes the
* CLAUDE_CONFIG_DIR seam incapable of resolving to `~/.claude` (or any
* descendant of it); it canonicalizes both sides, rejects descendants,
* and — after ensuring the dir — re-checks and rejects a symlinked
* target (TOCTOU). Fails CLOSED on any uncertainty.
* REQ 2 — Zero token leakage. This module never reads the proxy's
* `auth.json`; {@link buildClaudexEnv} strips the ENTIRE
* credential-bearing env family and hands Claude Code only
* `ANTHROPIC_AUTH_TOKEN=unused`. The proxy holds the real credential.
*
* Every side-effecting boundary is dependency-injected so the launch path is
* unit-testable without spawning Claude Code or touching a real config dir.
*/
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import {
CLAUDEX_PROXY_URL,
ensureProxyRunning,
runDeviceReauth,
runProxyPreflight,
type EnsureProxyResult,
type PreflightReport,
} from './claudex-proxy.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
// ─── Isolated CLAUDE_CONFIG_DIR (HARD SECURITY REQ 1) ─────────────────────────
/** Dedicated override env for the isolated config dir. The ambient
* `CLAUDE_CONFIG_DIR` is deliberately NOT honored — it may already point at the
* real `~/.claude` of the launching session. */
export const CLAUDEX_CONFIG_DIR_ENV = 'MOSAIC_CLAUDEX_CONFIG_DIR';
/** The default isolated config dir — structurally under the mosaic home, so it
* can never equal `~/.claude`. */
export function defaultClaudexConfigDir(mosaicHome: string = MOSAIC_HOME): string {
return join(mosaicHome, 'claudex', 'home');
}
export interface ConfigDirDeps {
/** The real Claude state dir to protect (default `~/.claude`). */
realClaudeDir?: string;
/** Resolve a path to canonical form, resolving symlinks on the longest
* existing ancestor (so a not-yet-created dir still canonicalizes). */
canonicalize?: (p: string) => string;
/** Ensure the isolated dir exists (mkdir -p, owner-only 0700). */
mkdir?: (p: string) => void;
/** Whether a path is itself a symlink (lstat). */
isSymlink?: (p: string) => boolean;
}
/** Resolve a path to canonical form, resolving symlinks on the LONGEST EXISTING
* ancestor and re-appending the not-yet-existing tail. A symlinked ancestor that
* points into `~/.claude` is therefore caught even before the leaf exists. */
function defaultCanonicalizeIntended(p: string): string {
const abs = resolve(p);
let existing = abs;
const tail: string[] = [];
// Walk up until we hit an existing ancestor (or the filesystem root).
for (;;) {
try {
const real = realpathSync(existing);
return tail.length > 0 ? join(real, ...tail) : real;
} catch (err) {
// ONLY a genuine "does not exist yet" (ENOENT) justifies walking up to an
// existing ancestor. Any other errno (ELOOP, EACCES, ENOTDIR, …) means we
// cannot establish the canonical form — fail CLOSED rather than fall back
// to a possibly-wrong literal path.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
const parent = dirname(existing);
if (parent === existing) return abs; // reached root without an existing prefix
tail.unshift(existing.slice(parent.length + 1));
existing = parent;
}
}
}
function defaultMkdir(p: string): void {
mkdirSync(p, { recursive: true, mode: 0o700 });
}
function defaultIsSymlink(p: string): boolean {
try {
return lstatSync(p).isSymbolicLink();
} catch (err) {
// A missing path is genuinely "not a symlink"; anything else (EACCES, ELOOP,
// ENOTDIR, …) is uncertainty the guard must not swallow — fail CLOSED.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw err;
}
}
/** True when `child` is `parent` itself or a descendant of it (path-wise). */
function isWithin(child: string, parent: string): boolean {
if (child === parent) return true;
const rel = relative(parent, child);
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
}
/**
* Guard: prove that `candidate` is a legitimate ISOLATED config dir and can
* never be, resolve to, or live under the real `~/.claude`. Canonicalizes BOTH
* sides (either may be a symlink), rejects `~/.claude` and any descendant, and
* fails CLOSED (throws) on an empty/relative candidate. Returns the canonical
* isolated path on success.
*/
export function assertIsolatedConfigDir(candidate: string, deps: ConfigDirDeps = {}): string {
const canonicalize = deps.canonicalize ?? defaultCanonicalizeIntended;
const realClaudeDir = deps.realClaudeDir ?? join(homedir(), '.claude');
if (typeof candidate !== 'string' || candidate.trim() === '') {
throw new Error('claudex: isolated CLAUDE_CONFIG_DIR must be a non-empty path (fail closed).');
}
if (!isAbsolute(candidate)) {
throw new Error(
`claudex: isolated CLAUDE_CONFIG_DIR must be an absolute path: ${JSON.stringify(candidate)}`,
);
}
const canonCandidate = canonicalize(candidate);
const canonReal = canonicalize(realClaudeDir);
// Compare canonical forms AND raw resolved forms — belt and suspenders so a
// canonicalizer that no-ops on a nonexistent real dir still catches the literal.
if (isWithin(canonCandidate, canonReal) || isWithin(resolve(candidate), resolve(realClaudeDir))) {
throw new Error(
`claudex: refusing to use the real Claude config dir (or a descendant of it) as the ` +
`isolated CLAUDE_CONFIG_DIR. Resolved to ${JSON.stringify(canonCandidate)}.`,
);
}
return canonCandidate;
}
/**
* Resolve the isolated CLAUDE_CONFIG_DIR: pick the dedicated override or the
* namespaced default, run the pre-create guard, ensure the dir (0700), then
* RE-CHECK after creation — reject a symlinked target and re-run the guard on
* the now-existing (fully canonicalizable) path. This closes the pre-created
* symlink race (TOCTOU). Every failure throws (fail closed).
*/
export function resolveClaudexConfigDir(
env: NodeJS.ProcessEnv = process.env,
deps: ConfigDirDeps & { mosaicHome?: string } = {},
): string {
const mosaicHome = deps.mosaicHome ?? MOSAIC_HOME;
const mkdir = deps.mkdir ?? defaultMkdir;
const isSymlink = deps.isSymlink ?? defaultIsSymlink;
const override = env[CLAUDEX_CONFIG_DIR_ENV]?.trim();
const candidate =
override && override.length > 0 ? override : defaultClaudexConfigDir(mosaicHome);
// Pre-create guard (before touching the filesystem).
assertIsolatedConfigDir(candidate, deps);
// Ensure the dir, then re-verify against the post-create reality.
mkdir(candidate);
if (isSymlink(candidate)) {
throw new Error(
'claudex: refusing to use the isolated CLAUDE_CONFIG_DIR — the target is a symlink ' +
'(possible pre-created race). Fail closed.',
);
}
// Re-run the guard now that the leaf exists so canonicalization reflects any
// symlinked ancestor introduced between the pre-check and mkdir.
return assertIsolatedConfigDir(candidate, deps);
}
// ─── Model-tier map (P3) ──────────────────────────────────────────────────────
export const CLAUDEX_DEFAULT_PRIMARY_MODEL = 'gpt-5.6-sol';
export const CLAUDEX_DEFAULT_SMALL_FAST_MODEL = 'gpt-5.6-luna';
export interface ClaudexModels {
/** Primary tier (opus/sonnet) → ANTHROPIC_MODEL. */
primary: string;
/** Small/fast tier (haiku) → ANTHROPIC_SMALL_FAST_MODEL. */
smallFast: string;
}
/** Resolve the model-tier map. Operator-provided env values WIN over defaults;
* blank values fall back to the defaults. */
export function resolveClaudexModels(env: NodeJS.ProcessEnv = process.env): ClaudexModels {
const primary = env['ANTHROPIC_MODEL']?.trim() || CLAUDEX_DEFAULT_PRIMARY_MODEL;
const smallFast = env['ANTHROPIC_SMALL_FAST_MODEL']?.trim() || CLAUDEX_DEFAULT_SMALL_FAST_MODEL;
return { primary, smallFast };
}
// ─── Env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
/**
* Names of env vars considered credential-bearing. The whole family is stripped
* from the composed env so no real Anthropic key, OAuth token, or third-party /
* cloud credential can reach the local proxy or be used by Claude Code to bypass
* it. We then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`,
* `_SMALL_FAST_MODEL`, `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep
* can't miss a specific var a short denylist forgot, while still preserving the
* arbitrary non-credential env the harness/MCP/hooks require (PATH, HOME, XDG,
* terminal, proxies, …), which a strict allowlist would fragilely drop.
*
* The cloud-provider families (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS`,
* `GOOGLE_CLOUD_*`, `GCP_*`) are included because Claude Code can route to the
* real Anthropic API via AWS Bedrock / GCP Vertex using the ambient cloud
* credential chain — a Claude-capable credential that must never survive into a
* claudex launch. `_KEY$` / `_SECRET` (not just the `_API_KEY$` tail) close the
* mid-string gap (`STRIPE_SECRET_KEY`, `SSH_PRIVATE_KEY`, `AWS_SECRET_ACCESS_KEY`).
*/
export const CLAUDEX_CREDENTIAL_ENV_RE =
/^ANTHROPIC_|^CLAUDE_CODE_OAUTH|^AWS_|^GOOGLE_APPLICATION_CREDENTIALS$|^GOOGLE_CLOUD_|^GCP_|_API_?KEY$|_KEY$|_TOKEN$|_SECRET/i;
/**
* Provider ROUTING switches whose mere PRESENCE (independent of any credential)
* makes Claude Code bypass `ANTHROPIC_BASE_URL` (the loopback proxy) and talk to
* the real Anthropic API via Bedrock/Vertex. A name-pattern is the wrong model
* for a boolean switch, so these are force-deleted by exact name — REGARDLESS of
* value — after the credential sweep. (REQ 2: isolation must hold for any
* launching env, including a Bedrock/Vertex-configured enterprise host.)
*/
export const CLAUDEX_FORCED_UNSET_ENV = [
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
] as const;
export interface BuildClaudexEnvOptions {
configDir: string;
models: ClaudexModels;
/** Override the proxy base URL (defaults to the PR-1 loopback constant). */
baseUrl?: string;
}
/**
* Compose the launch env for Claude Code. Returns a FRESH object (never mutates
* the base env). Strips the entire credential-bearing family AND force-deletes
* the Bedrock/Vertex routing switches (REQ 2), then sets the isolated config dir
* and the proxy routing. Claude Code sees only `ANTHROPIC_AUTH_TOKEN=unused`
* pointed at the loopback proxy; the proxy holds the real OAuth credential, which
* this module never reads.
*/
export function buildClaudexEnv(
baseEnv: NodeJS.ProcessEnv,
opts: BuildClaudexEnvOptions,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [name, value] of Object.entries(baseEnv)) {
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) continue; // drop the whole credential family
env[name] = value;
}
// Force-delete routing switches by exact name — their presence (not their
// value) is what would route Claude Code off the proxy to the real API.
for (const name of CLAUDEX_FORCED_UNSET_ENV) delete env[name];
env['CLAUDE_CONFIG_DIR'] = opts.configDir;
env['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL;
env['ANTHROPIC_AUTH_TOKEN'] = 'unused';
env['ANTHROPIC_MODEL'] = opts.models.primary;
env['ANTHROPIC_SMALL_FAST_MODEL'] = opts.models.smallFast;
return env;
}
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
/** Console banner shown at launch. Contains no token material by construction. */
export function buildClaudexBanner(models: ClaudexModels): string {
return [
'',
' ┌─────────────────────────────────────────────────────────────────────┐',
' │ ⚠ EXPERIMENTAL — mosaic claudex │',
' └─────────────────────────────────────────────────────────────────────┘',
` Running GPT models inside the Claude Code harness via claude-code-proxy`,
` (ChatGPT-subscription OAuth). This is NOT Anthropic Claude.`,
` primary : ${models.primary}`,
` small/fast : ${models.smallFast}`,
` Model behavior, tool use, and output quality may differ from Claude.`,
'',
].join('\n');
}
/** Markdown note appended to the composed runtime contract so the model itself
* knows it is running the EXPERIMENTAL GPT-via-proxy configuration. */
export function buildClaudexContractNote(models: ClaudexModels): string {
return [
'# EXPERIMENTAL Runtime — claudex (GPT via claude-code-proxy)',
'',
'You are running in Mosaic **claudex** mode: the Claude Code harness is wired to',
'GPT models through a local `claude-code-proxy` (ChatGPT-subscription OAuth). This',
"runtime is NOT Anthropic's Claude API and is not Claude.",
'',
`- Primary model: \`${models.primary}\``,
`- Small/fast model: \`${models.smallFast}\``,
'',
'Some Claude-specific harness assumptions may not hold under GPT models — verify',
'tool output carefully. This classification is EXPERIMENTAL and is not intended for',
'production delivery without explicit operator sign-off.',
].join('\n');
}
// ─── Proxy gate ───────────────────────────────────────────────────────────────
export interface ProxyGateResult {
ok: boolean;
report: PreflightReport;
/** Non-sensitive problems suitable for surfacing to the operator. */
problems: string[];
}
export interface ProxyGateDeps {
preflight?: () => Promise<PreflightReport>;
ensureProxy?: () => Promise<EnsureProxyResult>;
reauth?: () => number;
log?: (message: string) => void;
}
/**
* Run the proxy readiness gate: preflight → (device reauth if OAuth needs it) →
* (start the proxy if nothing trusted is live) → re-preflight. Returns `ok` only
* when the final preflight passes (binary present, OAuth valid, a TRUSTED-live
* listener — identity verified by PR-1's `verifyListenerIdentity`). All surfaced
* problems are non-sensitive (port + verdict only; never a token).
*/
export async function runClaudexProxyGate(deps: ProxyGateDeps = {}): Promise<ProxyGateResult> {
const preflight = deps.preflight ?? (() => runProxyPreflight());
const ensureProxy = deps.ensureProxy ?? (() => ensureProxyRunning());
const reauth = deps.reauth ?? (() => runDeviceReauth());
const log = deps.log ?? (() => {});
let report = await preflight();
// A missing binary is unrecoverable here — don't attempt reauth or a start.
if (!report.binaryPresent) {
return { ok: false, report, problems: report.problems };
}
if (report.needsReauth) {
log('claudex: claude-code-proxy OAuth needs re-authentication — starting device flow…');
const code = reauth();
if (code !== 0) {
return {
ok: false,
report,
problems: [...report.problems, 'claudex: device re-authentication did not complete.'],
};
}
report = await preflight();
}
if (!report.live) {
log('claudex: no trusted claude-code-proxy responding — starting it…');
const started = await ensureProxy();
if (!started.live) {
return {
ok: false,
report,
problems: [
...report.problems,
`claudex: could not bring up a trusted claude-code-proxy (${started.method}).`,
],
};
}
report = await preflight();
}
return { ok: report.ok, report, problems: report.problems };
}
// ─── Launch orchestration ─────────────────────────────────────────────────────
/**
* The launch.ts-provided seam. Keeps `claudex.ts` free of a circular import back
* into `launch.ts` while letting the orchestration reuse the harness preflight,
* the composed runtime contract, and the process-replacing exec.
*/
export interface ClaudexHarnessAdapter {
/** Runs the Claude-harness preflight (mosaic home, SOUL, `claude` on PATH,
* sequential-thinking). May terminate the process on a hard failure. */
harnessPreflight: () => void;
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
composePrompt: () => string;
/** Replace the current process with `claude` using the composed env. */
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
}
export interface LaunchClaudexDeps {
baseEnv?: NodeJS.ProcessEnv;
proxyGate?: () => Promise<ProxyGateResult>;
resolveConfigDir?: () => string;
models?: () => ClaudexModels;
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
log?: (message: string) => void;
errorLog?: (message: string) => void;
fail?: (code: number) => never;
}
/**
* Orchestrate a `mosaic [yolo] claudex` launch. Runs the harness preflight, the
* proxy gate, composes the isolated env (REQ 1 + REQ 2), appends the EXPERIMENTAL
* note, and exec's Claude Code. FAIL CLOSED: on any gate failure or guard throw
* it reports non-sensitive detail and exits WITHOUT reaching exec.
*/
export async function launchClaudex(
args: string[],
yolo: boolean,
adapter: ClaudexHarnessAdapter,
deps: LaunchClaudexDeps = {},
): Promise<void> {
const log = deps.log ?? ((m: string) => console.log(m));
const errorLog = deps.errorLog ?? ((m: string) => console.error(m));
const fail = deps.fail ?? ((code: number) => process.exit(code));
const baseEnv = deps.baseEnv ?? process.env;
const proxyGate = deps.proxyGate ?? (() => runClaudexProxyGate({ log }));
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
try {
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
adapter.harnessPreflight();
// Proxy readiness (binary, OAuth, trusted-live listener).
const gate = await proxyGate();
if (!gate.ok) {
errorLog('[mosaic] claudex preflight failed:');
for (const problem of gate.problems) errorLog(` - ${problem}`);
return fail(1);
}
// Compose the isolated launch env (guard throws → caught below, fail closed).
const resolvedModels = models();
const configDir = resolveConfigDir();
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
log(buildClaudexBanner(resolvedModels));
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
cliArgs.push('--append-system-prompt', prompt, ...args);
adapter.exec('claude', cliArgs, env);
} catch (err) {
errorLog(
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
);
return fail(1);
}
}

View File

@@ -1,20 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
accessSync,
chmodSync,
constants,
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
readFileSync,
renameSync,
symlinkSync,
} from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FileConfigAdapter } from '../config/file-adapter.js';
import { composeContract } from './launch.js';
/**
@@ -35,9 +22,7 @@ import { composeContract } from './launch.js';
const CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n';
const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n';
const USER = '# operator\n\nName: Test Operator\n';
const TOOLS = '# tools index\n\n<!-- fleet-comms-contract: 1 -->\n';
const FRAMEWORK_SOURCE = fileURLToPath(new URL('../../framework', import.meta.url));
const SOURCE_TOOLS_PATH = join(FRAMEWORK_SOURCE, 'defaults', 'TOOLS.md');
const TOOLS = '# tools index\n';
function makeHome(): { home: string; root: string } {
const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-'));
@@ -46,12 +31,6 @@ function makeHome(): { home: string; root: string } {
mkdirSync(join(home, 'runtime', h), { recursive: true });
writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`);
}
mkdirSync(join(home, 'defaults'), { recursive: true });
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(home, 'defaults', 'TOOLS.md'), TOOLS);
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
writeFileSync(helper, '#!/bin/sh\n');
chmodSync(helper, 0o755);
writeFileSync(join(home, 'CONSTITUTION.md'), CONSTITUTION);
writeFileSync(join(home, 'AGENTS.md'), AGENTS);
writeFileSync(join(home, 'USER.md'), USER);
@@ -63,31 +42,16 @@ describe('composeContract — overlay composer', () => {
let fixture: ReturnType<typeof makeHome>;
let prevCwd: string;
let cwdDir: string;
let prevAgentName: string | undefined;
let prevAgentClass: string | undefined;
let prevAgentToolPolicy: string | undefined;
beforeEach(() => {
fixture = makeHome();
prevCwd = process.cwd();
prevAgentName = process.env['MOSAIC_AGENT_NAME'];
prevAgentClass = process.env['MOSAIC_AGENT_CLASS'];
prevAgentToolPolicy = process.env['MOSAIC_AGENT_TOOL_POLICY'];
delete process.env['MOSAIC_AGENT_NAME'];
delete process.env['MOSAIC_AGENT_CLASS'];
delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-'));
process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks
});
afterEach(() => {
process.chdir(prevCwd);
if (prevAgentName === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prevAgentName;
if (prevAgentClass === undefined) delete process.env['MOSAIC_AGENT_CLASS'];
else process.env['MOSAIC_AGENT_CLASS'] = prevAgentClass;
if (prevAgentToolPolicy === undefined) delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
else process.env['MOSAIC_AGENT_TOOL_POLICY'] = prevAgentToolPolicy;
rmSync(fixture.root, { recursive: true, force: true });
rmSync(cwdDir, { recursive: true, force: true });
});
@@ -100,17 +64,13 @@ describe('composeContract — overlay composer', () => {
[
'version: 1',
'transport: tmux',
'tmux:',
' socket_name: mosaic-fleet',
'agents:',
' - name: orchestrator',
' runtime: claude',
' class: orchestrator',
' host: w-jarvis',
' - name: enhancer',
' runtime: claude',
' class: enhancer',
' host: w-jarvis',
' - name: coder0-0',
' runtime: claude',
' class: implementer',
@@ -122,266 +82,19 @@ describe('composeContract — overlay composer', () => {
const prev = process.env['MOSAIC_AGENT_NAME'];
try {
process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
const outputs = (['claude', 'codex', 'opencode', 'pi'] as const).map((runtime) =>
composeContract(runtime, fixture.home),
);
for (const out of outputs) {
const out = composeContract('claude', fixture.home);
expect(out).toContain('# Fleet Comms');
expect(out).toContain('Host: `w-jarvis`');
expect(out).toContain('Agent/session: `enhancer`');
expect(out).toContain('tmux socket: `mosaic-fleet`');
expect(out).toContain(
`Helper: \`${join(fixture.home, 'tools', 'tmux', 'agent-send.sh')}\``,
);
expect(out).toContain('-L mosaic-fleet -s orchestrator -m "…"');
expect(out).toContain('-L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator');
}
const commsSection = (out: string): string => out.slice(out.indexOf('# Fleet Comms'));
const authoritative = commsSection(outputs[0]!);
expect(outputs.map(commsSection)).toEqual([
authoritative,
authoritative,
authoritative,
authoritative,
]);
expect(out).toMatch(/`\[[^\]]+:enhancer\]`/); // own [host:session] identity (host machine-dependent)
// local peer → no -H; cross-host peer → -H ssh
expect(out).toContain('-s orchestrator -m "…"');
expect(out).toContain('-H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator'); // local stays local
} finally {
if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prev;
}
});
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'derives canonical class, persona, tool policy, and comms from one roster member for %s',
(runtime) => {
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roles', 'code.md'),
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
);
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: exact-coder',
` runtime: ${runtime}`,
' class: implementer',
' tool_policy: operator-interaction',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'ambient-policy-must-not-win';
const out = composeContract(runtime, fixture.home);
expect(out).toContain('# Persona Contract (code)');
expect(out).toContain('CANONICAL-CODE-MANDATE');
expect(out).toContain('Role/class: `code`');
expect(out).toContain('# Fleet Tool Policy (operator-interaction)');
expect(out).not.toContain('ambient-policy-must-not-win');
expect(out.indexOf('# Persona Contract')).toBeLessThan(out.indexOf('# Fleet Comms'));
},
);
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'does not inherit ambient tool policy when canonical member omits tool_policy for %s',
(runtime) => {
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roles', 'code.md'),
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
);
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: exact-coder',
` runtime: ${runtime}`,
' class: implementer',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'operator-interaction';
const out = composeContract(runtime, fixture.home);
expect(out).toContain('# Persona Contract (code)');
expect(out).toContain('Role/class: `code`');
expect(out).toContain('# Fleet Comms');
expect(out).not.toContain('# Fleet Tool Policy (operator-interaction)');
},
);
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'rejects an ambient class that mismatches the canonical roster member for %s',
(runtime) => {
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: exact-coder',
` runtime: ${runtime}`,
' class: implementer',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
process.env['MOSAIC_AGENT_CLASS'] = 'reviewer';
expect(() => composeContract(runtime, fixture.home)).toThrow(
/ambient MOSAIC_AGENT_CLASS.*review.*canonical roster.*code/i,
);
},
);
it('composes solo role mandate and boundaries before explicit no-peer authority', () => {
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roles', 'orchestrator.md'),
'# Orchestrator\n\n## Mandate\n\nCoordinate exact work.\n\n## Boundaries\n\nDo not infer authority.\n',
);
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: solo',
' runtime: claude',
' class: orchestrator',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'solo';
process.env['MOSAIC_AGENT_CLASS'] = 'orchestrator';
const out = composeContract('claude', fixture.home);
expect(out).toContain('## Mandate');
expect(out).toContain('## Boundaries');
expect(out).toContain('Role/class: `orchestrator`');
expect(out).toContain('## Solo authority boundaries');
expect(out.indexOf('## Mandate')).toBeLessThan(out.indexOf('# Fleet Comms'));
expect(out.indexOf('## Boundaries')).toBeLessThan(out.indexOf('# Fleet Comms'));
expect(out).toContain('no peer, orchestrator, or remote communication authority');
});
it('proves real source TOOLS.md through fresh install, executable helper, and final composition', async () => {
const installRoot = mkdtempSync(join(tmpdir(), 'mosaic-real-contract-'));
const installedHome = join(installRoot, 'mosaic-home');
mkdirSync(installedHome, { recursive: true });
const previous = process.env['MOSAIC_AGENT_NAME'];
try {
const adapter = new FileConfigAdapter(installedHome, FRAMEWORK_SOURCE);
await adapter.syncFramework('fresh');
const sourceTools = readFileSync(SOURCE_TOOLS_PATH, 'utf8');
const installedToolsPath = join(installedHome, 'TOOLS.md');
expect(readFileSync(installedToolsPath, 'utf8')).toBe(sourceTools);
expect(sourceTools).toContain('fleet-comms-contract: 1');
expect(sourceTools).not.toMatch(
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/,
);
const helper = join(installedHome, 'tools', 'tmux', 'agent-send.sh');
expect(() => accessSync(helper, constants.X_OK)).not.toThrow();
mkdirSync(join(installedHome, 'fleet'), { recursive: true });
writeFileSync(
join(installedHome, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'tmux:',
' socket_name: exact-socket',
'agents:',
' - name: exact-self',
' runtime: pi',
' class: orchestrator',
' host: local-host',
' - name: exact-peer',
' runtime: claude',
' class: implementer',
' host: local-host',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-self';
const composed = composeContract('pi', installedHome);
expect(composed).toContain(sourceTools);
expect(composed).toContain(`Helper: \`${helper}\``);
expect(composed).toContain(`${helper} -L exact-socket -s exact-peer -m "…"`);
expect(composed).not.toContain('# Fleet Comms Installation Status');
} finally {
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = previous;
rmSync(installRoot, { recursive: true, force: true });
}
});
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'never injects installed TOOLS.md through a target symlink for %s',
(runtime) => {
const toolsPath = join(fixture.home, 'TOOLS.md');
const external = join(fixture.root, 'attacker-tools.md');
writeFileSync(external, 'UNSAFE-TARGET-SYMLINK-CONTENT\n');
rmSync(toolsPath);
symlinkSync(external, toolsPath);
const out = composeContract(runtime, fixture.home);
expect(out).not.toContain('UNSAFE-TARGET-SYMLINK-CONTENT');
expect(out).toContain('# Fleet Comms Installation Status');
expect(out).toContain('unavailable');
expect(readFileSync(external, 'utf8')).toBe('UNSAFE-TARGET-SYMLINK-CONTENT\n');
},
);
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'never injects installed TOOLS.md through an ancestor symlink for %s',
(runtime) => {
const realHome = join(fixture.root, 'real-mosaic-home');
renameSync(fixture.home, realHome);
symlinkSync(realHome, fixture.home, 'dir');
writeFileSync(join(realHome, 'TOOLS.md'), 'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n');
const out = composeContract(runtime, fixture.home);
expect(out).not.toContain('UNSAFE-ANCESTOR-SYMLINK-CONTENT');
expect(out).toContain('# Fleet Comms Installation Status');
expect(out).toContain('unavailable');
expect(readFileSync(join(realHome, 'TOOLS.md'), 'utf8')).toBe(
'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n',
);
},
);
it('reports stale preserved TOOLS.md without rewriting it', () => {
const toolsPath = join(fixture.home, 'TOOLS.md');
const stale = '# user-customized tools without fleet contract marker\n';
writeFileSync(toolsPath, stale);
const out = composeContract('pi', fixture.home);
expect(out).toContain('# Fleet Comms Installation Status');
expect(out).toContain('does not byte-match');
expect(out).toContain('active context was not rewritten');
expect(readFileSync(toolsPath, 'utf8')).toBe(stale);
});
it('does NOT inject fleet comms when MOSAIC_AGENT_NAME is unset (non-fleet launch)', () => {
const prev = process.env['MOSAIC_AGENT_NAME'];
try {
@@ -392,24 +105,6 @@ describe('composeContract — overlay composer', () => {
}
});
it('fails closed when an explicitly requested fleet identity is unknown', () => {
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ' - name: exact-agent', ' runtime: pi'].join(
'\n',
),
);
const previous = process.env['MOSAIC_AGENT_NAME'];
try {
process.env['MOSAIC_AGENT_NAME'] = 'invented-agent';
expect(() => composeContract('pi', fixture.home)).toThrow(/known exact names: exact-agent/i);
} finally {
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = previous;
}
});
it('includes the per-tier anchors and the selected harness runtime', () => {
const out = composeContract('claude', fixture.home);
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
@@ -545,14 +240,10 @@ describe('composeContract — overlay composer', () => {
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: orchestrator',
' runtime: claude',
' class: orchestrator',
' - name: enhancer',
' runtime: claude',
' class: enhancer',
'',
].join('\n'),

View File

@@ -1,280 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetMigrationCommand } from './fleet-migration-command.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
describe('fleet migrate-v1 preview command', (): void => {
it('emits one ready JSON result without any runtime or file mutation API', async () => {
cleanup = await mkdtemp(join(tmpdir(), 'fleet-migration-command-'));
const rolesDir = join(cleanup, 'roles');
const overrideDir = join(cleanup, 'roles.local');
await mkdir(rolesDir);
await mkdir(overrideDir);
await writeFile(join(rolesDir, 'code.md'), '# code\n');
const files: Record<string, string> = {
source: `version: 1\ntransport: tmux\ntmux:\n socket_name: test\ndefaults:\n working_directory: /srv\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n runtime: pi\n class: implementer\n`,
decisions: JSON.stringify({
generation: 2,
defaultRuntime: 'pi',
agents: {
coder0: {
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
enabled: true,
launchYolo: false,
toolPolicyDisposition: { action: 'replace', className: 'code' },
},
},
}),
observations: JSON.stringify({ coder0: { systemd: 'inactive', tmux: 'missing' } }),
};
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command();
const fleet = program.command('fleet').option('--mosaic-home <path>', '', cleanup);
registerFleetMigrationCommand(fleet, {
mosaicHome: cleanup,
rolesDir,
overrideDir,
readText: async (path): Promise<string> => files[path] ?? '',
printJson,
setExitCode,
});
await program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
'--observations',
'observations',
]);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'ready' }));
expect(setExitCode).not.toHaveBeenCalled();
expect(fleet.helpInformation()).toContain('migrate-v1');
const migration = fleet.commands.find((command) => command.name() === 'migrate-v1');
expect(migration?.helpInformation()).not.toMatch(/--write|apply|canary|rollback/);
});
it('emits one stable blocked JSON result when --observations is missing', async () => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command().exitOverride();
program.configureOutput({
writeErr: vi.fn(),
writeOut: vi.fn(),
});
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText: vi.fn(),
printJson,
setExitCode,
});
await expect(
program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
]),
).resolves.toBe(program);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option',
path: 'request.observations',
detail: 'Required migration preview option is missing.',
},
],
});
expect(setExitCode).toHaveBeenCalledWith(1);
});
it.each(['source', 'decisions', 'observations'] as const)(
'emits one stable blocked JSON result when bare --%s has no value',
async (bareOption) => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const readText = vi.fn();
const writeErr = vi.fn();
const program = new Command().exitOverride();
program.configureOutput({ writeErr, writeOut: vi.fn() });
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText,
printJson,
setExitCode,
});
const args = [
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source=source',
'--decisions=decisions',
'--observations=observations',
];
args[args.findIndex((argument) => argument.startsWith(`--${bareOption}=`))] =
`--${bareOption}`;
await expect(program.parseAsync(args)).resolves.toBe(program);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${bareOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
expect(setExitCode).toHaveBeenCalledTimes(1);
expect(setExitCode).toHaveBeenCalledWith(1);
expect(readText).not.toHaveBeenCalled();
expect(writeErr).not.toHaveBeenCalled();
},
);
it.each(['source', 'decisions', 'observations'] as const)(
'emits one stable blocked JSON result when --%s is present-empty',
async (emptyOption) => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const readText = vi.fn();
const program = new Command().exitOverride();
program.configureOutput({ writeErr: vi.fn(), writeOut: vi.fn() });
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText,
printJson,
setExitCode,
});
const paths = { source: 'source', decisions: 'decisions', observations: 'observations' };
paths[emptyOption] = '';
await expect(
program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
`--source=${paths.source}`,
`--decisions=${paths.decisions}`,
`--observations=${paths.observations}`,
]),
).resolves.toBe(program);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith({
status: 'blocked',
blockers: [
{
code: 'empty-migration-preview-option',
path: `request.${emptyOption}`,
detail: 'Required migration preview option must be a non-empty path.',
},
],
});
expect(setExitCode).toHaveBeenCalledTimes(1);
expect(setExitCode).toHaveBeenCalledWith(1);
expect(readText).not.toHaveBeenCalled();
},
);
it('emits a blocked result and sets exit 1 for malformed evidence', async () => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command();
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText: async (path): Promise<string> => (path === 'decisions' ? 'not-json' : '{}'),
printJson,
setExitCode,
});
await program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
'--observations',
'observations',
]);
expect(printJson).toHaveBeenCalledWith(
expect.objectContaining({
status: 'blocked',
blockers: [expect.objectContaining({ code: 'migration-preview-failed' })],
}),
);
expect(setExitCode).toHaveBeenCalledWith(1);
});
it('redacts adversarial values from validation failures', async () => {
const secret = 'never-print-command-or-token';
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command();
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText: async (path): Promise<string> =>
path === 'decisions'
? JSON.stringify({ generation: 2, agents: {}, [secret]: secret })
: '{}',
printJson,
setExitCode,
});
await program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
'--observations',
'observations',
]);
expect(JSON.stringify(printJson.mock.calls)).not.toContain(secret);
expect(setExitCode).toHaveBeenCalledWith(1);
});
});

View File

@@ -1,170 +0,0 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Command } from 'commander';
import {
parseV1MigrationObservations,
parseV1ToV2MigrationDecisions,
previewV1ToV2Migration,
} from '../fleet/v1-v2-migration.js';
export interface FleetMigrationCommandDeps {
readonly mosaicHome?: string;
readonly rolesDir?: string;
readonly overrideDir?: string;
readonly readText?: (path: string) => Promise<string>;
readonly printJson?: (value: unknown) => void;
readonly setExitCode?: (code: number) => void;
}
interface PreviewOptions {
readonly source?: string | boolean;
readonly decisions?: string | boolean;
readonly observations?: string | boolean;
}
/** Registers preview-only v1 migration. This command has no mutation verbs or runners. */
export function registerFleetMigrationCommand(
fleetCommand: Command,
deps: FleetMigrationCommandDeps = {},
): void {
fleetCommand
.command('migrate-v1')
.description('Preview a field-complete v1-to-v2 roster migration')
.command('preview')
.description('Compile migration evidence without writing files or changing runtimes')
.option('--source [path]', 'v1 roster YAML or JSON')
.option('--decisions [path]', 'explicit migration decisions JSON')
.option('--observations [path]', 'reviewed lifecycle observations JSON')
.action(async (options: PreviewOptions): Promise<void> => {
const readText = deps.readText ?? ((path: string): Promise<string> => readFile(path, 'utf8'));
const printJson =
deps.printJson ?? ((value: unknown): void => console.log(JSON.stringify(value)));
const setExitCode =
deps.setExitCode ?? ((code: number): void => void (process.exitCode = code));
try {
const requiredOptionNames = ['source', 'decisions', 'observations'] as const;
const missingOption = requiredOptionNames.find((name) => options[name] === undefined);
if (missingOption !== undefined) {
printJson({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option',
path: `request.${missingOption}`,
detail: 'Required migration preview option is missing.',
},
],
});
setExitCode(1);
return;
}
const missingValueOption = requiredOptionNames.find((name) => options[name] === true);
if (missingValueOption !== undefined) {
printJson({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${missingValueOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
setExitCode(1);
return;
}
const emptyOption = requiredOptionNames.find(
(name) => typeof options[name] === 'string' && options[name].trim() === '',
);
if (emptyOption !== undefined) {
printJson({
status: 'blocked',
blockers: [
{
code: 'empty-migration-preview-option',
path: `request.${emptyOption}`,
detail: 'Required migration preview option must be a non-empty path.',
},
],
});
setExitCode(1);
return;
}
const sourcePath = options.source;
const decisionsPath = options.decisions;
const observationsPath = options.observations;
if (
typeof sourcePath !== 'string' ||
typeof decisionsPath !== 'string' ||
typeof observationsPath !== 'string'
) {
throw new Error('Validated migration preview options became unavailable.');
}
const [source, decisionsSource, observationsSource] = await Promise.all([
readText(sourcePath),
readText(decisionsPath),
readText(observationsPath),
]);
const decisions = parseV1ToV2MigrationDecisions(
parseJsonObject(decisionsSource, 'migration decisions'),
);
const observations = parseV1MigrationObservations(
parseJsonObject(observationsSource, 'lifecycle observations'),
);
const mosaicHome =
deps.mosaicHome ?? fleetCommand.opts<{ mosaicHome: string }>().mosaicHome;
const preview = await previewV1ToV2Migration({
source,
sourcePath,
decisions,
observations,
personaDirs: {
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
},
environment: {
mosaicHome,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
},
});
printJson(preview);
if (preview.status === 'blocked') setExitCode(1);
} catch (error: unknown) {
printJson({
status: 'blocked',
blockers: [
{
code: 'migration-preview-failed',
path: 'request',
detail: safeErrorDetail(error),
},
],
});
setExitCode(1);
}
});
}
function parseJsonObject(source: string, label: string): unknown {
let value: unknown;
try {
value = JSON.parse(source) as unknown;
} catch {
throw new Error(`${label} must be valid JSON.`);
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${label} must be a JSON object.`);
}
return value;
}
function safeErrorDetail(error: unknown): string {
if (error instanceof Error && isPublishableValidationMessage(error.message)) return error.message;
return 'Migration preview failed without publishable detail.';
}
function isPublishableValidationMessage(message: string): boolean {
return /^(migration decisions|lifecycle observations) must be (valid JSON|a JSON object)\.$/.test(
message,
);
}

View File

@@ -1,896 +0,0 @@
import { chmod, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
import { executeFleetRegen, formatFleetRegenReport } from './fleet-regen-command.js';
import {
acquirePrivateRosterMutationLock,
projectRosterV2AgentGeneratedEnv,
} from '../fleet/fleet-reconciler.js';
import { applyPreparedGeneratedAgentEnvironmentProjection } from '../fleet/generated-env-boundary.js';
import { parseRosterV2 } from '../fleet/roster-v2.js';
// A two-agent roster-v2 SSOT. `mosaic fleet regen` must rebuild each agent's
// `fleet/agents/<name>.env.generated` projection from exactly this source and
// nothing else — deterministically, without ever touching agent lifecycle.
const rosterYaml = `
version: 2
generation: 7
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: /srv/mosaic
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: /srv/mosaic
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
- name: coder1
alias: Coder 1
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: medium
tool_policy: code
working_directory: /srv/other
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`;
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function fleetHome(withRoster = rosterYaml): Promise<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-command-'));
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 });
await chmod(join(cleanup, directory), 0o700);
}
await chmod(cleanup, 0o700);
await writeFile(join(cleanup, 'fleet', 'roster.yaml'), withRoster, { mode: 0o600 });
// Semantic roster validation (matching `fleet reconcile`) resolves each agent
// class to a persona; seed the classes the fixtures reference.
for (const klass of ['code', 'merge-gate']) {
await writeFile(
join(cleanup, 'fleet', 'roles', `${klass}.md`),
`\`class: ${klass}\`\n\n# ${klass} persona\n`,
{ mode: 0o600 },
);
}
return cleanup;
}
/**
* A runner spy that records EVERY invocation. `regen` is projection-only and
* must never issue a lifecycle/restart call, so a non-empty call log is a
* hard failure — this is the load-bearing "never restarts" gate.
*/
function recordingRunner(
calls: string[][],
): (command: string, args: string[]) => Promise<CommandResult> {
return async (command: string, args: string[]): Promise<CommandResult> => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
}
function program(mosaicHome: string, runner: FleetCommandDeps['runner']): Command {
const result = new Command();
result.exitOverride();
registerFleetCommand(result, { mosaicHome, runner });
return result;
}
function capture(): string[] {
const lines: string[] = [];
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
lines.push(value);
});
return lines;
}
async function exists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
describe('projectRosterV2AgentGeneratedEnv', (): void => {
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => {
const roster = parseRosterV2(rosterYaml, 'yaml');
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
expect(agent).toBeDefined();
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
expect(values).toEqual({
MOSAIC_AGENT_NAME: 'coder0',
MOSAIC_AGENT_CLASS: 'code',
MOSAIC_AGENT_RUNTIME: 'pi',
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
MOSAIC_AGENT_REASONING: 'high',
MOSAIC_AGENT_TOOL_POLICY: 'code',
MOSAIC_AGENT_WORKDIR: '/srv/mosaic',
MOSAIC_TMUX_SOCKET: 'mosaic-fleet',
});
});
});
describe('mosaic fleet regen', (): void => {
it('reports a missing canonical roster with the shared initialization hint', async (): Promise<void> => {
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-missing-roster-'));
cleanup = home;
await expect(
program(home, recordingRunner([])).parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']),
).rejects.toThrow(
`No fleet roster found at ${join(home, 'fleet', 'roster.yaml')}. Run \`mosaic fleet init\``,
);
});
it('is dry-run by default: reports the plan and writes nothing', async (): Promise<void> => {
const home = await fleetHome();
const calls: string[][] = [];
const lines = capture();
await program(home, recordingRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
'regen',
'--json',
]);
const result = JSON.parse(lines.pop() ?? '{}');
expect(result).toMatchObject({
mode: 'dry-run',
generation: 7,
agentCount: 2,
written: 0,
});
expect(result.agents.map((agent: { name: string }) => agent.name)).toEqual([
'coder0',
'coder1',
]);
expect(
result.agents.every((agent: { disposition: string }) => agent.disposition === 'create'),
).toBe(true);
// Nothing on disk.
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
// No lifecycle/restart call — ever.
expect(calls).toEqual([]);
});
it('forwards configured persona roots (rolesDir/overrideDir) from reconcileDeps into regen', async (): Promise<void> => {
// Codex r6: regen must resolve personas the SAME way reconcile does. If the
// top-level registration drops reconcileDeps.rolesDir/overrideDir, a deployment
// with custom persona roots gets reconcile ACCEPTING a roster while regen
// REJECTS it (validating against the wrong default `<home>/fleet/roles`),
// blocking the recovery command. Pin the wiring: seed personas ONLY under the
// custom roots, leave the default roles dir empty, require regen to succeed.
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-wiring-'));
cleanup = home;
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
await mkdir(join(home, directory), { recursive: true, mode: 0o700 });
await chmod(join(home, directory), 0o700);
}
await chmod(home, 0o700);
await writeFile(join(home, 'fleet', 'roster.yaml'), rosterYaml, { mode: 0o600 });
// Personas live ONLY under the configured roots — the default `fleet/roles`
// stays empty, so broken wiring fails persona resolution.
const customRoles = join(home, 'custom-roles');
const customOverride = join(home, 'custom-roles.local');
await mkdir(customRoles, { recursive: true, mode: 0o700 });
await mkdir(customOverride, { recursive: true, mode: 0o700 });
for (const klass of ['code', 'merge-gate']) {
await writeFile(join(customRoles, `${klass}.md`), `\`class: ${klass}\`\n\n# ${klass}\n`, {
mode: 0o600,
});
}
const command = new Command();
command.exitOverride();
registerFleetCommand(command, {
mosaicHome: home,
runner: recordingRunner([]),
reconcileDeps: { rolesDir: customRoles, overrideDir: customOverride },
});
const lines = capture();
await command.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
// Regen validated against the CONFIGURED roots → success. Broken wiring
// resolves against the empty default and fails (exitCode 1, no JSON result).
expect(process.exitCode).not.toBe(1);
const result = JSON.parse(lines.pop() ?? '{}');
expect(result).toMatchObject({ mode: 'dry-run', agentCount: 2 });
});
it('--write rebuilds each generated projection from the roster SSOT', async (): Promise<void> => {
const home = await fleetHome();
const calls: string[][] = [];
const lines = capture();
await program(home, recordingRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
'regen',
'--write',
'--json',
]);
const result = JSON.parse(lines.pop() ?? '{}');
expect(result).toMatchObject({ mode: 'write', written: 2, agentCount: 2 });
const coder0 = await readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8');
expect(coder0).toContain('MOSAIC_AGENT_NAME=coder0');
expect(coder0).toContain('MOSAIC_AGENT_WORKDIR=/srv/mosaic');
expect(coder0).toContain('MOSAIC_TMUX_SOCKET=mosaic-fleet');
const coder1 = await readFile(join(home, 'fleet', 'agents', 'coder1.env.generated'), 'utf8');
expect(coder1).toContain('MOSAIC_AGENT_NAME=coder1');
expect(coder1).toContain('MOSAIC_AGENT_WORKDIR=/srv/other');
// No lifecycle/restart call — ever.
expect(calls).toEqual([]);
});
it('is deterministic and idempotent across repeated --write runs', async (): Promise<void> => {
const home = await fleetHome();
const calls: string[][] = [];
const path = join(home, 'fleet', 'agents', 'coder0.env.generated');
const cli = program(home, recordingRunner(calls));
capture();
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
const first = await readFile(path, 'utf8');
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
const second = await readFile(path, 'utf8');
expect(second).toBe(first);
expect(calls).toEqual([]);
});
it('reports rebuild disposition once the generated projection already exists', async (): Promise<void> => {
const home = await fleetHome();
const calls: string[][] = [];
const cli = program(home, recordingRunner(calls));
const lines = capture();
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
lines.length = 0;
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
const result = JSON.parse(lines.pop() ?? '{}');
expect(
result.agents.every((agent: { disposition: string }) => agent.disposition === 'rebuild'),
).toBe(true);
expect(result.written).toBe(0);
expect(calls).toEqual([]);
});
it('never issues a lifecycle/restart call in either mode', async (): Promise<void> => {
const home = await fleetHome();
const calls: string[][] = [];
const cli = program(home, recordingRunner(calls));
capture();
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
// Structural guarantee: regen has no path to systemctl/tmux at all.
expect(calls).toEqual([]);
const systemctlCalls = calls.filter(([command]) => command === 'systemctl');
expect(systemctlCalls).toEqual([]);
});
it('human-readable --write output prints the do-not-restart recovery runbook', async (): Promise<void> => {
const home = await fleetHome();
const lines = capture();
await program(home, recordingRunner([])).parseAsync([
'node',
'mosaic',
'fleet',
'regen',
'--write',
]);
const output = lines.join('\n');
expect(output).toMatch(/do not restart|before.*restart/i);
expect(output).toMatch(/env\.generated/);
// The unit has no EnvironmentFile= directive, so the runbook must NOT tell the
// operator to verify one — verify the launcher/generated file instead.
expect(output).not.toContain('EnvironmentFile');
expect(output).toMatch(/systemctl --user cat mosaic-agent@<name>/);
expect(output).toMatch(/restart/i);
});
it('emits paths and counts only — never the projected env body (secrev)', async (): Promise<void> => {
const home = await fleetHome();
const lines = capture();
await program(home, recordingRunner([])).parseAsync([
'node',
'mosaic',
'fleet',
'regen',
'--write',
]);
const output = lines.join('\n');
// Relative paths + counts are fine; the rendered projection body (KEY=value
// lines) must never be echoed to stdout.
expect(output).toContain('coder0.env.generated');
expect(output).not.toMatch(/^MOSAIC_AGENT_\w+=/m);
expect(output).not.toContain('MOSAIC_TMUX_SOCKET=mosaic-fleet');
});
it('is projection-only: leaves legacy .env untouched and writes no .env.local/.env.quarantine', async (): Promise<void> => {
// Recovery contract: regen rebuilds ONLY <name>.env.generated. It must never
// relocate, quarantine, or unlink the operator-owned legacy .env surface — the
// full reconciler apply path does, so regen must NOT use it.
const home = await fleetHome();
const legacyPath = join(home, 'fleet', 'agents', 'coder0.env');
await writeFile(legacyPath, 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', { mode: 0o600 });
capture();
await program(home, recordingRunner([])).parseAsync([
'node',
'mosaic',
'fleet',
'regen',
'--write',
'--json',
]);
// Generated projection rebuilt...
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
// ...but the operator's legacy .env is preserved verbatim, and no local/quarantine
// files were fabricated from it.
expect(await readFile(legacyPath, 'utf8')).toBe('MOSAIC_RUNTIME_BIN=/usr/bin/pi\n');
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.local'))).toBe(false);
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.quarantine'))).toBe(false);
});
it('prepares every agent before writing any: a later prepare failure leaves nothing written', async (): Promise<void> => {
// Fail-closed across agents. Pre-seed coder1's projection with world-readable
// perms so its prepare rejects; coder0 (valid) must NOT be written because
// preparation is fully completed before the first apply.
const home = await fleetHome();
const coder1Path = join(home, 'fleet', 'agents', 'coder1.env.generated');
await writeFile(coder1Path, 'MOSAIC_AGENT_NAME=coder1\n', { mode: 0o644 });
const calls: string[][] = [];
capture();
const errors: string[] = [];
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
errors.push(String(chunk));
return true;
});
await program(home, recordingRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
'regen',
'--write',
'--json',
]);
expect(process.exitCode).toBe(1);
expect(errors.join('')).toMatch(/regen failed/i);
// coder0 is valid but must remain unwritten — no partial rebuild.
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
expect(calls).toEqual([]);
});
it('fails closed on a semantically invalid roster (protected-class tool-policy mismatch)', async (): Promise<void> => {
// A hand-edited roster giving a protected class a weaker tool_policy is rejected
// by reconcile/plan/verify; regen must enforce the SAME gate, not silently
// project the downgraded policy into <name>.env.generated.
const home = await fleetHome(`
version: 2
generation: 3
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: /srv/mosaic
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: gate0
alias: Gate 0
class: merge-gate
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: /srv/mosaic
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`);
const calls: string[][] = [];
capture();
const errors: string[] = [];
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
errors.push(String(chunk));
return true;
});
await program(home, recordingRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
'regen',
'--write',
'--json',
]);
expect(process.exitCode).toBe(1);
expect(errors.join('')).toMatch(/regen failed/i);
expect(await exists(join(home, 'fleet', 'agents', 'gate0.env.generated'))).toBe(false);
expect(calls).toEqual([]);
});
it('refuses to write while a concurrent reconcile holds the mutation lock', async (): Promise<void> => {
// regen --write mutates the same projections as reconcile; it must take the
// shared reconcile lock so a concurrent reconcile cannot race a stale write.
const home = await fleetHome();
await writeFile(join(home, 'fleet', 'roster.yaml.reconcile.lock'), 'held\n', { mode: 0o600 });
const calls: string[][] = [];
capture();
const errors: string[] = [];
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
errors.push(String(chunk));
return true;
});
await program(home, recordingRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
'regen',
'--write',
'--json',
]);
expect(process.exitCode).toBe(1);
expect(errors.join('')).toMatch(/regen failed/i);
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
expect(calls).toEqual([]);
});
it('fails closed (non-zero, no writes) on an invalid roster', async (): Promise<void> => {
// roster-v2 requires >= 1 agent; a malformed roster must abort regen without
// writing any projection and without touching lifecycle.
const home = await fleetHome('version: 2\ngeneration: 1\n');
const calls: string[][] = [];
capture();
const errors: string[] = [];
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
errors.push(String(chunk));
return true;
});
await program(home, recordingRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
'regen',
'--write',
'--json',
]);
expect(process.exitCode).toBe(1);
expect(errors.join('')).toMatch(/regen failed/i);
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
expect(calls).toEqual([]);
});
it('rejects a non-canonical --roster path (canonical roster only)', async (): Promise<void> => {
// `fleet` exposes a global `--roster`; reconcile rejects a non-canonical value
// rather than silently reading the canonical roster. regen must enforce the
// SAME guard so `--roster /elsewhere regen --write` cannot mislead the operator.
const home = await fleetHome();
const calls: string[][] = [];
capture();
const errors: string[] = [];
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
errors.push(String(chunk));
return true;
});
await program(home, recordingRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
join(home, 'other', 'roster.yaml'),
'regen',
'--write',
'--json',
]);
expect(process.exitCode).toBe(1);
expect(errors.join('')).toMatch(/regen failed/i);
expect(errors.join('')).toMatch(/canonical roster/i);
// No projection written against a misdirected roster path.
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
expect(calls).toEqual([]);
});
it('reports an incomplete rebuild when a later projection write fails mid-loop', async (): Promise<void> => {
// Fault injected on the SECOND apply: coder0 is already replaced on disk, so a
// bare throw would hide the partial state. regen must surface which agents were
// rebuilt and which failed, with a verify-before-restart recovery instruction.
const home = await fleetHome();
const realApply = applyPreparedGeneratedAgentEnvironmentProjection;
const result = await executeFleetRegen(
{
runner: recordingRunner([]),
mosaicHome: home,
applyProjection: async (prepared): Promise<string> => {
if (prepared.generatedPath.endsWith('coder1.env.generated')) {
throw new Error('simulated ENOSPC on coder1');
}
return realApply(prepared);
},
},
{ write: true },
);
// coder0 written before the fault; coder1 not.
expect(result.written).toBe(1);
expect(result.incomplete).toMatchObject({
code: 'projection-apply-failed',
failedAgent: 'coder1',
writtenAgents: ['coder0'],
});
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
// The human report names the failed agent and directs a verify-before-restart
// recovery — not a bare success runbook.
const report = formatFleetRegenReport(result).join('\n');
expect(report).toMatch(/incomplete/i);
expect(report).toContain('coder1');
expect(report).toMatch(/verify|re-run|retry/i);
// Structural: still no lifecycle call anywhere on the failure path.
expect(report).not.toMatch(/systemctl --user restart mosaic-agent@<name>\n.*\n/);
});
it('refuses to write while agent CRUD holds the roster mutation lock', async (): Promise<void> => {
// regen --write overwrites the SAME generated projections that `fleet agent
// create/update/delete` writes under roster.yaml.mutation.lock. If regen only
// took the reconcile lock it could read a stale roster and overwrite a just-
// committed projection. It must also take the mutation lock and fail closed.
const home = await fleetHome();
await writeFile(join(home, 'fleet', 'roster.yaml.mutation.lock'), 'crud\n', { mode: 0o600 });
const calls: string[][] = [];
capture();
const errors: string[] = [];
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
errors.push(String(chunk));
return true;
});
await program(home, recordingRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
'regen',
'--write',
'--json',
]);
expect(process.exitCode).toBe(1);
expect(errors.join('')).toMatch(/regen failed/i);
// No projection written against a roster a concurrent CRUD is mutating.
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
expect(calls).toEqual([]);
});
it('surfaces BOTH the roster failure and a stale-lock warning on a double fault', async (): Promise<void> => {
// Worst case with nothing written: run() fails (invalid roster, fail-closed) AND
// a lock release faults. The operator must be told the lock may be stale, not
// just the roster error — otherwise the next regen/reconcile is silently blocked.
const home = await fleetHome('version: 2\ngeneration: 1\n');
const throwingRelease =
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
throw new Error('simulated lock unlink fault');
};
await expect(
executeFleetRegen(
{
runner: recordingRunner([]),
mosaicHome: home,
acquireReconcileLock: throwingRelease,
acquireRosterMutationLock: throwingRelease,
},
{ write: true },
),
).rejects.toThrow(/stale|inspect|lock/i);
// Nothing written on the fail-closed path.
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
});
it('propagates a roster-mutation-lock release failure instead of silently keeping a stale lock', async (): Promise<void> => {
// Finding L: the real `acquirePrivateRosterMutationLock` release must SURFACE an
// unlink failure, not swallow it. If it swallowed (like the CRUD-internal
// acquireMutationLock does), a stale roster.yaml.mutation.lock left after a
// successful regen would block later regen/agent CRUD while the command reported
// success — and the finding-J stale-lock warning would never fire for this lock.
const home = await fleetHome();
const release = await acquirePrivateRosterMutationLock(home)();
// Simulate the lock vanishing (or being unremovable) before release runs: the
// underlying unlink then faults. A swallowing release would resolve and hide it.
await rm(join(home, 'fleet', 'roster.yaml.mutation.lock'));
await expect(release()).rejects.toThrow();
});
it('preserves a complete rebuild result and flags lock-cleanup when release faults', async (): Promise<void> => {
// A lock-release fault after a successful write must NOT discard the fact that
// projections are now live — the operator needs to know the write happened and
// that the shared lock may be stale, not just a bare "regen failed".
const home = await fleetHome();
const result = await executeFleetRegen(
{
runner: recordingRunner([]),
mosaicHome: home,
acquireReconcileLock:
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
throw new Error('simulated lock unlink fault');
},
},
{ write: true },
);
expect(result.written).toBe(2);
expect(result.incomplete).toBeUndefined();
expect(result.cleanup).toMatchObject({
code: 'lock-cleanup-failed',
action: 'inspect-lock-before-retry',
});
// Projections are genuinely on disk despite the release fault.
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(true);
const report = formatFleetRegenReport(result).join('\n');
expect(report).toMatch(/lock/i);
expect(report).toMatch(/stale|inspect|clear/i);
});
it('preserves a partial rebuild AND flags lock-cleanup when both apply and release fault', async (): Promise<void> => {
// Worst case: a mid-loop apply fault leaves a partial rebuild, then the release
// also faults. Both recovery states must survive so the operator sees exactly
// which projections are live and that the lock may be stale.
const home = await fleetHome();
const realApply = applyPreparedGeneratedAgentEnvironmentProjection;
const result = await executeFleetRegen(
{
runner: recordingRunner([]),
mosaicHome: home,
applyProjection: async (prepared): Promise<string> => {
if (prepared.generatedPath.endsWith('coder1.env.generated')) {
throw new Error('simulated ENOSPC on coder1');
}
return realApply(prepared);
},
acquireReconcileLock:
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
throw new Error('simulated lock unlink fault');
},
},
{ write: true },
);
expect(result.written).toBe(1);
expect(result.incomplete).toMatchObject({
code: 'projection-apply-failed',
failedAgent: 'coder1',
writtenAgents: ['coder0'],
});
expect(result.cleanup).toMatchObject({ code: 'lock-cleanup-failed' });
const report = formatFleetRegenReport(result).join('\n');
expect(report).toMatch(/incomplete/i);
expect(report).toMatch(/lock/i);
});
it('fails closed without deleting a REPLACEMENT roster-mutation lock it no longer owns', async (): Promise<void> => {
// Finding M1 (replacement-lock race): after this invocation created its lock, the
// lock is cleared and re-created by ANOTHER writer (new inode + foreign token)
// BEFORE release runs. The release must PROVE ownership (device/inode + token) and
// refuse to unlink the stranger's live lock — otherwise it deletes it, a third
// writer enters concurrently, and mutual exclusion over the projections is broken.
// This pins the REACHABLE window (replaced-before-release); the residual sub-window
// between the final check and the path `unlink` is unreachable within the `wx`
// writer protocol and matches the merged reconcile lock — see the acquirer doc.
const home = await fleetHome();
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
const release = await acquirePrivateRosterMutationLock(home)();
// A different writer replaces the lock: same path, NEW inode, foreign content.
await rm(lockPath);
await writeFile(lockPath, 'a-different-writer\n', { mode: 0o600 });
const replacement = await stat(lockPath);
// Release must reject (it no longer owns this file) AND the diagnosis must name
// the MUTATION lock specifically — a fault on this shared helper must not be
// mislabeled as the reconcile lock (that would defeat the M3 accurate-diagnosis
// goal, since regen serializes on both) ...
await expect(release()).rejects.toThrow(/roster\.yaml\.mutation\.lock/);
// ... and MUST NOT have unlinked the stranger's live lock.
expect(await exists(lockPath)).toBe(true);
expect((await stat(lockPath)).ino).toBe(replacement.ino);
});
it('does not strand the lock file when initialization fails after creating it', async (): Promise<void> => {
// Codex r4: if writeFile/stat/close/ownership-check throws AFTER the `wx` create
// succeeds, the just-created lock must NOT be left behind — a stranded lock would
// permanently block future regen AND agent CRUD (both contend on this path). The
// cleanup is dev/ino-guarded (never deletes a replacement) and best-effort (never
// masks the initialization error).
const home = await fleetHome();
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
// Inject an opener that performs the REAL `wx` create (so the lock file really
// lands on disk) but whose handle faults on the token write — a post-create
// initialization failure.
const faultingOpen = (async (
path: Parameters<typeof open>[0],
flags: Parameters<typeof open>[1],
mode: Parameters<typeof open>[2],
) => {
const handle = await open(path, flags, mode);
return new Proxy(handle, {
get(target, prop, receiver): unknown {
if (prop === 'writeFile') {
return async (): Promise<never> => {
throw new Error('simulated ENOSPC on token write');
};
}
const value = Reflect.get(target, prop, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
});
}) as typeof open;
await expect(acquirePrivateRosterMutationLock(home, faultingOpen)()).rejects.toThrow(
/mutation\.lock/,
);
// The lock we created must have been cleaned up — not stranded on disk.
expect(await exists(lockPath)).toBe(false);
});
it('does not strand the lock file when the post-create stat itself fails', async (): Promise<void> => {
// Codex r5: the narrower sub-path where `handle.stat()` ITSELF rejects right after
// the `wx` create succeeds (transient EIO/EBADF). device/inode identity is then
// unavailable, so the init-failure cleanup cannot prove ownership by dev/ino — yet
// the lock must STILL not be stranded, because a stranded lock permanently blocks
// future regen AND agent CRUD. The token we persisted before the failure uniquely
// identifies OUR lock, so cleanup falls back to matching it.
const home = await fleetHome();
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
// Real `wx` create (lock really lands on disk); the token write SUCCEEDS so our
// random token is persisted, then `stat()` faults — a post-create init failure.
const faultingStatOpen = (async (
path: Parameters<typeof open>[0],
flags: Parameters<typeof open>[1],
mode: Parameters<typeof open>[2],
) => {
const handle = await open(path, flags, mode);
return new Proxy(handle, {
get(target, prop, receiver): unknown {
if (prop === 'stat') {
return async (): Promise<never> => {
throw new Error('simulated EIO on post-create stat');
};
}
const value = Reflect.get(target, prop, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
});
}) as typeof open;
await expect(acquirePrivateRosterMutationLock(home, faultingStatOpen)()).rejects.toThrow(
/mutation\.lock/,
);
// Even without dev/ino, the token-based fallback must have removed our lock.
expect(await exists(lockPath)).toBe(false);
});
it('surfaces a lock-cleanup fault when unwinding after a later lock acquire fails', async (): Promise<void> => {
// Finding M2: the FIRST lock is taken, the SECOND lock's acquire throws, and
// unwinding the first lock's release ALSO faults. The acquire-unwind path must
// surface both — the already-taken lock may now be stale and silently block the
// next regen/reconcile — not just re-throw the acquire error and drop the fault.
const home = await fleetHome();
await expect(
executeFleetRegen(
{
runner: recordingRunner([]),
mosaicHome: home,
// Mutation lock (acquired first) succeeds, but its RELEASE faults on unwind.
acquireRosterMutationLock:
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
throw new Error('simulated mutation-lock unlink fault');
},
// Reconcile lock (acquired second) ACQUIRE fails, triggering the unwind.
acquireReconcileLock: () => async (): Promise<() => Promise<void>> => {
throw new Error('simulated reconcile acquire failure');
},
},
{ write: true },
),
).rejects.toThrow(/stale|inspect|lock/i);
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
});
it('names BOTH fleet locks in the stale-lock warning (cleanup can come from either)', async (): Promise<void> => {
// Finding M3: finding L made the roster MUTATION lock's release fault reachable, so
// the cleanup marker can now originate from either lock. The human report must not
// claim only the reconcile lock is stale — it must name both candidate lock files
// so the operator inspects the right one.
const home = await fleetHome();
const result = await executeFleetRegen(
{
runner: recordingRunner([]),
mosaicHome: home,
// Fault the MUTATION lock's release specifically (reconcile lock is real).
acquireRosterMutationLock:
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
throw new Error('simulated mutation-lock unlink fault');
},
},
{ write: true },
);
expect(result.written).toBe(2);
expect(result.cleanup).toMatchObject({ code: 'lock-cleanup-failed' });
const report = formatFleetRegenReport(result).join('\n');
expect(report).toContain('roster.yaml.mutation.lock');
expect(report).toContain('roster.yaml.reconcile.lock');
});
});

View File

@@ -1,446 +0,0 @@
import { stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join, relative, resolve } from 'node:path';
import type { Command } from 'commander';
import type { CommandRunner } from './fleet.js';
import {
applyPreparedGeneratedAgentEnvironmentProjection,
prepareGeneratedAgentEnvironmentProjection,
type PreparedGeneratedAgentEnvironmentProjection,
} from '../fleet/generated-env-boundary.js';
import {
acquirePrivateReconcileLock,
acquirePrivateRosterMutationLock,
projectRosterV2AgentGeneratedEnv,
} from '../fleet/fleet-reconciler.js';
import {
parseRosterV2,
validateRosterV2Semantics,
type FleetRosterV2,
} from '../fleet/roster-v2.js';
import { FleetRosterConfigurationError, readFleetRosterText } from '../fleet/fleet-roster-v1.js';
/**
* `mosaic fleet regen` — recovery-framed regeneration of the roster-derived
* agent env projections (`fleet/agents/<name>.env.generated`) from the roster
* SSOT. It is the recovery layer of #791: when a wiped/diverged operator surface
* has cost an agent its generated projection, regen rebuilds it deterministically
* from `roster.yaml` so the launcher can source the intended identity again.
*
* It is intentionally a THIN projection-only wrapper over the same mapping the
* reconciler apply path uses ({@link projectRosterV2AgentGeneratedEnv}), and it
* has NO code path to systemd lifecycle: regen never starts, stops, or restarts
* an agent. Recovery order forbids restart-before-verify, so the operator must
* verify each rebuilt projection resolves before restarting anything.
*/
export interface FleetRegenAgentPlan {
readonly name: string;
/** Generated-projection path, relative to the Mosaic home (never absolute in output). */
readonly path: string;
/** `create` when the projection is currently absent, `rebuild` when it already exists. */
readonly disposition: 'create' | 'rebuild';
}
/**
* Present ONLY when a `--write` projection apply failed partway through the
* fleet. Every prior agent was already validated and prepared, so `writtenAgents`
* are byte-complete on disk; `failedAgent` is where the write faulted. The
* command surfaces this instead of a bare throw so the operator can finish the
* rebuild and verify every projection before restarting any unit.
*/
export interface FleetRegenIncomplete {
readonly code: 'projection-apply-failed';
/** The agent whose generated-projection write faulted. */
readonly failedAgent: string;
/** Agents whose projections were fully written before the fault (in apply order). */
readonly writtenAgents: readonly string[];
}
export interface FleetRegenResult {
readonly mode: 'dry-run' | 'write';
/** Roster path, relative to the Mosaic home. */
readonly rosterPath: string;
readonly generation: number;
readonly agentCount: number;
/** Count of projections written to disk (always 0 in dry-run). */
readonly written: number;
readonly agents: readonly FleetRegenAgentPlan[];
/** Set ONLY when a `--write` apply faulted mid-fleet — a partial rebuild. */
readonly incomplete?: FleetRegenIncomplete;
/**
* Set ONLY when a shared fleet lock could not be released after a write completed
* (or partially completed) — EITHER `roster.yaml.mutation.lock` or
* `roster.yaml.reconcile.lock`, since regen holds both. The projections in this
* result are real; the lock file may be stale and must be inspected before the
* next mutation. Independent of {@link incomplete} — both can be present at once.
*/
readonly cleanup?: {
readonly code: 'lock-cleanup-failed';
readonly action: 'inspect-lock-before-retry';
};
}
export interface FleetRegenDeps {
/**
* Present ONLY so an accidental future lifecycle wiring is caught by the
* "never restarts" test — regen is contractually forbidden to invoke it.
*/
readonly runner: CommandRunner;
readonly mosaicHome?: string;
/** Persona roots for semantic roster validation (defaults mirror the reconciler). */
readonly rolesDir?: string;
readonly overrideDir?: string;
/** Test seam: canonical roster reader. Defaults to parse + semantic validation. */
readonly readRoster?: (rosterPath: string) => Promise<FleetRosterV2>;
/**
* Test seam: generated-only projection validator. Defaults to the audited
* boundary helper that validates ONLY `<name>.env.generated`.
*/
readonly prepareProjection?: typeof prepareGeneratedAgentEnvironmentProjection;
/** Test seam: generated-only projection writer. Defaults to the audited boundary helper. */
readonly applyProjection?: typeof applyPreparedGeneratedAgentEnvironmentProjection;
/**
* Test seam: acquire the shared reconcile lock before a `--write`. Defaults to
* the reconciler's private lock so regen and reconcile are mutually exclusive
* and a concurrent reconcile cannot race a stale projection write.
*/
readonly acquireReconcileLock?: (mosaicHome: string) => () => Promise<() => Promise<void>>;
/**
* Test seam: acquire the shared roster mutation lock before a `--write`.
* Defaults to the reconciler's hardened, ownership-proving acquirer for the
* same `fleet/roster.yaml.mutation.lock` path that CRUD contends on, so regen
* serializes against agent create/update/delete — both rewrite the same
* generated projections, so without this a concurrent CRUD could race a stale
* projection write.
*/
readonly acquireRosterMutationLock?: (mosaicHome: string) => () => Promise<() => Promise<void>>;
/** Test seam: existence probe for create/rebuild disposition. */
readonly fileExists?: (path: string) => Promise<boolean>;
}
interface FleetRegenOptions {
readonly write?: boolean;
}
function defaultMosaicHome(deps: FleetRegenDeps): string {
return deps.mosaicHome ?? process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}
async function defaultFileExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
/**
* Projection-only regeneration core. Deterministic and side-effect-free in
* dry-run; in `--write` it rebuilds each generated projection through the
* audited generated-only boundary writer and NOTHING else — it never writes
* `.env.local`/`.env.quarantine`, never unlinks the legacy `.env`, and never
* touches `deps.runner`.
*
* Fail-closed by construction: the roster is validated (structural + semantic)
* and EVERY agent's projection is prepared before ANY is written, so a single
* invalid agent (or a semantically rejected roster) leaves the whole surface
* untouched. In `--write` mode the read-prepare-apply sequence runs under the
* shared reconcile lock so a concurrent reconcile cannot race a stale write.
*/
export async function executeFleetRegen(
deps: FleetRegenDeps,
options: FleetRegenOptions,
): Promise<FleetRegenResult> {
const mosaicHome = defaultMosaicHome(deps);
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
const readRoster = deps.readRoster ?? defaultReadRoster(deps, mosaicHome);
const prepare = deps.prepareProjection ?? prepareGeneratedAgentEnvironmentProjection;
const apply = deps.applyProjection ?? applyPreparedGeneratedAgentEnvironmentProjection;
const fileExists = deps.fileExists ?? defaultFileExists;
const acquireReconcileLock = deps.acquireReconcileLock ?? acquirePrivateReconcileLock;
const acquireRosterMutationLock =
deps.acquireRosterMutationLock ?? acquirePrivateRosterMutationLock;
const write = options.write === true;
const run = async (): Promise<FleetRegenResult> => {
const roster = await readRoster(rosterPath);
// Prepare (validate) every agent BEFORE any write, so a later failure never
// leaves an earlier projection partially rebuilt.
const prepared: (PreparedGeneratedAgentEnvironmentProjection & {
readonly name: string;
readonly disposition: 'create' | 'rebuild';
})[] = [];
for (const agent of roster.agents) {
const projection = await prepare({
mosaicHome,
agentEnvDir,
agentName: agent.name,
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
});
const disposition = (await fileExists(projection.generatedPath)) ? 'rebuild' : 'create';
prepared.push({ ...projection, name: agent.name, disposition });
}
// Every projection is already validated (prepared); an apply here can only
// fault on the underlying I/O (ENOSPC, EIO, a race outside the lock). If a
// later write faults, earlier agents are already replaced on disk, so we
// stop and report the partial state rather than throwing a bare error that
// hides which projections are now live.
let written = 0;
let incomplete: FleetRegenIncomplete | undefined;
if (write) {
for (const projection of prepared) {
try {
await apply(projection);
} catch {
incomplete = {
code: 'projection-apply-failed',
failedAgent: projection.name,
writtenAgents: prepared.slice(0, written).map((entry) => entry.name),
};
break;
}
written += 1;
}
}
return {
mode: write ? 'write' : 'dry-run',
rosterPath: relative(mosaicHome, rosterPath),
generation: roster.generation,
agentCount: roster.agents.length,
written,
agents: prepared.map((projection) => ({
name: projection.name,
path: relative(mosaicHome, projection.generatedPath),
disposition: projection.disposition,
})),
...(incomplete ? { incomplete } : {}),
};
};
// Dry-run is preview-only and never mutates, so it stays lock-free (usable even
// while a reconcile or CRUD holds a lock). A `--write` must serialize against
// BOTH roster writers of the same generated projections: agent CRUD (roster
// mutation lock) and reconcile (reconcile lock). Acquire in a fixed order — the
// locks are non-blocking (they throw on contention rather than wait), so no
// deadlock is possible — and fail closed if either is already held.
if (!write) return run();
const releases: (() => Promise<void>)[] = [];
try {
releases.push(await acquireRosterMutationLock(mosaicHome)());
releases.push(await acquireReconcileLock(mosaicHome)());
} catch (error: unknown) {
// A later acquire failed; unwind any lock already taken (reverse order). If
// that unwind ALSO faults, surface both — the already-taken lock may now be
// stale and silently block the next regen/reconcile — rather than dropping it.
const releaseFault = await releaseFleetLocks(releases);
throw augmentWithLockCleanupFault(error, releaseFault);
}
let result: FleetRegenResult | undefined;
let primaryError: unknown;
try {
result = await run();
} catch (error: unknown) {
primaryError = error;
}
// Release both locks (reverse acquire order), continuing past a fault so neither
// leaks. A release fault must never discard a completed (or partial) rebuild —
// the projections are already on disk — and must not be silently hidden even
// when the run itself failed with nothing written.
const releaseFault = await releaseFleetLocks(releases);
if (result) {
return releaseFault === undefined
? result
: {
...result,
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
};
}
// result is undefined ⇒ run() threw ⇒ primaryError is set.
throw augmentWithLockCleanupFault(primaryError, releaseFault);
}
/**
* Releases held locks in reverse acquire order, continuing past a fault so a
* single failed release never leaks the others. Returns the first fault seen (or
* undefined when every release succeeded).
*/
async function releaseFleetLocks(releases: readonly (() => Promise<void>)[]): Promise<unknown> {
let firstFault: unknown;
for (const release of [...releases].reverse()) {
try {
await release();
} catch (error: unknown) {
if (firstFault === undefined) firstFault = error;
}
}
return firstFault;
}
/**
* When a run failed with nothing written AND lock cleanup also faulted, surface
* both: the operator needs to know the lock may be stale (else the next
* regen/reconcile is silently blocked), not just the original run error.
*/
function augmentWithLockCleanupFault(primaryError: unknown, releaseFault: unknown): unknown {
if (releaseFault === undefined) return primaryError;
const base = primaryError instanceof Error ? primaryError.message : String(primaryError);
return new Error(
`${base} (additionally, a fleet lock could not be released — ` +
'fleet/roster.yaml.mutation.lock or roster.yaml.reconcile.lock may be stale; ' +
'inspect and clear it before retry)',
);
}
function defaultReadRoster(
deps: FleetRegenDeps,
mosaicHome: string,
): (rosterPath: string) => Promise<FleetRosterV2> {
return async (rosterPath: string): Promise<FleetRosterV2> => {
const roster = parseRosterV2(await readFleetRosterText(rosterPath), 'yaml');
// Enforce the SAME semantic gate as reconcile/plan/verify (persona resolution
// + protected-class tool-policy match) so regen cannot project a roster the
// rest of the fleet surface would reject.
await validateRosterV2Semantics(roster, {
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
});
return roster;
};
}
/**
* Human-readable report — paths and counts ONLY. The rendered projection body
* (KEY=value lines) is never echoed (secrev). In `--write` mode it prints the
* recovery runbook: verify each projection resolves BEFORE any restart.
*/
export function formatFleetRegenReport(result: FleetRegenResult): string[] {
const lines: string[] = [];
const header =
result.mode === 'dry-run'
? 'mosaic fleet regen — dry run (no changes written)'
: 'mosaic fleet regen — wrote roster-derived projections';
lines.push(header);
lines.push(
`roster: ${result.rosterPath} (generation ${result.generation}, ${result.agentCount} agent(s))`,
);
for (const agent of result.agents) {
lines.push(` ${agent.name} ${agent.path} [${agent.disposition}]`);
}
if (result.mode === 'dry-run') {
lines.push(
`Plan: would write ${result.agentCount} generated projection(s). ` +
`Re-run with --write to apply. regen never restarts agents.`,
);
return lines;
}
if (result.incomplete) {
const { failedAgent, writtenAgents } = result.incomplete;
lines.push(
`INCOMPLETE: rebuild stopped at agent ${failedAgent} — its projection write faulted.`,
);
lines.push(
`Rebuilt before the fault: ${writtenAgents.length} projection(s)` +
(writtenAgents.length > 0 ? ` (${writtenAgents.join(', ')})` : '') +
`; ${result.agentCount - writtenAgents.length} not written.`,
);
lines.push(
'Recover: re-run `mosaic fleet regen --write` to finish, then VERIFY every ' +
"agent's fleet/agents/<name>.env.generated resolves BEFORE restarting any " +
'unit. regen never restarts agents.',
);
} else {
lines.push(`Wrote ${result.written} generated projection(s).`);
lines.push('Next steps — DO NOT restart agents before verifying:');
lines.push(
" 1. Confirm each agent's fleet/agents/<name>.env.generated exists and carries " +
'the intended MOSAIC_AGENT_* values.',
);
// The unit sets NO EnvironmentFile= — it launches from a minimal env and the
// launcher (start-agent-session.sh) sources .env.generated itself. Verify the
// launcher path, not a nonexistent EnvironmentFile property.
lines.push(
' 2. Confirm the unit launches from it: `systemctl --user cat mosaic-agent@<name>` ' +
'shows ExecStart running start-agent-session.sh, which reads .env.generated.',
);
lines.push(
' 3. Only then restart one unit at a time: systemctl --user restart mosaic-agent@<name>.',
);
}
if (result.cleanup) {
lines.push(
'WARNING: a fleet lock could not be released — ' +
'fleet/roster.yaml.mutation.lock or fleet/roster.yaml.reconcile.lock may be ' +
'stale; inspect and clear it before the next reconcile or regen.',
);
}
return lines;
}
function resolveRegenMosaicHome(fleetCommand: Command, deps: FleetRegenDeps): string {
const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>();
return options.mosaicHome ?? defaultMosaicHome(deps);
}
/**
* regen reads the roster SSOT at `<mosaicHome>/fleet/roster.yaml` and ignores the
* `fleet --roster <path>` global. If an operator passes a non-canonical `--roster`
* they must NOT be silently served the canonical file — mirror reconcile's guard
* and reject, so `mosaic fleet --roster /elsewhere regen --write` fails closed.
*/
function assertRegenCanonicalRoster(fleetCommand: Command, mosaicHome: string): void {
const options = fleetCommand.optsWithGlobals<{ roster?: string }>();
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) {
throw new Error(
'Roster-v2 regeneration requires the canonical roster path (fleet/roster.yaml).',
);
}
}
/** Registers the recovery-framed `mosaic fleet regen` command. */
export function registerFleetRegenCommand(fleetCommand: Command, deps: FleetRegenDeps): void {
fleetCommand
.command('regen')
.description(
'Regenerate roster-derived agent env projections (dry-run default, --write to apply; never restarts)',
)
.option('--write', 'Write the regenerated projections to disk (default: dry-run)')
.option('--json', 'Emit the plan/result as JSON')
.action(async (opts: { write?: boolean; json?: boolean }): Promise<void> => {
const mosaicHome = resolveRegenMosaicHome(fleetCommand, deps);
try {
assertRegenCanonicalRoster(fleetCommand, mosaicHome);
const result = await executeFleetRegen(
{ ...deps, mosaicHome },
{ write: opts.write === true },
);
if (opts.json === true) {
console.log(JSON.stringify(result));
} else {
for (const line of formatFleetRegenReport(result)) console.log(line);
}
// A partial rebuild or a stale-lock cleanup state is a non-zero outcome:
// the operator must finish/verify (and clear the lock) before restarting.
if (result.incomplete || result.cleanup) process.exitCode = 1;
} catch (error: unknown) {
if (error instanceof FleetRosterConfigurationError) {
fleetCommand.error(error.message, { code: 'fleet.roster', exitCode: 1 });
return;
}
process.exitCode = 1;
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`mosaic fleet regen failed: ${message}\n`);
}
});
}

View File

@@ -60,7 +60,6 @@ import {
type SleepFn,
} from './fleet.js';
import { registerAgentCommand } from './agent.js';
import { parseFleetRosterDocument, readFleetRosterText } from '../fleet/fleet-roster-v1.js';
function buildProgram(): Command {
const program = new Command();
@@ -91,14 +90,12 @@ describe('registerFleetCommand', () => {
'init',
'install',
'install-systemd',
'migrate-v1',
'persona',
'plan',
'profile',
'provision',
'ps',
'reconcile',
'regen',
'remove',
'restart',
'start',
@@ -167,91 +164,6 @@ describe('registerFleetCommand', () => {
});
});
describe('fleet roster configuration failures', () => {
it('reports a missing roster with an actionable initialization hint', async () => {
const home = await tempDir();
try {
const program = buildProgram();
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', '--mosaic-home', home, 'ps']),
).rejects.toThrow(
`No fleet roster found at ${join(home, 'fleet', 'roster.json')}. Run \`mosaic fleet init\``,
);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('reports a malformed roster with the path and repair hint', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.json');
try {
await mkdir(dirname(rosterPath), { recursive: true });
await writeFile(rosterPath, '{not valid json');
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
`Fleet roster at ${rosterPath} is invalid. Fix the file or run \`mosaic fleet init --force\``,
);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('reports an unreadable roster without exposing the filesystem error', async () => {
const home = await tempDir();
try {
await expect(readFleetRosterText(home)).rejects.toThrow(
`Could not read fleet roster at ${home}. Check the file exists and is readable.`,
);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('reports a malformed roster while selecting the v1/v2 command path', () => {
expect(() => parseFleetRosterDocument('version: [', '/srv/mosaic/fleet/roster.yaml')).toThrow(
'Fleet roster at /srv/mosaic/fleet/roster.yaml is invalid. Fix the file or run `mosaic fleet init --force`.',
);
});
it('reports a semantically invalid v1 roster as an actionable nonzero command error', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
await mkdir(dirname(rosterPath), { recursive: true });
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: canary-pi',
' runtime: pi',
' - name: canary-pi',
' runtime: codex',
].join('\n'),
);
await expect(
buildProgram().parseAsync(['node', 'mosaic', 'fleet', '--mosaic-home', home, 'ps']),
).rejects.toMatchObject({
code: 'fleet.roster',
exitCode: 1,
message: 'Fleet roster has duplicate agent name: canary-pi.',
});
expect(stderrSpy.mock.calls.flat().join('')).toContain(
'Fleet roster has duplicate agent name: canary-pi.',
);
expect(stderrSpy.mock.calls.flat().join('')).not.toMatch(/\n\s+at\s/);
} finally {
stderrSpy.mockRestore();
await rm(home, { recursive: true, force: true });
}
});
});
describe('fleet roster parsing', () => {
let cleanup: string | undefined;
@@ -285,26 +197,6 @@ describe('fleet roster parsing', () => {
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
});
it('uses /clear for an explicitly declared empty pi runtime config', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'runtimes:',
' pi: {}',
'agents:',
' - name: canary-pi',
' runtime: pi',
].join('\n'),
);
const loaded = await loadFleetRoster(rosterPath);
expect(loaded.runtimes.pi?.resetCommand).toBe('/clear');
});
it('accepts optional agent alias and provider metadata without requiring them', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
@@ -378,32 +270,6 @@ describe('fleet roster parsing', () => {
expect(env).toContain('MOSAIC_TMUX_SOCKET=\n');
});
it('preserves home-relative traversal until the shared environment boundary rejects it', async () => {
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
defaults: { working_directory: workingDirectory },
agents: [{ name: 'coder0', runtime: 'pi' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(() => generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toThrow(
expect.objectContaining({
diagnostic: expect.objectContaining({
code: 'unsafe-path',
key: 'MOSAIC_AGENT_WORKDIR',
}),
}),
);
}
});
it('generates deterministic per-agent EnvironmentFile content', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
@@ -422,8 +288,8 @@ describe('fleet roster parsing', () => {
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[
'MOSAIC_AGENT_NAME=coder0',
// Reflects the roster's canonicalized compatibility class (A3a).
'MOSAIC_AGENT_CLASS=code',
// Reflects the roster's non-default `class: implementer` (A3a).
'MOSAIC_AGENT_CLASS=implementer',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_MODEL=',
'MOSAIC_AGENT_REASONING=',
@@ -3639,66 +3505,7 @@ describe('fleet add/remove — pure helpers', () => {
}
});
it.each([
['tmux', { kind: 'tmux' }],
['discord', { kind: 'discord', discord: { channelId: '1234567890' } }],
[
'matrix',
{
kind: 'matrix',
matrix: {
homeserverUrl: 'https://matrix.example.test',
userId: '@mosaic:example.test',
roomId: '!fleet:example.test',
},
},
],
] as const)('round-trips the supported %s connector through YAML', async (_kind, connector) => {
const yaml = serializeRosterToYaml({ ...baseRoster, connector });
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
const rosterPath = join(dir, 'roster.yaml');
try {
await writeFile(rosterPath, yaml);
expect((await loadFleetRoster(rosterPath)).connector).toEqual(connector);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it.each([
['tmux', { kind: 'tmux' }],
['discord', { kind: 'discord', discord: { channel_id: '1234567890' } }],
[
'matrix',
{
kind: 'matrix',
matrix: {
homeserver_url: 'https://matrix.example.test',
user_id: '@mosaic:example.test',
room_id: '!fleet:example.test',
},
},
],
] as const)('parses the supported %s connector from JSON', async (_kind, connector) => {
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
const rosterPath = join(dir, 'roster.json');
try {
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'orchestrator', runtime: 'claude', class: 'orchestrator' }],
connector,
}),
);
expect((await loadFleetRoster(rosterPath)).connector?.kind).toBe(_kind);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('serializeRosterToYaml round-trips optional fields and exact comms targets', async () => {
it('serializeRosterToYaml round-trips optional fields (modelHint, workingDirectory)', async () => {
const rosterWithOptionals: FleetRoster = {
...baseRoster,
agents: [
@@ -3710,9 +3517,6 @@ describe('fleet add/remove — pure helpers', () => {
workingDirectory: '/tmp/work',
persistentPersona: true,
resetBetweenTasks: false,
host: '10.1.10.37',
ssh: 'jwoltje@10.1.10.37',
socket: 'mosaic-fleet',
},
],
};
@@ -3720,9 +3524,6 @@ describe('fleet add/remove — pure helpers', () => {
expect(yaml).toContain('model_hint: claude-3-5-sonnet');
expect(yaml).toContain('working_directory: /tmp/work');
expect(yaml).toContain('persistent_persona: true');
expect(yaml).toContain('host: 10.1.10.37');
expect(yaml).toContain('ssh: jwoltje@10.1.10.37');
expect(yaml).toContain('socket: mosaic-fleet');
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(dir, 'roster.yaml');
@@ -3732,9 +3533,6 @@ describe('fleet add/remove — pure helpers', () => {
expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet');
expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work');
expect(loaded.agents[0]!.persistentPersona).toBe(true);
expect(loaded.agents[0]!.host).toBe('10.1.10.37');
expect(loaded.agents[0]!.ssh).toBe('jwoltje@10.1.10.37');
expect(loaded.agents[0]!.socket).toBe('mosaic-fleet');
} finally {
await rm(dir, { recursive: true, force: true });
}

View File

@@ -18,36 +18,15 @@ import { spawn } from 'node:child_process';
import * as readline from 'node:readline';
import type { Command } from 'commander';
import YAML from 'yaml';
import {
FleetRosterConfigurationError,
getRosterAgent,
loadFleetRoster,
parseFleetRosterDocument,
readFleetRosterText,
resolveInstalledFleetRosterPath,
type FleetAgent,
type FleetRoster,
} from '../fleet/fleet-roster-v1.js';
export {
getRosterAgent,
loadFleetRoster,
resolveInstalledFleetRosterPath,
} from '../fleet/fleet-roster-v1.js';
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
import {
registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps,
} from './fleet-agent-crud-command.js';
import {
registerFleetMigrationCommand,
type FleetMigrationCommandDeps,
} from './fleet-migration-command.js';
import {
executeReconcilerCommandJson,
registerFleetReconcilerCommands,
type FleetReconcilerCommandDeps,
} from './fleet-reconciler-command.js';
import { registerFleetRegenCommand } from './fleet-regen-command.js';
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
import {
applyPreparedAgentEnvironmentProjection,
@@ -105,7 +84,72 @@ export interface FleetCommandDeps {
isStdinTTY?: boolean;
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
}
interface RawFleetRoster {
version?: unknown;
transport?: unknown;
tmux?: {
socket_name?: unknown;
socketName?: unknown;
holder_session?: unknown;
holderSession?: unknown;
};
defaults?: {
working_directory?: unknown;
workingDirectory?: unknown;
};
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
agents?: Array<{
name?: unknown;
alias?: unknown;
provider?: unknown;
runtime?: unknown;
class?: unknown;
working_directory?: unknown;
workingDirectory?: unknown;
model_hint?: unknown;
modelHint?: unknown;
reasoning_level?: unknown;
reasoningLevel?: unknown;
tool_policy?: unknown;
toolPolicy?: unknown;
persistent_persona?: unknown;
persistentPersona?: unknown;
reset_between_tasks?: unknown;
resetBetweenTasks?: unknown;
kickstart_template?: unknown;
kickstartTemplate?: unknown;
}>;
}
export interface FleetAgent {
name: string;
alias?: string;
provider?: string;
runtime: string;
className: string;
workingDirectory?: string;
modelHint?: string;
reasoningLevel?: string;
toolPolicy?: string;
persistentPersona?: boolean | string;
resetBetweenTasks?: boolean;
kickstartTemplate?: string;
}
export interface FleetRoster {
version: 1;
transport: 'tmux';
tmux: {
socketName: string;
holderSession: string;
};
defaults: {
workingDirectory: string;
};
runtimes: Record<string, { resetCommand: string }>;
agents: FleetAgent[];
}
export interface FleetPaths {
@@ -126,6 +170,8 @@ type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
* fallback for a socket-less roster (that now resolves to the default socket).
*/
export const DEFAULT_SOCKET_NAME = 'mosaic-fleet';
const DEFAULT_HOLDER_SESSION = '_holder';
const DEFAULT_WORKING_DIRECTORY = '~/src';
/**
* tmux `-L` args for a socket name. An empty/absent socket ⇒ the LITERAL default
@@ -149,6 +195,13 @@ export const VERIFY_POLL_INTERVAL_MS = 400;
* Configurable via `--verify-timeout <ms>` on `agent send`.
*/
export const VERIFY_DEFAULT_TIMEOUT_MS = 6_000;
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
claude: { resetCommand: '/clear' },
codex: { resetCommand: '/clear' },
opencode: { resetCommand: '/clear' },
pi: { resetCommand: '/new' },
};
export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
return {
mosaicHome,
@@ -176,6 +229,20 @@ function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
}
}
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
const rawText = await readFile(path, 'utf8');
const parsed = parseRosterText(rawText, path);
return normalizeRoster(parsed);
}
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
const agent = roster.agents.find((candidate) => candidate.name === name);
if (!agent) {
throw new Error(`Agent "${name}" is not in the fleet roster.`);
}
return agent;
}
// ---------------------------------------------------------------------------
// NORTH_STAR — machine-readable fleet planning source + Markdown projection
//
@@ -489,8 +556,8 @@ function generateAgentEnvValues(
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
MOSAIC_AGENT_WORKDIR: workingDirectory,
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
};
}
@@ -1916,7 +1983,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
const roster = await loadRosterAtPath(cmd, rosterPath);
const roster = await loadFleetRoster(rosterPath);
const newAgent: FleetAgent = {
name,
@@ -1976,7 +2043,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
const roster = await loadRosterAtPath(cmd, rosterPath);
const roster = await loadFleetRoster(rosterPath);
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
const updatedRoster = removeAgentFromRoster(roster, name);
@@ -2057,28 +2124,12 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// Roster-v2 desired-state mutations belong directly to the fleet control
// plane; they do not share the root `mosaic agent` gateway-backed surface.
registerFleetAgentCrudCommands(cmd, deps);
registerFleetMigrationCommand(cmd, {
...deps.migrationDeps,
mosaicHome: deps.mosaicHome,
});
registerFleetReconcilerCommands(cmd, {
runner,
mosaicHome: deps.mosaicHome,
reconcileDeps: deps.reconcileDeps,
});
// Recovery (#791 PR3): rebuild roster-derived env projections from the roster
// SSOT. Projection-only and preview-first — never issues a lifecycle restart.
registerFleetRegenCommand(cmd, {
runner,
mosaicHome: deps.mosaicHome,
// Resolve personas the SAME way reconcile does: forward any configured roots
// so a custom-persona-root deployment cannot have reconcile accept a roster
// that regen then rejects against the default `<mosaicHome>/fleet/roles`.
rolesDir: deps.reconcileDeps?.rolesDir,
overrideDir: deps.reconcileDeps?.overrideDir,
});
return cmd;
}
@@ -2106,11 +2157,14 @@ export function registerFleetAgentCommands(
});
agentCommand
.command('comms-block <exact-member>')
.description('Print the Fleet Comms contract for one exact roster member')
.action((exactMember: string) => {
.command('comms-block <role>')
.description(
"Print the Fleet Comms cheat-sheet for a roster role (preview a peer's peer-reach view)",
)
.option('--host <host>', 'Override the fleet host (preview a cross-host peer view)')
.action((role: string, opts: { host?: string }) => {
const mosaicHome = resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome);
const res = resolveCommsBlock(mosaicHome, exactMember);
const res = resolveCommsBlock(mosaicHome, role, opts.host);
if (!res.ok) {
console.error(`[mosaic] comms-block: ${res.error}`);
process.exitCode = 1;
@@ -2405,19 +2459,14 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
return loadRosterAtPath(cmd, await resolveRosterPath(opts.mosaicHome, opts.roster));
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
}
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
let parsed: unknown;
try {
parsed = parseFleetRosterDocument(await readFleetRosterText(path), path);
} catch (error) {
reportFleetRosterConfigurationError(cmd, error);
}
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
return (
typeof parsed === 'object' &&
parsed !== null &&
@@ -2433,22 +2482,7 @@ async function loadRosterFromAgentCommand(
): Promise<FleetRoster> {
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
return loadRosterAtPath(command, await resolveRosterPath(mosaicHome, opts.roster));
}
async function loadRosterAtPath(command: Command, path: string): Promise<FleetRoster> {
try {
return await loadFleetRoster(path);
} catch (error) {
reportFleetRosterConfigurationError(command, error);
}
}
function reportFleetRosterConfigurationError(command: Command, error: unknown): never {
if (error instanceof FleetRosterConfigurationError) {
command.error(error.message, { code: 'fleet.roster', exitCode: 1 });
}
throw error;
return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster));
}
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
@@ -2456,6 +2490,253 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
return opts.mosaicHome ?? override ?? defaultMosaicHome();
}
function parseRosterText(text: string, path: string): RawFleetRoster {
const trimmed = text.trim();
if (path.endsWith('.json')) {
return JSON.parse(trimmed) as RawFleetRoster;
}
return YAML.parse(trimmed) as RawFleetRoster;
}
function normalizeRoster(raw: RawFleetRoster): FleetRoster {
assertObject(raw, 'Fleet roster');
assertKnownKeys(raw, 'Fleet roster', [
'version',
'transport',
'tmux',
'defaults',
'runtimes',
'agents',
]);
if (raw.tmux !== undefined) {
assertObject(raw.tmux, 'Fleet roster tmux');
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
'socket_name',
'socketName',
'holder_session',
'holderSession',
]);
}
if (raw.defaults !== undefined) {
assertObject(raw.defaults, 'Fleet roster defaults');
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
'working_directory',
'workingDirectory',
]);
}
if (raw.runtimes !== undefined) {
assertObject(raw.runtimes, 'Fleet roster runtimes');
for (const [runtime, config] of Object.entries(raw.runtimes)) {
assertObject(config, `Fleet roster runtime "${runtime}"`);
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
'reset_command',
'resetCommand',
]);
}
}
if (raw.version !== 1) {
throw new Error('Fleet roster version must be 1.');
}
if (raw.transport !== 'tmux') {
throw new Error('Fleet roster transport must be "tmux".');
}
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
throw new Error('Fleet roster must define at least one agent.');
}
const agents = raw.agents.map(normalizeAgent);
assertUniqueAgentNames(agents);
return {
version: 1,
transport: 'tmux',
tmux: {
// Absent socket_name ⇒ '' (the literal default tmux socket, no -L) — NOT
// mosaic-fleet. Shipped presets set socket_name explicitly, so they are
// unaffected; only socket-less rosters get default-socket behavior.
socketName: stringValue(
raw.tmux?.socket_name ?? raw.tmux?.socketName,
'',
'Fleet roster tmux socket_name',
),
holderSession: stringValue(
raw.tmux?.holder_session ?? raw.tmux?.holderSession,
DEFAULT_HOLDER_SESSION,
'Fleet roster tmux holder_session',
),
},
defaults: {
workingDirectory: stringValue(
raw.defaults?.working_directory ?? raw.defaults?.workingDirectory,
DEFAULT_WORKING_DIRECTORY,
'Fleet roster defaults working_directory',
),
},
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
agents,
};
}
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
assertObject(raw, 'Fleet roster agent');
assertKnownKeys(raw, 'Fleet roster agent', [
'name',
'alias',
'provider',
'runtime',
'class',
'working_directory',
'workingDirectory',
'model_hint',
'modelHint',
'reasoning_level',
'reasoningLevel',
'tool_policy',
'toolPolicy',
'persistent_persona',
'persistentPersona',
'reset_between_tasks',
'resetBetweenTasks',
'kickstart_template',
'kickstartTemplate',
]);
const name = stringValue(raw.name, '', 'Fleet roster agent name');
const runtime = stringValue(
raw.runtime,
'',
`Fleet roster agent "${name || '<unknown>'}" runtime`,
);
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
}
if (!runtime) {
throw new Error(`Fleet agent "${name}" must define a runtime.`);
}
return {
name,
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
runtime,
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
workingDirectory: optionalString(
raw.working_directory ?? raw.workingDirectory,
`Fleet roster agent "${name}" working_directory`,
),
modelHint: optionalString(
raw.model_hint ?? raw.modelHint,
`Fleet roster agent "${name}" model_hint`,
),
reasoningLevel: optionalString(
raw.reasoning_level ?? raw.reasoningLevel,
`Fleet roster agent "${name}" reasoning_level`,
),
toolPolicy: optionalString(
raw.tool_policy ?? raw.toolPolicy,
`Fleet roster agent "${name}" tool_policy`,
),
persistentPersona: optionalBooleanOrString(
raw.persistent_persona ?? raw.persistentPersona,
`Fleet roster agent "${name}" persistent_persona`,
),
resetBetweenTasks: optionalBoolean(
raw.reset_between_tasks ?? raw.resetBetweenTasks,
`Fleet roster agent "${name}" reset_between_tasks`,
),
kickstartTemplate: optionalString(
raw.kickstart_template ?? raw.kickstartTemplate,
`Fleet roster agent "${name}" kickstart_template`,
),
};
}
function normalizeRuntimes(
raw: RawFleetRoster['runtimes'] | undefined,
): Record<string, { resetCommand: string }> {
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
for (const [runtime, config] of Object.entries(raw ?? {})) {
result[runtime] = {
resetCommand: stringValue(
config.reset_command ?? config.resetCommand,
'/clear',
`Fleet roster runtime "${runtime}" reset_command`,
),
};
}
return result;
}
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must be an object.`);
}
}
function assertKnownKeys(
value: Record<string, unknown>,
label: string,
allowedKeys: readonly string[],
): void {
const allowed = new Set(allowedKeys);
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
if (unknownKeys.length > 0) {
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
}
}
function assertUniqueAgentNames(agents: FleetAgent[]): void {
const seen = new Set<string>();
for (const agent of agents) {
if (seen.has(agent.name)) {
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
}
seen.add(agent.name);
}
}
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
if (value === undefined) {
return fallback;
}
if (typeof value !== 'string') {
throw new Error(`${label} must be a string.`);
}
return value;
}
function optionalString(value: unknown, label = 'Value'): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string') {
throw new Error(`${label} must be a string.`);
}
return value;
}
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'boolean') {
throw new Error(`${label} must be a boolean.`);
}
return value;
}
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'boolean' && typeof value !== 'string') {
throw new Error(`${label} must be a boolean or string.`);
}
return value;
}
function expandHome(path: string): string {
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
}
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
const failures: string[] = [];
for (const agentName of agentNames) {
@@ -2608,23 +2889,6 @@ export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetR
};
}
function serializeConnector(
connector: NonNullable<FleetRoster['connector']>,
): Record<string, unknown> {
if (connector.kind === 'tmux') return { kind: 'tmux' };
if (connector.kind === 'discord') {
return { kind: 'discord', discord: { channel_id: connector.discord.channelId } };
}
return {
kind: 'matrix',
matrix: {
homeserver_url: connector.matrix.homeserverUrl,
user_id: connector.matrix.userId,
room_id: connector.matrix.roomId,
},
};
}
/**
* Serialize a FleetRoster to YAML text (snake_case keys).
* The output is parseable by loadFleetRoster.
@@ -2642,15 +2906,6 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
if (agent.provider !== undefined) {
raw['provider'] = agent.provider;
}
if (agent.host !== undefined) {
raw['host'] = agent.host;
}
if (agent.ssh !== undefined) {
raw['ssh'] = agent.ssh;
}
if (agent.socket !== undefined) {
raw['socket'] = agent.socket;
}
if (agent.workingDirectory !== undefined) {
raw['working_directory'] = agent.workingDirectory;
}
@@ -2692,7 +2947,6 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
},
runtimes,
agents,
...(roster.connector ? { connector: serializeConnector(roster.connector) } : {}),
};
return YAML.stringify(raw);
@@ -2839,5 +3093,6 @@ export async function resolveRosterPath(
if (await canRead(yamlPath)) {
return yamlPath;
}
return resolveInstalledFleetRosterPath(mosaicHome);
const jsonPath = join(mosaicHome, 'fleet', 'roster.json');
return jsonPath;
}

Some files were not shown because too many files have changed in this diff Show More