Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
205adc54dc | ||
|
|
32799e6025 | ||
|
|
ba74ce4e48 | ||
|
|
bd5afb0711 | ||
|
|
6d92234422 | ||
|
|
9f4b3716db | ||
|
|
1fa2d2ab21 | ||
|
|
806b763d97 | ||
|
|
4c1b2a1fda | ||
|
|
e01cfd44cb | ||
|
|
8f02b55b03 | ||
|
|
aa4b584764 | ||
|
|
2a9360407b | ||
|
|
2922aa152d | ||
|
|
7fc7fa1a25 | ||
|
|
b66d1858e1 | ||
|
|
673fbdb978 | ||
|
|
8fe14d8e1a | ||
|
|
dde38717dd | ||
|
|
d9207d4c1a | ||
|
|
33caf78744 | ||
|
|
1aedf32523 | ||
|
|
0d5aae2c84 | ||
|
|
08f706b675 | ||
|
|
eae62a598a | ||
|
|
267b58deb3 | ||
|
|
e2058d4e84 | ||
|
|
4430e60d84 | ||
|
|
4520d2f672 | ||
|
|
09875143c0 | ||
|
|
08cb73fde3 | ||
|
|
24a7915633 | ||
|
|
e1a3806292 | ||
|
|
72b577eb85 | ||
|
|
52114dd4eb | ||
|
|
8f70b1bc63 | ||
|
|
32384dab02 | ||
|
|
777a8f7f5b | ||
|
|
995f8b6a24 | ||
|
|
d0a510d28d | ||
|
|
8cef39f924 | ||
|
|
f3c100f814 | ||
|
|
2750cc4842 | ||
|
|
553474e4c8 | ||
|
|
0de8ccb52c | ||
|
|
37402e9770 | ||
|
|
01831814b6 | ||
|
|
86cd1a0f46 | ||
|
|
249335e515 | ||
|
|
d9ab6026c8 | ||
|
|
50c0340add | ||
|
|
345d152790 | ||
|
|
f11f257368 | ||
|
|
6c779595e1 | ||
|
|
cc097f36c5 | ||
|
|
56d8e8a3d5 | ||
|
|
4589659bc1 | ||
|
|
357a636a3a | ||
|
|
9e205e8201 | ||
|
|
73a313301b | ||
|
|
eddf718a5c | ||
|
|
c83a3cd3e2 | ||
|
|
bf8c6f4a89 | ||
|
|
bdf8932328 | ||
|
|
16d11cd6cd | ||
|
|
8ce77fcb0d | ||
|
|
cdd5568adb | ||
|
|
ff4b45b025 | ||
|
|
f47cf45b0c | ||
|
|
e7c9160c7b | ||
|
|
2d58779675 | ||
|
|
cbbe3e1ce2 | ||
|
|
9a7ab73952 | ||
|
|
2b85afe4ea | ||
|
|
eabe04bc5e | ||
|
|
36018be8d1 | ||
|
|
8c496993e4 | ||
|
|
e85a088f85 | ||
|
|
8a795df0ce | ||
|
|
f5a0566198 | ||
|
|
1e7a0701fd | ||
|
|
43f69bf167 | ||
|
|
f9435c2a03 | ||
|
|
3b191b6b34 | ||
|
|
85bdbe3383 | ||
|
|
ae4baf145d | ||
|
|
4512312b9f | ||
|
|
e233f196b5 | ||
|
|
3477e933df | ||
|
|
d0ad6e942b | ||
|
|
7f686aaf6d | ||
|
|
f13222b76b | ||
|
|
d1e7ba19ca | ||
|
|
0ffdcf14bd | ||
|
|
be10bdc828 | ||
|
|
2201c30284 | ||
|
|
75dfe2fa75 | ||
|
|
409bf23e6a | ||
|
|
31c3191305 | ||
|
|
6a067174ed |
@@ -8,7 +8,6 @@ coverage
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
__pycache__/
|
||||
docs/reports/
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||
pnpm typecheck && pnpm lint && pnpm format:check
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
||||
# Non-root checkouts use their own HOME. Override without editing this file via
|
||||
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
||||
store-dir=${HOME}/.local/share/pnpm/store
|
||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||
# instead of repopulating a fresh one.
|
||||
store-dir=/root/.local/share/pnpm/store
|
||||
|
||||
@@ -201,21 +201,8 @@ git clone [email protected]:mosaicstack/stack.git
|
||||
cd stack
|
||||
|
||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
||||
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
||||
pnpm install
|
||||
|
||||
# Verify dependencies and generated state before running source-quality gates.
|
||||
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
||||
# The web build certifies its exact standalone symlink manifest; added, removed,
|
||||
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
||||
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
||||
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
||||
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
||||
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
||||
# trust anchor outside worktree authority.
|
||||
pnpm preflight
|
||||
|
||||
# Optional local queue service only. This does not start PostgreSQL.
|
||||
docker compose up -d valkey
|
||||
|
||||
@@ -243,7 +230,6 @@ Gateway start command until KBN-101-02 makes that state fail closed.
|
||||
### Quality Gates
|
||||
|
||||
```bash
|
||||
pnpm preflight # Checkout/dependency/generated-state validation
|
||||
pnpm typecheck # TypeScript type checking (all packages)
|
||||
pnpm lint # ESLint (all packages)
|
||||
pnpm test # Vitest (all packages)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
-43
@@ -79,49 +79,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
||||
|
||||
---
|
||||
|
||||
## Per-estate durable agent working memory (#1051)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
Agent and lane continuity currently accumulates as plain local files with no repository backing. The installer must make a private, per-estate `mosaic-brain` clone at `~/.mosaic` reproducible without authorizing cross-estate access or introducing an independent credential path.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `MB-REQ-01` (R1): Ensure the target estate's existing `mosaic-brain` can be cloned to `~/.mosaic`; repository creation and live access granting remain broker-mediated.
|
||||
2. `MB-REQ-02` (R2/Q1): Derive estate and brain target from the configured target git host through the credential broker's estate registry. A second `brain_repo` authority and host-machine inference are forbidden; an unknown host fails closed with a named diagnosis.
|
||||
3. `MB-REQ-03` (R3): Seat access is granted only through `mosaic cred`; callers must never resolve or read a token independently. Live grant verification is gated on MC-CRED-01 implementation.
|
||||
4. `MB-REQ-04` (R4): The eventual live postcondition requires `~/.mosaic` to be a `main`-branch git repo with the expected remote and a seat-owned read/write round-trip. This live validation is gated on MC-CRED-01 implementation and cannot be replaced by a clone exit code.
|
||||
5. `MB-REQ-05` (R5): The out-of-estate refusal control covers both Git and API resolver axes. Axis disagreement is `indeterminate` failure, never permission; contract tests bind to the broker's four terminal classes and stable reason codes.
|
||||
6. `MB-REQ-06` (R6): The brain skeleton excludes `*.token`, `*.key`, `*.pem`, `.env`, and `credentials.json`; credentials remain broker-owned and no error path may print secret material. Arbitrary legacy content is never auto-published from a heuristic denylist: an approved content scanner must bind approval to the exact source snapshot, otherwise the item is retained and reported.
|
||||
7. `MB-REQ-07` (R7): Detect existing local lane directories and seat state files, publish approved snapshots into the durable layout without overwrite or deletion, and explicitly report every detected item that cannot be migrated. Automatic source deletion is parked until command-scoped identity propagation and the required clean audit; retained sources are always reported. Lane findings are append-only; `board/` has a named single writer; writes push immediately rather than on a timer.
|
||||
8. `MB-REQ-08` (R8): `mosaic doctor` reports missing clone, wrong remote, incomplete write-access evidence, and uncommitted local state. `--fix` repairs the first three only through the approved installer/broker path; it never hand-rolls credential resolution.
|
||||
9. `MB-REQ-09`: Retention is ownership-first and archive-only. Every retained artifact requires a named durable owner; absent or non-durable ownership leaves the gate open and blocking. Age and size never authorize deletion.
|
||||
10. `MB-REQ-10`: Brain provisioning occupies canonical installer P7 only after the applicable P5 credential postcondition commits; canonical phase numbers are unchanged.
|
||||
|
||||
### Current delivery slice
|
||||
|
||||
In scope now: estate derivation, secret exclusion, non-destructive migration, doctor reporting/repair orchestration, and red-first tests over all four credential-contract terminal classes. Live grant and live read/write round-trip evidence remain explicitly gated on the working MC-CRED-01 broker and must not be mocked or replaced by independent token lookup.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-MB-01`: Contract tests observe RED before implementation and then distinguish `ok/0`, `refused/10`, `error/20`, and `indeterminate/30`, preserving v1.5 diagnoses including refused `provider-identity-mismatch`/`credential-rejected` and indeterminate `identity-not-measured`/`provider-unavailable`. A scope-forbidden `/user` result with confirmed in-scope repository capability is never represented as a dead credential. `identity-not-found` remains reserved for a future visibility-authorized inventory operation and is not an expected `validate` result.
|
||||
2. `AC-MB-02`: Estate resolution uses the configured target git host and one registry; unknown, mismatched, and host-machine-derived inputs fail closed.
|
||||
3. `AC-MB-03`: A clean fixture contains the required layout and exact secret exclusions; filename-, content-, binary-, and size-based secret controls remain outside Git without their values appearing in output. Without an approved scanner, even benign legacy content is retained and reported rather than auto-published.
|
||||
4. `AC-MB-04`: Migration publishes approved lane-durable and seat-state snapshots into collision-safe archive/ledger paths, retains and reports every source, never overwrites an existing finding, and never deletes by age/size.
|
||||
5. `AC-MB-05`: Doctor detects all four R8 defect classes; `--fix` repairs eligible classes through the approved P7/broker seam and leaves unresolved credential-dependent states visible.
|
||||
6. `AC-MB-06`: Git-axis and API-axis refusal must both be authoritative `refused` outcomes with matching stable reason codes; any disagreement yields `indeterminate`.
|
||||
7. `AC-MB-07`: Independent code review and security review pass at the exact head, and HOMELAB Woodpecker instance `mosaic` is terminal green before integration.
|
||||
8. `AC-MB-08`: After reviewed merge to `main`, report only **believed-fixed, pending jarvis validation**; issue #1051 remains open until W-jarvis validates the installed result.
|
||||
|
||||
### Constraints and risks
|
||||
|
||||
- MC-CRED-01 contract v1.5 is the caller boundary; no independent credential/token lookup is permitted. Identity is established from governed mint-time binding and provider evidence when measurable, never a credential filename. Runtime validation does not widen a least-privilege token merely to make `/user` observable.
|
||||
- C1 owns installer phase sequencing. This slice consumes P5/P7 ordering without renumbering or duplicating the phase machine.
|
||||
- Lane content is findings, so last-writer-wins is data loss. Append-only names and explicit collision handling are mandatory.
|
||||
- A created-but-empty brain beside unbacked local doctrine fails the objective; migration is a primary acceptance gate.
|
||||
|
||||
---
|
||||
|
||||
## Compaction Refresh Trust Lifecycle (M1, #827–#830)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: the merge-base anchor round
|
||||
|
||||
**Subject head (exact):** `fbb61912981abb250d289ec7aafd4316db6fdb11`
|
||||
**Supersedes:** the second NO-GO at `32b490a7` (D-45/D-46/D-47). That verdict is VOID.
|
||||
**Registered by:** `mos-remediation` **before any reviewer read the diff.** **Reviewer:** `rev-974`.
|
||||
|
||||
## What the orchestrator already reproduced (do not re-spend the review here)
|
||||
|
||||
- Derived boundary `f4fd5967` **equals** an independently computed `git merge-base HEAD origin/main`.
|
||||
- Emptying `criteria`/`gates`/`proseClaims`/`compatibilityScenarios` now fails with
|
||||
_"population must be non-empty and anchored before evaluation"_.
|
||||
- The both-directions bootstrap statement is present in `gate-history.mjs` with the Builds 1–2 residual.
|
||||
|
||||
## A — attack the ANCHOR (highest value)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **A1** | `targetRef` is the literal `refs/remotes/origin/main`. **Who controls that ref in the environment where the gate runs?** Can a PR author influence it — a `remote.origin.url` rewrite, a crafted `refs/remotes/origin/main` in their own clone, a push to a fork's `main`? If the gate trusts a locally-writable ref, the anchor moved from the manifest into git config. |
|
||||
| **A2** | **Absent/stale target:** if `refs/remotes/origin/main` is missing, stale, or the fetch is shallow, does it fail CLOSED (line 57 suggests it does) or silently derive a narrower range? |
|
||||
| **A3** | **Delayed introduction — the round-2 attack, re-run.** Commit a gate change BEFORE adding the manifest. Does it now stay in range and fail the own-tree read? |
|
||||
| **A4** | **Branch from an old main:** branch from a point far behind `origin/main`, so merge-base is old. Does the range widen correctly (safe) or include unrelated main commits that cannot carry manifests (a new false-red)? Over-inclusion is the natural failure mode of this fix. |
|
||||
| **A5** | Is the **bootstrap residual** recorded as a **tracked dependency on Builds 1–2**, not prose? (D-19's third mandatory move.) |
|
||||
|
||||
## P — the non-empty/anchored precondition
|
||||
|
||||
| id | check |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **P1** | Does the precondition run **before** every quantified check, on **every** path — or only the ones the author enumerated? Find a quantified check that still runs before its precondition. |
|
||||
| **P2** | **Seven required gate IDs anchored to canonical sources** — can that anchor list itself be emptied, shrunk, or pointed elsewhere? An anchor list the author edits is D-45 again, one level in. |
|
||||
| **P3** | The author reports finding and REMOVING a **production CLI fixture-profile bypass** in their own pre-commit review. **Verify it is gone from the shipped CLI path**, that the remaining relaxation is test-support only and non-executable in production, and that the CLI rejection is tested. This was self-disclosed — confirm it independently. |
|
||||
|
||||
## Q — clauses quantified over the population (D-47)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Q1** | `gateRefs` **exactly spans** every registered gate. Can a gate be added WITHOUT a corresponding ref — i.e. does adding a gate to the population escape the clause? |
|
||||
| **Q2** | Deleting `gateRefs`, or shrinking gates and refs **together**, must fail. Shrink-together is the D-46 shape; verify it is a genuine control and not a regression guard mislabelled. |
|
||||
| **Q3** | Do the population controls **iterate all seven gates** and mutate evidence-subject and comparison-type **per gate**, or only a representative one? |
|
||||
| **Q4** | Honest labelling: author claims delayed-introduction, empty-populations, fixture-profile bypass and removable gateRefs as **genuine red-first**, and shrink-together/exact-span as **regression guards**. Verify each claim against pre-fix code (D-8). |
|
||||
|
||||
## C — head, CI, integrity
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **C1** | Head `fbb61912` on git and provider; `Closes #1029` present. |
|
||||
| **C2** | Exact-head CI terminal-green, full `-f json`, counts stated incl. `clone`; machine-checked with `verify-terminal-green.py --expect-commit fbb61912981abb250d289ec7aafd4316db6fdb11`. |
|
||||
| **C3** | Nothing weakened: `32b490a7` → `fbb61912` removes/relaxes no existing case, test, or assertion. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `fbb61912981abb250d289ec7aafd4316db6fdb11`, evidence enumerated, posted durably under
|
||||
your own identity. If a check is unrunnable, **say so**.
|
||||
|
||||
**A1 and P2 are the ones I most want answered** — both ask whether the fix moved the author's control
|
||||
point again rather than removing it, which is now the mission's named first-class principle and has
|
||||
recurred three times. **Attack outside this set.**
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -0,0 +1,67 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: the four-blocker hardening pass
|
||||
|
||||
**Subject head (exact):** `32b490a712a5e8e77f13c40c60099b51feb38994`
|
||||
**Supersedes:** the NO-GO at `83d2ecb2` (D-44, four silent-defeat paths). That verdict is VOID.
|
||||
**Registered by:** `mos-remediation` **before any reviewer read the diff.** **Reviewer:** `rev-974`.
|
||||
|
||||
## Framing — attack the FIX, not the original bug
|
||||
|
||||
Every round on this mission, **the remediation introduced the next hole**: the commit-binding fix
|
||||
shipped a type confusion; the type-strict fix was clean but the registry around it had four defeats.
|
||||
So the highest-value checks here are **not** "was each blocker fixed" — the orchestrator already
|
||||
reproduced three of the four attacks as dead — but **"is the NEW mechanism itself defeatable?"**
|
||||
|
||||
Blocker 1's fix replaced an author-settable field with a **derivation**. A derivation has inputs. **The
|
||||
question is who controls them.**
|
||||
|
||||
## H — the new derivation (highest value)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **H1** | `deriveHistoryBoundary` = parent of the first first-parent commit introducing `gates/gates.manifest.json`. **Are its inputs author-controlled?** Can a PR author influence the derived value by adding, moving, renaming, deleting-and-recreating, or symlinking that path — or by adding a SECOND manifest earlier in history? If the derivation is gameable, blocker 1 is not fixed, only relocated. |
|
||||
| **H2** | **First-parent traversal:** what happens on a merge commit, an octopus merge, a squash, a rebase that reorders, or a branch where the introducing commit is not on the first-parent chain? Does the boundary silently move, or fail closed? |
|
||||
| **H3** | **Shallow/partial clone:** the branch previously needed an unshallow fix (`e8959975` "unshallow gate replay history"). If history is shallow, does the derivation fail CLOSED or silently derive a wrong/empty boundary? |
|
||||
| **H4** | **Path deletion:** if `gates/gates.manifest.json` is absent at HEAD, or was deleted and re-added, what is derived? Fail closed, or a boundary that excludes the deletion window? |
|
||||
| **H5** | Are the **three registered seam controls (HEAD, HEAD^, introduction) genuinely RED-FIRST** — do they fail against the PRE-fix code for their own stated reason? A control that was always green is a regression guard, not a must-fail control (D-8). |
|
||||
|
||||
## S — the recursive schema closure
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **S1** | **Is closure COMPLETE or enumerated?** The author lists the objects they closed. Find one they did **not** — any nested object anywhere in the manifest — and inject an unknown key there. If it is accepted, the fix covers instances, not the class. |
|
||||
| **S2** | **Wrong-type, not just unknown-key:** `outputPattern` as a number/array/object/null rather than misspelled. Does the closed schema type-check values, or only key names? |
|
||||
| **S3** | **Empty/degenerate values:** `outputPattern: ""` — does an empty regex match everything and silently neuter the assertion the same way a typo did? The author claims an empty-pattern control; verify it is bound. |
|
||||
| **S4** | Confirm the registered typo/wrong-type/empty-pattern controls are **genuine red-first** against pre-fix code, and that anything labelled a regression guard is honestly labelled as one. |
|
||||
|
||||
## E — provider evidence (blocker 4)
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **E1** | Global validation runs **before** per-commit filtering **on every path**, including the HEAD-only path the author calls out. Is there any code path that reaches per-commit assessment without it? |
|
||||
| **E2** | **Duplicate identity by another key:** the fix makes pipeline NUMBER globally unique. Can two records still collide on a different identity dimension — same commit + different number, same URL, same step-id — and certify the wrong subject? |
|
||||
| **E3** | "Exactly one gate-verify step per record" — what if a record has zero, or two with different outcomes? Fail closed? |
|
||||
|
||||
## C — clauses, integrity, CI
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **C1** | The three new criteria (`RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-BOUNDARY`) are **checked criteria with bidirectionally bound cases**, not prose. Orchestrator observed caseRefs 1 / 3 / 3 — verify the bindings actually run and can fail. |
|
||||
| **C2** | **B1/B2 satisfied in substance:** does `RM02-EVIDENCE-SUBJECT-BINDING` express D-38 generally ("does this gate bind its evidence to its subject?") or only the one pipeline-number instance? Same for D-40 vs the one typo instance. **A clause that only covers its originating instance is not a clause.** |
|
||||
| **C3** | **Nothing weakened:** diff `83d2ecb2` → `32b490a7` removes/relaxes no existing case, test, or assertion. |
|
||||
| **C4** | Exact-head CI terminal-green, full `-f json`, counts stated **including `clone`**; machine-checked with `verify-terminal-green.py --expect-commit 32b490a712a5e8e77f13c40c60099b51feb38994`. |
|
||||
| **C5** | `Closes #1029` still present (D-38c). |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `32b490a712a5e8e77f13c40c60099b51feb38994`, evidence enumerated, posted durably under
|
||||
your own identity. If a check is unrunnable, **say so** — never substitute a passing variant.
|
||||
|
||||
**H1 and S1 are the two I most want answered**, because they ask whether each fix closed a **class** or
|
||||
an **instance**. Attack outside this set: four of the last five blockers on this PR came from mutating
|
||||
something nobody had registered.
|
||||
|
||||
**Environment note:** `pnpm gate:verify` fails on `checkout-preflight` with outcome 42 (bubblewrap
|
||||
class) in at least one worktree. If you hit it, report it as an environment limitation — do not treat it
|
||||
as a finding, and do not let it mask one.
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -0,0 +1,59 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: honest narrowing + (d)-strict
|
||||
|
||||
**Subject head (exact):** `e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`
|
||||
**Supersedes:** the third NO-GO at `fbb61912` (D-45/D-46/D-47) and the D-52 stop. Those are VOID.
|
||||
**Registered by:** `mos-remediation` before any reviewer read the diff. **Reviewer:** `rev-974`.
|
||||
|
||||
## Already reproduced by the orchestrator (do not re-spend here)
|
||||
|
||||
- The required narrowing sentence is **verbatim** in `gates/required-gates.baseline.json` and in
|
||||
`scripts/gate-inventory-claim-control.mjs`: _"Detects accidental and incompetent inventory drift
|
||||
within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier
|
||||
consistently — that guarantee requires RM-60's external boundary."_
|
||||
- **The overclaim control FIRES**: clean ⇒ exit 0; injecting "protected, independently anchored" into
|
||||
the baseline purpose ⇒ **exit 84**, `INVENTORY_CLAIM_OVERSTATED`.
|
||||
- CI #2201 at the exact head, 10/10 incl. `clone`; machine check bound, `exempted_steps 0`, no anomalies.
|
||||
|
||||
## N — the narrowing (D-52's ruling is the wording)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **N1** | ★ Is the narrowed claim **honest EVERYWHERE**, or only where the control looks? Find any place — docs, comments, observations, manifest prose, CLI output — that still implies the baseline is protected or independently anchored. **The control only checks the files it checks.** |
|
||||
| **N2** | ★ Can the **overclaim control itself** be defeated? It is PR-controlled like everything else. Weaken/skip/reword it so an overstatement passes. If it can be trivially neutered, D-52 is re-armed one level down — the exact recursion this PR keeps producing. |
|
||||
| **N3** | Does the control detect **semantic** overclaim or only the literal strings it greps? An artifact could assert protection in different words and pass. |
|
||||
| **N4** | Is the narrowed claim **TRUE**? Verify the baseline genuinely catches accidental/inconsistent drift — not just that it says so. A narrowed claim that is still overstated is D-48 again. |
|
||||
|
||||
## H — (d)-strict history removal
|
||||
|
||||
| id | check |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **H1** | Is the verifier **structurally incapable** of reporting history-provenance-verified, or merely not currently doing so? The manifest says "structurally incapable" — test the claim. |
|
||||
| **H2** | ★ **TRAP 1 re-check:** nothing emits the unverifiable state as an OBSERVATION (which exits 0). Confirm no path yields exit 0 while implying provenance was considered. |
|
||||
| **H3** | ★ **TRAP 2 re-check:** the exclusion is **enforced**, not merely declared in `coverageBoundary.excluded`. Re-enable history provenance without a trustworthy anchor — does a registered case go RED? |
|
||||
| **H4** | The residual is stated **correctly** — no local git state is trustworthy because PR code executes before the gate — **NOT** the D-48 wording ("compromised main"). |
|
||||
|
||||
## B3 / R — evidence-subject and renderer
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **B1** | Subject is on the **EVIDENCE side** and compared **at consumption**, not `gate.evidenceSubject === gate.id` metadata-vs-itself. Mutate the evidence-side subject **per gate**. |
|
||||
| **B2** | Can evidence still be moved or misbound while every gate satisfies its check? |
|
||||
| **R1** | Renderer now covers the **final success stdout/stderr** paths. |
|
||||
|
||||
## C — integrity + CI
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **C1** | Head `e910a45a` on git + provider; `Closes #1029` present. |
|
||||
| **C2** | Exact-head CI terminal-green, `-f json`, counts incl. `clone`; machine-checked with `--expect-commit e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`. |
|
||||
| **C3** | Nothing weakened `fbb61912` → `e910a45a`. Red-first labels honest (D-8). |
|
||||
| **C4** | **NO fourth local anchor was invented.** Blocker 1 and blocker 2's adversarial guarantee both track RM-60. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`, evidence enumerated, posted durably.
|
||||
Unrunnable ⇒ **say so**. **N1 and N2 are the sharp ones** — this PR's history is that every fix
|
||||
relocates the defect one level down, and N2 asks whether the control that enforces honesty is itself
|
||||
honest. **Attack outside the set: six for six so far.**
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -0,0 +1,71 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED ACCEPTANCE CHECKS (post-rebase, keystone)
|
||||
|
||||
**Subject head (exact):** `83d2ecb2243f1b0987ef2bc14de47f444285a684`
|
||||
**Rebased onto:** `main` @ `f4fd5967fc5d4cbc72d680b199d88224aa855131` (post-RM-61)
|
||||
**Registered by:** `mos-remediation` **BEFORE the reviewer read the diff.** **Reviewer:** `rev-974`
|
||||
(author ≠ reviewer; branch commits are `f10-coder`'s and `coder-mos1`'s).
|
||||
**Prior reviews 63 / 65 are SUPERSEDED; this head carries NO live review of any kind.**
|
||||
|
||||
RM-02 is the **keystone**: it is the anti-inert-gate registry every downstream gate is measured against.
|
||||
A defect that survives here propagates into every gate this mission ships.
|
||||
|
||||
## ★ CRUX — the one non-mechanical change, made by the author, that turned a red green
|
||||
|
||||
The rebase was clean. One further commit was **not** purely mechanical and the author disclosed it
|
||||
unprompted (`83d2ecb2` — advance registry activation seam):
|
||||
|
||||
gates/gates.manifest.json
|
||||
- "activationCommit": "f65e9ea656ec466e12640bf6ab5d46fe07ff160c"
|
||||
+ "activationCommit": "f4fd5967fc5d4cbc72d680b199d88224aa855131"
|
||||
|
||||
Stated rationale: after the rebase, `pnpm gate:verify` correctly failed because the newly-merged
|
||||
pre-registry RM-61 commit was treated as _prospective_ and required a manifest that could not exist.
|
||||
|
||||
`activationCommit` sets the lower bound of `activationCommit..HEAD` — **the set of commits required to
|
||||
carry own-tree registry provenance** (`gate-history.mjs:314` → `listProspectiveCommits`). Advancing it
|
||||
**shrinks that set**.
|
||||
|
||||
**Determine, do not assume — and I have deliberately formed and stated no verdict (D-39):**
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **A1** | **Is the advance JUSTIFIED?** Can a pre-registry commit on `main` (e.g. `f4fd5967`) satisfy the provenance requirement at all, or is requiring it impossible-by-construction? If impossible, is advancing the seam the _correct_ remedy, or does a correct remedy exist that does not move an author-controlled field? |
|
||||
| **A2** | **Is the advance MINIMAL?** Was it advanced exactly to the new parent baseline, or beyond it? Does any commit that _should_ remain covered fall outside the new range? |
|
||||
| **A3** | **Are all 7 branch commits still PROSPECTIVE under the new seam?** Enumerate them and show each is still required to carry provenance. The author claims "own-tree provenance over every registry-era branch commit" — verify the claim, do not accept it. |
|
||||
| **A4** | ★★ **IS THE SEAM ITSELF CONSTRAINED?** The only validation found is `merge-base --is-ancestor activationCommit head` (`gate-history.mjs:288`). **A commit is its own ancestor.** Determine what `activationCommit = HEAD` does: is the prospective range empty, does the per-commit loop iterate zero times, and does the history check therefore PASS VACUOUSLY? If so, the anti-inert-gate registry contains an author-settable field that can inert its own history check. **Run it. Report the observed exit and whether any failure is raised.** |
|
||||
| **A5** | **If A4 shows vacuity is reachable, is there a registered MUST-FAIL negative control for it?** RM-02's own founding rule is that a gate with no proven failure path manufactures evidence. Does the registry hold a case that goes RED when the seam is over-advanced? If not, that is a hole in the registry's coverage of itself. |
|
||||
| **A6** | **Self-verification shape (charter / D-19 / D-39b):** the field was advanced by the change's own author to make the author's gate pass. Independent of whether the value is correct, determine whether the _mechanism_ permits an audited party to relax its own audit, and whether that needs a constraint, an owner, or an escalation. |
|
||||
|
||||
## Registry substance — the clauses this PR must now carry
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **B1** | **D-38 clause present and enforced:** the registry asks of every gate _"does this gate BIND its evidence to the subject under review?"_ Ruled in-scope for THIS PR by Mos. Verify it exists as a checked clause, not prose. |
|
||||
| **B2** | **D-40 clause present and enforced:** _"discriminator and comparison inputs must be TYPE-STRICT."_ Also ruled in-scope for THIS PR. `== 0` matching `False`/`0.0` is the banked instance; the clause is the general form. |
|
||||
| **B3** | **D-42's clause is CORRECTLY ABSENT** — provider-side negative control tracks separately (Mos: landing it now would give the registry a clause with nothing to satisfy it, going red on its own clause). Confirm its absence is deliberate and recorded, not forgotten. |
|
||||
| **B4** | **The four founding clauses still hold** (`MISSION.md`): every check proven **right** (red for its own stated reason), the set **covers**, no two criteria **conflict**, and criterion evolution **retains original text + restatement + reason**. |
|
||||
| **B5** | **Nothing was weakened, skipped, or deleted by the rebase.** Diff `f9746b23` → `83d2ecb2` and confirm no registered case, test, or assertion was removed or relaxed to make the rebase land. |
|
||||
| **B6** | **`coverageBoundary.excluded[]` is honest in BOTH directions** (charter principle 4): what it does NOT cover is stated beside what it DOES, and the gap is tracked (`trackedBy: RM-54`) rather than implied-fixed. |
|
||||
| **B7** | **The RM-02 execution boundary** (`gate-history.mjs`, DOES / DOES NOT, owner RM-60, xref RM-59) states its limits in both directions and names a tracked owner — not a documented gap with no owner. |
|
||||
|
||||
## Head + CI (re-run these; do not carry them forward from the author's report)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **C1** | Head `83d2ecb2…` on **both** git and provider; `Closes #1029` present in the body (delivery-issue rule, D-38c). |
|
||||
| **C2** | Exact-head CI terminal-green by **full `-f json` scan**, counts stated **including `clone`**. Note the count changed 9 → **10** (this PR adds `gate-verify`) — confirm the new step is real, not a miscount. |
|
||||
| **C3** | **Machine-check it, do not assert it:** `verify-terminal-green.py --expect-commit 83d2ecb2243f1b0987ef2bc14de47f444285a684`. Report exit, `exempted_steps`, `commit == expected_commit`. |
|
||||
| **C4** | `exempted_steps=0` is expected here — `ci-postgres` succeeded, so the #1000 exemption was **not needed**. Confirm the exemption is present-but-unused rather than silently inapplicable. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `83d2ecb2243f1b0987ef2bc14de47f444285a684`, evidence enumerated per check, posted
|
||||
durably under your own identity. If a check is unrunnable, **say so** — never substitute a variant that
|
||||
passes. Scan CI from `-f json`, never default text (D-33); state your counts.
|
||||
|
||||
**A4 is the check I most want an answer to and the one I have least confidence about.** Attack outside
|
||||
this set as well: every blocker found on this mission so far came from mutating something nobody had
|
||||
registered a check for.
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).** Observations may be
|
||||
relayed to you; verdicts may not. The ruling is yours.
|
||||
@@ -0,0 +1,42 @@
|
||||
# RM-03 / PR #1032 — PRE-REGISTERED RE-REVIEW (post-freshening)
|
||||
|
||||
**Subject head (exact):** `868b9b871a5118762aa5b8aafecd2f0592f7ab5c`
|
||||
**Rebased onto:** `main` @ `f4fd5967` **Registered by:** `mos-remediation` before the reviewer read the
|
||||
diff. **Reviewer:** `rev-974`.
|
||||
|
||||
**Why this exists:** review 66 APPROVED and the merge-gate GO were bound to `78ec47cd`; `main` drifted
|
||||
under a long hold and the PR went `mergeable=false` (**D-50 — a GO has a shelf life**). Both verdicts
|
||||
are VOID at the new head. Jason's merge approval is **standing** and executes once this re-gates.
|
||||
|
||||
## What the orchestrator already verified (do not re-spend here)
|
||||
|
||||
- `git range-diff 78ec47cd...868b9b87`: commits **2–5 are `=` (patch-equivalent)**; only commit 1 differs.
|
||||
- That single difference is the `test:framework-shell` chain, resolved as a **UNION** — main's
|
||||
`test-terminal-green-contract.sh` **and** RM-03's `tristate`, `github-checks`, `merge-queue-branch`,
|
||||
`merge-head-pin`, plus the pre-existing `branch-absent`. **Nothing dropped from either side.**
|
||||
- CI #2199 at the exact head: 9 children, 9 success incl. `clone`; machine check exit 0, bound, no anomalies.
|
||||
- `mergeable` is now **true**.
|
||||
|
||||
## Checks
|
||||
|
||||
| id | check |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **R1** | ★ **THE GUARD CAN FAIL.** RM-03 exists because the queue guard is ZERO-INFORMATION (D-23) — it returns pass for every possible input. Prove the rebased head's guard **actually fails** on asserted non-readiness, not merely that its tests pass. This is the whole deliverable. |
|
||||
| **R2** | ★ **NO QUEUE-GUARD SEMANTIC MOVED IN THE REBASE.** Confirm the range-diff equivalence independently. A queue-guard semantic resolved quietly re-breaks the thing this PR fixes. |
|
||||
| **R3** | **Union is complete in BOTH directions:** no RM-03 suite lost to main's version, no main suite (esp. `test-terminal-green-contract.sh`) lost to RM-03's. Enumeration count reconciles. |
|
||||
| **R4** | **Tri-state is real** (`verified` / `written-unverified` / `failed`) — P-WRAPPER-001. Verify an indeterminate result cannot present as a pass; that is the exact defect (`state=unknown exit 0`). |
|
||||
| **R5** | The guard's **exit-asserting non-null-case** tests are genuine — each fails for its own stated reason against pre-fix code, not merely present (D-8). |
|
||||
| **R6** | `test:framework-shell` is **runnable to completion in CI** (canonical env). Known: it exits 97 on some hosts via a Bash 5.2.15 `BASH_LINENO` assertion — if you hit that, report it as an environment limitation, do not treat it as a finding and do not let it mask one. |
|
||||
| **R7** | Exact-head CI terminal-green, full `-f json`, counts stated incl. `clone`; machine-checked with `--expect-commit 868b9b871a5118762aa5b8aafecd2f0592f7ab5c`. |
|
||||
| **R8** | `Closes #1019` present in the body (D-38c delivery-issue rule). |
|
||||
| **R9** | Nothing weakened by the rebase: no test, case, or assertion removed or relaxed to make it land. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `868b9b871a5118762aa5b8aafecd2f0592f7ab5c`, evidence enumerated, posted durably under
|
||||
your own identity. If a check is unrunnable, **say so**.
|
||||
|
||||
**R1 is the deliverable and R2 is the risk.** Attack outside this set. **Do not cite the queue guard's
|
||||
own green as evidence of anything** — it remains inert until this very PR lands (D-23).
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -0,0 +1,40 @@
|
||||
# RM-61 / PR #1033 — PRE-REGISTERED RE-REVIEW ACCEPTANCE CHECKS
|
||||
|
||||
**Subject head (exact):** `033b2ffb46674b2c0bcc5197273c109b461f62d9`
|
||||
**Supersedes:** review 67 / comment 20403 @ `e7b29219` (NO GO). That verdict is VOID — the head moved.
|
||||
**Registered by:** `mos-remediation` (orchestrator). **Reviewer:** `rev-974` (author ≠ reviewer).
|
||||
**Registered BEFORE the reviewer read the diff.** Any head move after this file is committed voids the
|
||||
re-review and requires re-registration.
|
||||
|
||||
## Scope discipline
|
||||
|
||||
The prior review passed AC1–AC8 and still found a blocker, because the registered set tested whether the
|
||||
signature DISCRIMINATES and never tested whether the evidence was BOUND TO ITS SUBJECT (Finding 3 /
|
||||
coverage-failure-mode-2, D-17 class). This set therefore carries the binding property as a first-class
|
||||
check, and **RR7 explicitly invites the reviewer to attack outside the set** — a registered set is
|
||||
protection against retrofitting only, never a ceiling on scrutiny.
|
||||
|
||||
## Checks
|
||||
|
||||
| id | check | verdict form |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| **RR1** | The full 12-case contract harness passes at the exact head: `bash test-terminal-green-contract.sh` | ⇒0, and states 12 cases |
|
||||
| **RR2** | ★CRUX — the ORIGINAL ATTACK IS DEAD. Take the real `#2188` record, mutate ONLY `commit` to an unrelated 40-hex value, verify against the true expected head | ⇒1, `exempted_steps == 0`, anomaly naming expected vs actual |
|
||||
| **RR3** | Missing `--expect-commit` cannot be defaulted, inferred, or skipped | ⇒2 (not 0, not 1) |
|
||||
| **RR4** | Malformed expected commit (short SHA, non-hex, empty) is rejected — no silent normalisation into a pass | ⇒2 |
|
||||
| **RR5** | A record with the `commit` key ABSENT (not merely different) is rejected — fail-closed on missing, not just on mismatch | ⇒1, `exempted_steps == 0` |
|
||||
| **RR6** | The genuine artifact still passes when correctly bound: real `#2188` + its true head | ⇒0, `exempted_steps == 1`, exactly one `WP-K8S-1000-CI-POSTGRES-TEARDOWN` |
|
||||
| **RR7** | ★RED-FIRST, PROVED RETROACTIVELY. The four new cases must FAIL against the OLD verifier at `e7b29219` — otherwise they do not test what they claim (D-8 class). Run the new cases against the previous implementation | new cases ⇒≠0 under `e7b29219` |
|
||||
| **RR8** | AC2 OF THE PRIOR SET DID NOT REGRESS: both REAL controls stay terminal red — `#2189` (`ci-postgres` exit 1) and `#2191` (exit 137, `test` exit 61) | both ⇒1, `exempted_steps == 0` |
|
||||
| **RR9** | Still NO fetch / trigger / retry / re-roll / sleep / network of any kind in the verifier or harness. The coin flip must remain removed, not codified (D-21) | grep ⇒ no such call sites |
|
||||
| **RR10** | The doc/baseline changes REQUIRE the current provider PR head to be passed — they must not merely mention it. Check `merge-gate.md`, `CI-CD-PIPELINES.md`, `woodpecker/README.md` state it as a requirement a gate operator cannot satisfy by omission | reviewer judgement, quote the lines |
|
||||
| **RR11** | Exemption remains bound to #1000 and retires with it; signature conjunction unchanged and not widened by this fix | diff-scoped, ⇒ no widening |
|
||||
| **RR12** | Case-sensitivity: an uppercase-hex record commit against a lowercase expected head must NOT silently pass by accident of comparison. State which way it resolves and whether it fails closed | state the observed behaviour |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Report the verdict **bound to `033b2ffb46674b2c0bcc5197273c109b461f62d9`** and state each check's
|
||||
observed result, including counts read from `pipeline-status.sh -f json` (never default text — it omits
|
||||
`clone`, D-33). If any check is unrunnable, **say so** — never substitute a passing variant. Attack
|
||||
outside this set and report anything it finds; RR7 and RR12 exist because the last blocker was found
|
||||
exactly that way.
|
||||
@@ -0,0 +1,60 @@
|
||||
# RM-61 / PR #1033 — PRE-REGISTERED ACCEPTANCE CHECKS: `exit_code` TYPE-STRICTNESS (D-40)
|
||||
|
||||
**Registered by:** `mos-remediation` (orchestrator) — **BEFORE the fix was pushed and before any
|
||||
reviewer read a diff.** At registration time the fix existed only as an unpushed local commit
|
||||
(`6e7a336d`) on coder-mos1's machine which **I have not read**. There is therefore no diff for these
|
||||
checks to have been retrofitted to.
|
||||
|
||||
**Subject head:** TBD — binds to the NEW head once coder-mos1 pushes. The prior head
|
||||
`033b2ffb46674b2c0bcc5197273c109b461f62d9` and its review 69 / pipeline #2193 go VOID on that push;
|
||||
that cost was accepted deliberately by Mos's BLOCKING ruling on D-40.
|
||||
|
||||
**Ruling being enforced (Mos, 2026-08-01):** `exit_code` must be accepted ONLY as a real integer.
|
||||
`false`, `0.0`, `"0"`, and `null` must all fail to satisfy the exemption. Wrong-ACCEPT is the
|
||||
disqualifying direction; this hole sits inside the load-bearing discriminator.
|
||||
|
||||
## ⚠ Read this before writing the tests — RED-FIRST HERE IS NOT UNIFORM
|
||||
|
||||
Two of the four near-miss values **already block** at `033b2ffb`. Demanding "all four observed RED
|
||||
first" would be demanding an impossible red for two of them, and the predictable response to an
|
||||
impossible demand is a fudge — weakening something real to manufacture the red. So state it precisely:
|
||||
|
||||
| value | behaviour at `033b2ffb` (pre-fix) | what the new case is |
|
||||
| ------- | --------------------------------- | ------------------------- |
|
||||
| `false` | **WRONGLY EXEMPTS** (exit 0) | **genuine RED-FIRST** |
|
||||
| `0.0` | **WRONGLY EXEMPTS** (exit 0) | **genuine RED-FIRST** |
|
||||
| `"0"` | correctly blocks (exit 1) | **regression guard** only |
|
||||
| `null` | correctly blocks (exit 1) | **regression guard** only |
|
||||
|
||||
Claiming red-first for `"0"` or `null` would be a false claim about your own evidence. Say which is
|
||||
which. **Do not weaken anything to make a green case go red** (D-8).
|
||||
|
||||
## Checks
|
||||
|
||||
| id | check | verdict form |
|
||||
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| **TS1** | ★RED-FIRST. `exit_code: false` on the real #2188 record is **observed wrongly exempting at `033b2ffb`** (exit 0, `exempted_steps 1`), then blocks after the fix | pre-fix ⇒0/exempt 1 · post-fix ⇒1/exempt 0 |
|
||||
| **TS2** | ★RED-FIRST. Same for `exit_code: 0.0` | pre-fix ⇒0/exempt 1 · post-fix ⇒1/exempt 0 |
|
||||
| **TS3** | REGRESSION GUARD. `exit_code: "0"` blocked before AND after — state honestly that it was already green | both ⇒1, `exempted_steps 0` |
|
||||
| **TS4** | REGRESSION GUARD. `exit_code: null` blocked before AND after | both ⇒1, `exempted_steps 0` |
|
||||
| **TS5** | ★NOT OVER-TIGHTENED. The genuine artifact — real #2188, real integer `exit_code: 0`, correctly bound head — still passes | ⇒0, `exempted_steps 1`, exactly one `WP-K8S-1000-CI-POSTGRES-TEARDOWN` |
|
||||
| **TS6** | The strictness sits on the value the EXEMPTION rests on, not on a cosmetic sibling. Show the guarded comparison is the one feeding `exemption_applies` | reviewer quotes the line |
|
||||
| **TS7** | `bool` is excluded EXPLICITLY, not incidentally. A bare `isinstance(x, int)` still admits `True`/`False` — verify the `not isinstance(x, bool)` clause exists | ⇒ clause present; `true` also blocks |
|
||||
| **TS8** | Negative control unaffected: a genuine failed step with a real integer non-zero exit still blocks | ⇒1, `exempted_steps 0` |
|
||||
| **TS9** | NO REGRESSION ON THE PRIOR ROUND'S BINDING WORK: mutated record commit ⇒1; `--expect-commit` omitted ⇒2; record `commit` absent ⇒1 | ⇒1 / ⇒2 / ⇒1 |
|
||||
| **TS10** | Signature conjunction NOT widened elsewhere by this fix — `POD_NOT_FOUND` / name / type / state untouched | diff-scoped ⇒ no widening |
|
||||
| **TS11** | Still no fetch / trigger / retry / re-roll / sleep / network | grep ⇒ no call sites |
|
||||
| **TS12** | Full harness passes at the new head; **state the case count** (12 previously, expected 16 — confirm the actual number rather than the expected one) | ⇒0, count stated |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to the NEW head, evidence enumerated per check, CI counts from `pipeline-status.sh -f json`
|
||||
(never default text — it omits `clone`, D-33). If a check is unrunnable, **say so**; never substitute a
|
||||
passing variant.
|
||||
|
||||
**Attack outside this set and report what you find.** Both blockers on this PR so far — the missing
|
||||
commit binding, and this type confusion — were found outside the registered set, by mutating a field
|
||||
nobody had registered a check for. That is now the expectation, not a bonus.
|
||||
|
||||
**Nobody dispatching this review may state a conclusion on an open check here (D-39).** If you are sent
|
||||
an observation, it is an observation; the ruling is yours.
|
||||
@@ -23,3 +23,68 @@ Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ th
|
||||
### **D-4 / P-LIFECYCLE + hygiene — a dispatched agent silently IGNORED an in-message context reset.**
|
||||
|
||||
planner-sol was at 64.3%/372k; the brief asked it to reset first; it began work on dirty context anyway. Only an out-of-band `/new` driven by the orchestrator guaranteed clean state. Confirms the postmortem thesis: **instructions are not enforcement.** Reset must be a mechanical pre-dispatch step, not a request.
|
||||
|
||||
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
||||
|
||||
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
|
||||
|
||||
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
|
||||
|
||||
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
|
||||
|
||||
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
|
||||
|
||||
<!-- board-roll: 1 entry rolled from BOARD.md -->
|
||||
|
||||
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
|
||||
|
||||
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
|
||||
|
||||
<!-- board-roll: Decisions-log narrative rolled from BOARD.md 2026-08-01 (D-43: meet the
|
||||
byte budget by ROLLING OUT, never by rewording what stays) -->
|
||||
|
||||
### Decisions log — full record in [`TASKS.md`](./TASKS.md)
|
||||
|
||||
All 44 findings (D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-43 + D-38c in `TASKS.md`) and every ruling with its
|
||||
rationale live there. **Not duplicated here** — a second copy is a second thing to go stale, which this
|
||||
board had done three times in one night (gate list, capability registry, DECISION-1 status), and three
|
||||
more times by the next rotation seam (RM-61 "building", "nothing implemented yet", DECISION-1/2/3
|
||||
"must be ruled"). The rulings a fresh seat needs are items 4–7 above; they are **not** repeated here,
|
||||
because that repetition is what went stale.
|
||||
|
||||
<!-- board-roll: Sequencing section rolled verbatim from BOARD.md 2026-08-01 (D-43: meet the
|
||||
byte budget by ROLLING OUT, never by rewording what stays). Canonical: MISSION.md + TASKS.md §5 -->
|
||||
|
||||
### Sequencing — see [`MISSION.md`](./MISSION.md)
|
||||
|
||||
Builds 1-5, the cross-cutting retirements, and DECISION-1's corrected wire-in target are stated once in
|
||||
the charter and `TASKS.md` §5. **Not repeated here** — the previous copy of DECISION-1's status on this
|
||||
board is one of the six stale restatements below.
|
||||
|
||||
<!-- board-roll: capability/seat-identity bullets rolled verbatim from BOARD.md 2026-08-01
|
||||
(D-43: roll out, never reword). Authoritative home: TASKS.md D-11 / D-11a / D-11b. -->
|
||||
|
||||
- Capability is **per-path** (D-11b → **superseded in part by D-13/D-15**): a token file is **necessary,
|
||||
not sufficient**. Three layers — token file (raw-API), `tea` login (tea paths), **repository permission**
|
||||
(writes). Before dispatch, assert `permissions.push == true` **as that seat**, not token existence and
|
||||
not a 200 on a read. Mos owns provisioning; escalate missing pairs.
|
||||
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
|
||||
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
|
||||
|
||||
<!-- board-roll: D-26 gate-restatement rationale rolled verbatim from BOARD.md 2026-08-01 -->
|
||||
|
||||
### Why the board must not restate the delivery gates (D-26)
|
||||
|
||||
Restating the gate from memory is how the merge-gate step went missing from mission setup twice,
|
||||
once inside the correction for it (**D-26**).
|
||||
|
||||
<!-- board-roll: Fleet seats rolled verbatim from BOARD.md 2026-08-01 (D-43: roll out, never
|
||||
reword). NOTE: all these seats are UNMANAGED per D-41; `mosaic fleet ps` is live truth. -->
|
||||
|
||||
## Fleet seats
|
||||
|
||||
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
||||
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
|
||||
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
|
||||
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
||||
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
||||
|
||||
+65
-73
@@ -1,93 +1,85 @@
|
||||
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
||||
|
||||
**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next.
|
||||
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
||||
**Phase:** EXECUTING — **RM-02 keystone is the front**.
|
||||
**Updated:** 2026-08-05 — successor seat live. D-56 state correction applied (lost round-5 dispatch,
|
||||
4 idle days); round-5 review then ran same day: verdict id 88 REQUEST_CHANGES @ `e910a45a`,
|
||||
remediation dispatched to `coder-mos2`. Review dispatches now always leave a durable PR comment.
|
||||
⚠ That was a **MANUAL pane respawn** (prior seat ~803k tokens): it validates the checkpoint+rehydration
|
||||
**design**, NOT a lifecycle **mechanism** — P-LIFECYCLE rotation does not exist yet (**D-41 / RM-62**).
|
||||
|
||||
## Head
|
||||
|
||||
- Mission charter + 15 decisions + 4-build plan: PERSISTED (`docs/remediation/MISSION.md`).
|
||||
- HOLD lifted for this workstream (Jason 2026-07-31). Nothing implemented yet — planning first.
|
||||
- Orchestrator seat `mos-remediation` is LIVE and owns the mission. Residency attestation: PASS.
|
||||
- **TASK-0 DONE** — checkout repaired, all three gates green HONESTLY (no `--no-verify`), branch pushed.
|
||||
- **TASK-1 DONE** — both planners delivered independently on clean context; reconciled into `TASKS.md`
|
||||
(58 tasks across P0–P5, 7 convergences, 7 adjudicated disagreements, 3 escalated decisions).
|
||||
- **NEXT ACTION IS NOT MINE:** DECISION-1/2/3 (`TASKS.md` §5) must be ruled before P0 dispatch.
|
||||
RM-01 is dispatchable immediately regardless — it depends on nothing and blocks everything.
|
||||
- Charter + 15 decisions + 4-build plan: `MISSION.md`. Backlog + all findings: `TASKS.md`.
|
||||
- Planning DONE (58 tasks, P0–P5). **DECISION-1/2/3 all RULED by Mos 2026-07-31** (`TASKS.md` §5) —
|
||||
nothing is waiting on a decision. D-2's availability _target_ is Jason-pending and non-blocking.
|
||||
- **Executing, not planning.** RM-01 is MERGED; three lanes are live (see In-flight).
|
||||
- Orchestrator seat `mos-remediation` LIVE, owns the mission, resumed across the rotation seam
|
||||
2026-08-01 and re-attested to Mos from the files. Residency attestation: PASS.
|
||||
|
||||
## In-flight
|
||||
|
||||
| Task | Owner | State |
|
||||
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
|
||||
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
|
||||
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
|
||||
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
|
||||
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
|
||||
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
|
||||
| Task | Owner | State |
|
||||
| ------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| RM-01 checkout | — | **MERGED** `f58b3699` (#1027) |
|
||||
| RM-03 queue guard | Jason (re-sync) | ✅ **MERGED** `58b971ab` (#1032), #1019 closed. ⚠ **NOT DELIVERED**: installed guard still the broken one (291 lines / 0 `ASSERTED_NOT_READY` vs main 482 / 5). Re-sync via `mosaic upgrade`, then **prove it blocks a KNOWN-RED pipeline** — **D-51** |
|
||||
| RM-02 registry ★key | coder-mos2 | **ROUND-5 VERDICT id 88 REQUEST_CHANGES @ `e910a45a` (2026-08-05T22:37Z, live).** 2 blockers left: renderer accepts manifest-controlled `COMPATIBILITY `-prefixed provenance-success payloads; overclaim control is lexical-not-semantic vs docs claims. Shrink-together + evidence-subject CONFIRMED CLOSED — do not weaken. Remediation dispatched to `coder-mos2` 22:5xZ (PR comment + verified tmux); context guard: checkpoint at 85%. ⚠ **MERGE PRECONDITIONS (D-55/D-55f, measured):** poster=f10-coder (1 of 3 authors); (1) explicit `-m squash` (repo default `merge` discards ALL authorship); (2) **merger ≠ f10-coder** (self-merge suppresses the trailer generator → all 3 lost); (3) **the EXECUTING pr-merge.sh must contain `MergeMessageField` — merging #1066 is NOT sufficient** (deployed wrapper `08a65e85` = 0 refs, physically cannot emit; D-51's merged≠effective class inside the remedy; Mos-as-merger verifies wrapper sha256 before ANY trailer-dependent merge and refuses on `08a65e85`); (4) ✅ **CLOSED ON MEASUREMENT** — N=2 supplied non-poster trailers BOTH land, order preserved, poster line appended last (fixture prmerge-trailer-fixture#2 → `39db9d13`, #1030's exact shape; N=3+/poster-dup-dedup/Co-committed-by absence remain unmeasured but #1030 needs N=2). Merge decision resolved: all-land ⇒ merge as planned, three authors preserved. Trade in body; **branch MUST NOT be deleted**. ACs @ `dde38717` |
|
||||
| RM-61 CI exemption | — | ✅ **MERGED** `f4fd5967` (#1033). #1034 closed; **#1000 stays OPEN** (retirement trigger). Exemption is on `main` |
|
||||
| RM-59 / RM-60 | Jason (infra) | tracked deps; RM-60 option **B** |
|
||||
| #1023 queue attempt | Jason | SUPERSEDED-PENDING-JASON — live REQUEST_CHANGES, do **not** merge |
|
||||
|
||||
### For the incoming orchestrator — read this before acting
|
||||
|
||||
1. **Lane state lives in the In-flight table above — this item does NOT restate it.** It went stale
|
||||
three times in one session by duplicating that table (D-26's class). Read the table. ⚠ **And
|
||||
re-derive any board claim from the provider before load-bearing use (D-43)** — the board is
|
||||
sole-written and has no independent verifier.
|
||||
2. **`docs/remediation/TASKS.md` is authoritative**, not the newest voice in a chat. It holds 58 findings
|
||||
(D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-55c + D-38c in TASKS.md), every ruling with its rationale, and the
|
||||
requirements each finding placed on RM-02/RM-34/RM-50/RM-55.
|
||||
3. **`MISSION.md` carries the first-class principles** — read them there, they are not listed here.
|
||||
Two added 2026-08-01: **the anchor must live outside the audited party's authority** (D-19/D-25/D-45,
|
||||
third arrival) and **no universally-quantified check may pass over an empty set** (D-44/D-46).
|
||||
4. **Seat identity:** `export MOSAIC_GIT_IDENTITY=<seat>` is stripped by a context reset (**D-34**) —
|
||||
every dispatch/rehydration brief must re-export it, or the seat cannot use its credentials.
|
||||
5. **Scan CI from `-f json`, never default text** — text mode omits `clone` (**D-33**). State counts.
|
||||
6. **The queue guard is zero-information until RM-03 merges** (**D-23**) — never cite its green.
|
||||
7. **The bounded CI re-roll used on RM-02 was a one-time stopgap, NOT policy.** A per-PR free re-roll is
|
||||
D-21 normalisation. Do not repeat it; RM-61 is the fix.
|
||||
|
||||
## Delivery gates — REFERENCE, do not restate
|
||||
|
||||
Canonical: `~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) +
|
||||
`~/.config/mosaic/fleet/roles/validator.md`. Order and the freeze/zero-information rules: `MISSION.md`
|
||||
and `KICKSTART.md`. **Read them there** (why: **D-26**, in `BOARD-LEDGER.md`).
|
||||
|
||||
## Fleet seats
|
||||
|
||||
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
||||
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
|
||||
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
|
||||
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
||||
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
||||
Roster rolled verbatim to [`BOARD-LEDGER.md`](./BOARD-LEDGER.md); live truth is `mosaic fleet ps`.
|
||||
|
||||
## Gate status
|
||||
|
||||
- Delivery gates active: author≠reviewer, diff-blind pre-registered checks, CI-green, merged-PR completion.
|
||||
- Freeze: LIFTED for this workstream only.
|
||||
- Git identity: `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
||||
- Git identity: orchestrator runs `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
||||
gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions.
|
||||
- Capability check (D-11b): before dispatching seat X to provider Y, verify
|
||||
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists. Token-file set = authoritative
|
||||
capability registry. Mos owns provisioning; escalate missing pairs to him.
|
||||
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
|
||||
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
|
||||
- Standing worker-brief doctrine (accreted, mandatory in every brief): don't weaken a RED test to make
|
||||
it pass; if a check is unrunnable as written SAY SO, never silently substitute; `agent-send -f` never
|
||||
`-m`; heavy artifacts off shared `/tmp`.
|
||||
- Capability + seat identity (**D-11b / D-11a**, incl. the false-NEGATIVE twin): authoritative in
|
||||
`TASKS.md`. Short form — **assert the DIFFERENTIAL as that seat** (authenticated `push:true` vs
|
||||
unauthenticated `push:false`); a single endpoint can be true for anyone or 403 for an unrelated
|
||||
scope. Full text rolled to [`BOARD-LEDGER.md`](./BOARD-LEDGER.md).
|
||||
- ⚠ **LIVE HAZARD (D-37) — one shared `.git/config` re-identifies EVERY worktree at once.** Every seat,
|
||||
including `rev-974`'s review worktree, currently authors as **`coder-mos1`**; `MOSAIC_GIT_IDENTITY`
|
||||
does **not** override it. **STANDING ORDER: commit with explicit
|
||||
`git -c user.name=<seat> -c user.email=<seat>@…`, and NOBODY rewrites the shared config mid-flight.**
|
||||
Real fix authorised, Mos owns it, sequenced at a quiet seam. **#1024 implicated.** Detail: D-37.
|
||||
- Standing worker-brief doctrine (mandatory in EVERY brief): re-export `MOSAIC_GIT_IDENTITY` (**D-34**);
|
||||
commit early/WIP (**D-31**); don't weaken a RED test to pass; if a check is unrunnable SAY SO, never
|
||||
substitute; `agent-send -f` never `-m`; artifacts off shared `/tmp`; scan CI from `-f json` (**D-33**);
|
||||
**relay observations into an open review, NEVER your own conclusion on an open check (D-39)**; the
|
||||
**author never adjudicates their own PR's blocker status** — surface evidence, prepare the fix, hold.
|
||||
- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay**
|
||||
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
|
||||
|
||||
## Sequencing (from MISSION.md)
|
||||
## Decisions log — full record in [`TASKS.md`](./TASKS.md)
|
||||
|
||||
1. Spine + choke-point service (MACP wiring @ mosaic_orchestrator.py::run_single_task) + PG/Redis
|
||||
⚠ **CONTESTED — see DECISION-1.** Both planners independently reject this wire-in point: that
|
||||
controller is `"enabled": false` and references a dispatcher that does not exist here. Charter text
|
||||
left UNCHANGED pending Mos/Jason ruling; do not treat it as settled.
|
||||
2. Rotation daemon (finish Mission Control Plane, reuse packages/coord)
|
||||
3. Comms service (envelope→service→PG/Redis→adapters)
|
||||
4. Hygiene + conformance harness
|
||||
Cross-cutting retirements: flat-file tracking, 3 MACP islands, silent MOSAIC BYPASS.
|
||||
|
||||
## Dogfood evidence — live failure classes, not hypotheticals
|
||||
|
||||
> Newest first. Oldest entries roll to `BOARD-LEDGER.md` via `board-roll.sh` when this file
|
||||
> exceeds its 8 KB cap. Keystone detail is duplicated in `TASKS.md` §1a, so rolling loses nothing.
|
||||
|
||||
<!-- BOARD-ROLL:START -->
|
||||
|
||||
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
|
||||
|
||||
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
|
||||
|
||||
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
|
||||
|
||||
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
|
||||
|
||||
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
|
||||
|
||||
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
|
||||
|
||||
<!-- BOARD-ROLL:END -->
|
||||
|
||||
## Decisions log
|
||||
|
||||
- 2026-07-31 — Mission set up by Mos post-postmortem (15/15 decided). Dogfood posture active.
|
||||
- 2026-07-31 — Mos: stale `.mosaic/orchestrator/mission.json` is RESIDUE of the disabled Python
|
||||
orchestrator rail that this plan RETIRES. Do NOT invest in it; do NOT build on that rail. The 0/0
|
||||
milestone banner is cosmetic. (Supersedes any plan to repair it.)
|
||||
- 2026-07-31 — Mos: planners must be dispatched with GUARANTEED clean context, not requested-clean.
|
||||
Prior default-socket planner sessions predate this mission; dirty context is the indicted hygiene.
|
||||
- 2026-07-31 — mos-remediation: worker briefs forbid all git ops and restrict each worker to a single
|
||||
named output file, so two planners can share one checkout without a branch race (M2-era incident doctrine).
|
||||
All 58 findings (D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-55c + D-38c in `TASKS.md`) and every ruling with
|
||||
its rationale live there. **Not duplicated here.** The history of _why_ this board must not restate —
|
||||
six stale copies across two seams — is rolled verbatim into [`BOARD-LEDGER.md`](./BOARD-LEDGER.md).
|
||||
|
||||
@@ -25,8 +25,18 @@ mechanically until Build 3 (rotation) makes it automatic.
|
||||
## Standing invariants (never violate)
|
||||
|
||||
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
|
||||
- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff;
|
||||
CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity.
|
||||
- **Delivery gates — REFERENCE the canonical files, never restate them:**
|
||||
`~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) and
|
||||
`~/.config/mosaic/fleet/roles/validator.md` (validator role). Order:
|
||||
independent review (author ≠ reviewer, `rev-974`; pre-registered diff-blind checks committed before the
|
||||
diff is read) → remediation → **CI terminal-green at the exact head by full step scan** →
|
||||
**merge-gate verdict `GO`/`NO-GO`/`HOLD`**, commit-bound and **void the instant the head moves**, posted
|
||||
durably with enumerated evidence under its own minted identity → **coordinator head-pinned merge**.
|
||||
The queue guard runs but is **zero-information until RM-03 lands** (D-23) and must not be cited as evidence.
|
||||
**After a `GO`, freeze pushes** — even a doc tweak voids the verdict. The coordinator assigns the gate seat.
|
||||
- **Query for refutation, never for confirmation.** A subordinate asked to confirm a hypothesis will
|
||||
agree — the bias is in the question, not the answerer, and agent seats are agreeable by construction.
|
||||
State the hypothesis as yours, ask for the evidence that KILLS it, and reproduce when it matters.
|
||||
- **Dogfooding:** every fix validated against its live seed case (MISSION.md lists them).
|
||||
- **Tracking → DB** (hard cutover); do NOT re-invest in flat-file tracking. jarvis-brain PDA is off-limits.
|
||||
- **Git identity:** export `MOSAIC_GIT_IDENTITY=<your-seat>` so wrappers author correctly and survive respawn.
|
||||
|
||||
+143
-2
@@ -91,6 +91,99 @@ gate/program; the LLM handles only genuine judgment.
|
||||
> seat in the loop. Residual risk bound to **RM-59** (`depends_on: RM-12, RM-21, RM-25`), where the
|
||||
> choke-point executor and spine verify from _outside_ the worktree's authority.
|
||||
|
||||
### First-class principle — query for refutation, never for confirmation
|
||||
|
||||
> **A subordinate asked to confirm a hypothesis will agree. Ask it to refute, with evidence.**
|
||||
>
|
||||
> The bias is induced by the **query**, not by the answerer's diligence. _"The DB flaked — please
|
||||
> confirm"_ and _"confirm or refute this, with the log line that proves it"_ are different instruments,
|
||||
> and they return different answers to the same question. The first harvests agreement; only the second
|
||||
> can return **"you are wrong, and here is why."**
|
||||
>
|
||||
> This matters most with agent subordinates, which are **agreeable by construction**: fluent, eager to
|
||||
> be useful, and structurally disinclined to tell the dispatcher their premise is false. A confirmation
|
||||
> query aimed at one is close to a guaranteed yes — so the discipline cannot rest on the answerer being
|
||||
> rigorous. **It has to be built into how the question is asked.**
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-08-01). Origin: the orchestrator hypothesised that a coincident
|
||||
> `ci-postgres` failure caused a CI test failure and asked the implementing seat to **confirm or refute**
|
||||
> it. The seat **refuted it** with the log (`ci-postgres:5432 - accepting connections`, migrations
|
||||
> completed) and identified the real cause. Reproduction on an identical head then settled it. Had the
|
||||
> query been phrased for confirmation, the agreement would have been returned, **D-21 would have been
|
||||
> re-classified on a false premise**, and a banked finding would have been silently corrupted.
|
||||
>
|
||||
> **Operationally:** state your hypothesis explicitly, mark it as yours, ask for _evidence that kills
|
||||
> it_, and say what evidence would change your mind. A hypothesis you cannot describe how to falsify is
|
||||
> not yet a hypothesis. Where the answer is consequential, **reproduce** rather than accept — two
|
||||
> independent runs beat one confident report.
|
||||
|
||||
### First-class principle — the anchor must live outside the audited party's authority
|
||||
|
||||
> **You cannot fix "the author controls X" by deriving X from something the author ALSO controls.**
|
||||
> Deriving merely **moves** the control point; it does not remove it.
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-08-01) on the **THIRD INDEPENDENT ARRIVAL** of the same
|
||||
> conclusion, each reached while trying to ship something else, each from a different direction:
|
||||
>
|
||||
> | arrival | the audited party controls… | found as |
|
||||
> | ----------------- | ------------------------------------------------------------------- | -------- |
|
||||
> | manifest | the tree that certifies its own generated state (same-UID, CWE-345) | **D-19** |
|
||||
> | sandbox | the code that enters the sandbox, before the boundary exists | **D-25** |
|
||||
> | **registry seam** | **WHEN the tracked path is introduced, hence the derived boundary** | **D-45** |
|
||||
>
|
||||
> Round 1 the value was an author-settable **field**, so it was **derived**. Round 2 the derivation
|
||||
> depended on **when the author introduces the path**. Same authority, new costume. **Three
|
||||
> impossibility-derivations of one conclusion is not a coincidence to note — it is the strongest
|
||||
> architectural evidence this mission has produced**, and it is precisely what **forces Builds 1–2**
|
||||
> rather than making them a preference.
|
||||
>
|
||||
> **Operationally:** anchor to a reference the audited party cannot move. A git **merge-base against
|
||||
> `main`** is such a reference for a PR author (they own their branch; they do not own `main`).
|
||||
> **State the bootstrap in BOTH directions (D-19):** that anchor is sound against an author who cannot
|
||||
> rewrite `main` — the threat in scope — and **NOT** sound against an attacker who can. **That residual
|
||||
> is what Builds 1–2 close, and it must be recorded as a tracked dependency, never implied.**
|
||||
|
||||
### First-class principle — no universally-quantified check may pass over an empty set
|
||||
|
||||
> **"All registered cases ran" is VACUOUSLY TRUE when there are no registered cases.**
|
||||
> **Non-emptiness and anchoring are PRECONDITIONS asserted before the quantified check runs — not
|
||||
> properties hoped for after it.**
|
||||
>
|
||||
> Promoted by Mos (2026-08-01) after the identical vacuity appeared **twice, at two different levels**:
|
||||
> `activationCommit = HEAD` emptied the **commit range** (D-44), and an emptied manifest — `criteria`,
|
||||
> `gates`, `proseClaims`, `compatibilityScenarios` all `[]` — emptied the **registry population**
|
||||
> (D-46), each yielding **exit 0**. The first was fixed **as an instance**; the principle was never
|
||||
> extracted, **so it returned one level up.**
|
||||
>
|
||||
> **Delete every gate and every criterion TOGETHER and no remaining reference complains — because every
|
||||
> reference went with them.** A check that quantifies over a population must actually **range** over it,
|
||||
> and that population must be **provably complete and non-empty**.
|
||||
>
|
||||
> **Corollary (same disease):** a clause written for the instance that produced it is not a clause.
|
||||
> **Do not relabel the originating instances as the general clauses** — quantify over the population.
|
||||
|
||||
### First-class principle — redundant observation on evidence-bearing steps
|
||||
|
||||
> **Two observers of the same evidence, disagreeing, catch what neither catches alone.** Apply redundancy
|
||||
> not only to judgement calls but to **evidence gathering itself** — the step everyone assumes is
|
||||
> mechanical and therefore skips.
|
||||
>
|
||||
> Promoted by Mos (2026-08-01) from **D-33**. A seat scanned a pipeline with `-f json` and reported 9
|
||||
> steps; the orchestrator scanned the same pipeline in the wrapper's default text mode and reported 8.
|
||||
> **The default output omits `clone`.** Every "full step scan" that night had been 8-of-9 and was stated
|
||||
> as complete in good faith. No verdict changed — but the _method_ was wrong, invisibly, and **only the
|
||||
> disagreement between two counts surfaced it.**
|
||||
>
|
||||
> The reason it survived: **a summary that resembles an enumeration is more dangerous than one that
|
||||
> obviously summarises.** A labelled list of named steps with states _looks_ like the artifact, so nobody
|
||||
> checks it against the record. Compare D-24 — `mergeable` was a _true answer to a different question_;
|
||||
> this was a _true answer to a smaller one_. Neither is a lie; both pass every sniff test.
|
||||
>
|
||||
> **Operationally:** where a step _produces evidence a decision rests on_, have it produced twice by
|
||||
> different means, and treat **any divergence as a finding rather than as noise to reconcile**. Prefer the
|
||||
> machine-readable record over the human-readable rendering — _read the artifact, not the summary_ — and
|
||||
> state the counts observed so a divergence is detectable at all.
|
||||
|
||||
## Decision record (authoritative, immutable)
|
||||
|
||||
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
||||
@@ -109,6 +202,31 @@ gate/program; the LLM handles only genuine judgment.
|
||||
| **4. Comms service** | AUTHORITY, INBOX (+ versioning roadmap) | Envelope (comms/v1) → sole-path service → PG/Redis → pluggable adapters (tmux→Matrix/Discord/Slack/Telegram). Version the protocol, not participants. |
|
||||
| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 1–4 hold. |
|
||||
|
||||
## Why Builds 1–2 are necessary — two independent impossibility proofs
|
||||
|
||||
**The choke-point executor and PG spine are not a design preference. They are forced.** Twice during
|
||||
the mission's own first deliveries, work stopped against a security property that **cannot exist** at
|
||||
the layer that needed it — and both times the only resolution was an authority _outside_ the audited
|
||||
party's control, which is precisely what Builds 1–2 provide.
|
||||
|
||||
| | the audited party controls… | so what fails | found as |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- | -------- |
|
||||
| **Artifact integrity** | the manifest that certifies its own generated tree (same-UID write ⇒ regenerate manifest _and_ marker consistently, CWE-345) | tamper-evidence | **D-19** |
|
||||
| **Execution integrity** | the code that enters the sandbox (PR-controlled config executes _before_ the boundary exists) | isolation | **D-25** |
|
||||
|
||||
Both reduce to one sentence:
|
||||
|
||||
> **Self-verification by the audited party is not verification.**
|
||||
|
||||
And to one consequence: **the anchor must live outside the audited party's authority.** A local check
|
||||
cannot defend against an actor who can rewrite the check; a repo cannot grant a capability to
|
||||
PR-controlled config and simultaneously prevent that config from using it.
|
||||
|
||||
**An architecture forced by two independent impossibility proofs is stronger evidence than one argued
|
||||
for.** Neither proof was sought — both arrived while trying to ship something else, from different
|
||||
directions (a symlink manifest; a CI sandbox), at different layers. RM-59 and RM-60 are the two tracked
|
||||
dependencies this creates, and they are the same dependency in different clothes.
|
||||
|
||||
## The finding that sets the cost
|
||||
|
||||
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
|
||||
@@ -161,6 +279,29 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
|
||||
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
|
||||
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
|
||||
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
|
||||
- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed
|
||||
before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer.
|
||||
- **Delivery gates — REFERENCE, do not restate.** The authoritative definitions live at
|
||||
[`~/.config/mosaic/fleet/roles.local/merge-gate.md`](file:///home/hermes/.config/mosaic/fleet/roles.local/merge-gate.md)
|
||||
(verdict authority) and
|
||||
[`~/.config/mosaic/fleet/roles/validator.md`](file:///home/hermes/.config/mosaic/fleet/roles/validator.md)
|
||||
(validator/certificate role). **Read them; do not paraphrase them.** Restating an authoritative source
|
||||
is lossy every time — see D-26, where a subset restated from memory dropped a security precondition.
|
||||
|
||||
**Gate order** (the sequence only; the definitions are in the files above):
|
||||
1. Independent review, **author ≠ reviewer** (`rev-974` on mosaicstack), with PRE-REGISTERED diff-blind
|
||||
acceptance checks committed before the diff is read
|
||||
2. Remediation of findings
|
||||
3. **CI terminal-green** at the exact full-40 head, by **full step scan**
|
||||
4. **Merge-gate verdict — `GO` / `NO-GO` / `HOLD`** (class `merge-gate`; "Ultron" is an _instance name_,
|
||||
display data, never an authority source). **Bound to a commit and VOID the instant the head moves.**
|
||||
`HOLD` persists until replaced; a `NO-GO` answered by an empty commit must be re-issued as `NO-GO`.
|
||||
Posted durably on the PR under the gate's own minted identity, **enumerating** its evidence — a bare
|
||||
"GO — gates verified" is non-conforming.
|
||||
5. **Coordinator merge**, head-pinned. The gate never merges; it holds `push=False` by design.
|
||||
|
||||
⚠ **The CI queue guard runs but is ZERO-INFORMATION until RM-03 lands** (D-23: it returns pass for every
|
||||
possible input). It must not be cited as evidence by any gate, including the coordinator's own merge path.
|
||||
|
||||
**Assignment:** the coordinator assigns the merge-gate seat; the orchestrator does not. The orchestrator
|
||||
owns getting a PR _gate-ready_.
|
||||
|
||||
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
|
||||
|
||||
+2090
-12
File diff suppressed because it is too large
Load Diff
@@ -1,118 +0,0 @@
|
||||
# RM-61 — CI contract exemption for #1000 teardown artifact
|
||||
|
||||
**Tracking:** RM-61 / issue #1000
|
||||
|
||||
**Branch:** `fix/rm-61-ci-contract-exemption`
|
||||
**Owner:** `coder-mos1`
|
||||
|
||||
## Objective
|
||||
|
||||
Determine, by red-first provider controls, whether the `ci-postgres` pod-not-found teardown signature discriminates from a real PostgreSQL failure. Only if it discriminates may a named, bounded CI-contract exemption be implemented. The exemption must retire when #1000 is fixed; fixing #1000 is the closure path.
|
||||
|
||||
## Pre-registered kill criterion
|
||||
|
||||
If an injected real `ci-postgres` failure also yields `pods "wp-svc-<ULID>-ci-postgres" not found` as the service's provider-visible failure, the signature does not discriminate. Option B is unsafe; stop exemption implementation and fall to Option A (#1000).
|
||||
|
||||
## Plan
|
||||
|
||||
1. Capture full `-f json` records for the 11 supplied observations and state counts.
|
||||
2. Run one startup-failure control using the real pgvector/PostgreSQL image with an invalid `initdb` argument.
|
||||
3. Run one post-readiness crash control using real PostgreSQL, `pg_isready`, and a deliberate postmaster kill while a DB-dependent probe is active.
|
||||
4. Compare the raw `ci-postgres` service record independently of failures in dependent steps.
|
||||
5. Investigate runner/time/head clustering only as a hypothesis; never encode incidental correlates or retries into policy.
|
||||
6. If and only if the controls discriminate, implement and test the exact exemption, document its two-way boundary, and track retirement at #1000.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token cap supplied. Working estimate: 20K–30K tokens. Limit provider controls to the two pre-registered runs; no retries or re-roll policy.
|
||||
|
||||
## Initial evidence
|
||||
|
||||
Historical JSON saved locally under `.evidence/rm-61/` (not for commit). Supplied pipelines: 11 total. Child-step counts: five pipelines with 9 children and six with 10 children. Seven contain the `ci-postgres` pod-not-found failure (#2170, #2175, #2180, #2181, #2182, #2187, #2188); four do not (#2158, #2167, #2184, #2186). Every observed workflow reports `agent_id=44`, so the available JSON does not separate clean and artifact runs by runner. This refutes runner identity as a discriminator in the sampled record.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Requirements and kill criterion recorded before control implementation.
|
||||
- [x] Historical full-JSON records captured.
|
||||
- [x] Startup-failure control observed terminal.
|
||||
- [x] Post-readiness crash control observed terminal.
|
||||
- [x] Discrimination verdict recorded: Option B may proceed.
|
||||
- [x] Conditional exemption implementation.
|
||||
|
||||
## Tests / evidence
|
||||
|
||||
### Control 1 — real startup failure
|
||||
|
||||
- Commit: `3931b0e29eb834914f7b17e4db7e221481d436fa`
|
||||
- Pipeline: #2189, exact commit match.
|
||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||
- `ci-postgres`: `state=failure`, `exit_code=1`, `error=null`, with a five-second execution window.
|
||||
- `test`: `state=failure`, `exit_code=1` after the readiness budget expired.
|
||||
- Pipeline/workflow: terminal `failure`.
|
||||
|
||||
This control is red and its service record differs from #1000 (`exit_code=0` plus pod-not-found). It proves the startup-failure direction only. It does not settle the dangerous post-readiness crash/garbage-collection path.
|
||||
|
||||
### Control 2 — real post-readiness crash
|
||||
|
||||
- Commit: `25ac59715a94dd1b52ef42577472eb44ecc4b446`
|
||||
- Pipeline: #2191, exact commit match.
|
||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||
- Service log proves PostgreSQL reached `database system is ready to accept connections`, the test created the arm table, and the service then killed postmaster PID 7.
|
||||
- Test log proves a successful `SELECT 1` followed by `Connection refused`; it exited the pre-registered control code 61.
|
||||
- `ci-postgres`: `state=failure`, `exit_code=137`, `error=null`, with a 203-second execution window.
|
||||
- `test`: `state=failure`, `exit_code=61`.
|
||||
- Pipeline/workflow: terminal `failure`.
|
||||
|
||||
This is the dangerous post-readiness crash path. Its service record is not pod-not-found and therefore differs from #1000 independently of the dependent test failure.
|
||||
|
||||
### Discrimination verdict
|
||||
|
||||
Both real failures are provider-visible as process exits (`exit_code=1` startup; `exit_code=137` crash) with no pod-not-found error. The seven observed #1000 artifacts are provider reconciliation misses (`exit_code=0` plus the exact pod-not-found error). The declared kill criterion did not fire, so Option B may proceed with a matcher requiring the full conjunction. This evidence does **not** prove every future Kubernetes failure is distinguishable; it proves these two concrete real-failure classes remain blocking and bounds the exemption to the observed reconciliation shape.
|
||||
|
||||
### Unit red-first checkpoint
|
||||
|
||||
The nine-case contract harness was written before the verifier. First execution exited 1 because `verify-terminal-green.py` did not exist; no exemption implementation was live. Cases pre-register ordinary green, the exact artifact, both provider controls, near-miss signatures, an independent failure, and a skipped step.
|
||||
|
||||
### Control 2 setup attempt — invalid, excluded from evidence
|
||||
|
||||
- Commit: `9455cd6a2650b2b7e70f746c07933d96e5cb3d20`
|
||||
- Pipeline: #2190, exact commit match.
|
||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||
- Service log: `/bin/sh: 0: -c requires an argument`.
|
||||
- Root cause: Woodpecker service `commands` did not become the third `sh -c` argument. PostgreSQL never started, so this run is **not** the post-readiness crash control and provides no discrimination evidence.
|
||||
- Focused remediation: place the script directly in the third `entrypoint` element and supply `PGPASSWORD` for the marker query. This is a control-fixture correction, not a retry of #1000 and not evidence for either verdict.
|
||||
|
||||
## Implementation evidence
|
||||
|
||||
- `verify-terminal-green.py` consumes only the full JSON/API record; it performs no fetch, retry, or trigger.
|
||||
- Exact #2188 record: exit 0, 10 children, 9 success + 1 named exemption.
|
||||
- Historical set: #2158/#2167/#2184/#2186 pass with no exemption; #2170/#2175/#2182/#2187/#2188 pass with one named exemption; #2180/#2181 remain red because independent failures exist.
|
||||
- Provider controls: #2189 and #2191 both exit 1 under the verifier; neither is exempted.
|
||||
- Unit harness: initial 9/9 cases passed after the red-first checkpoint; review remediation expands this to 12 cases with expected-head match/missing/mismatch coverage.
|
||||
- Test-membership guard: PASS, population 45; 26 enumerated, 19 signed exclusions; all 39 surface paths present.
|
||||
- Python compile: PASS.
|
||||
- `pnpm typecheck`: PASS, 45/45 tasks.
|
||||
- `pnpm lint`: PASS, 25/25 tasks.
|
||||
- `pnpm format:check`: PASS after moving local evidence outside the repository tree.
|
||||
- `test:framework-shell`: RM-61 and all preceding suites passed, then the pre-existing wake assertion aborted with exit 97 because this host's Bash 5.2.15 reports `BASH_LINENO [3 5]` where that suite requires `[3 4]`. RM-61 does not modify the wake suite; the command is not fully runnable on this host as written and no substitute result is claimed.
|
||||
|
||||
## Independent review
|
||||
|
||||
- Review 67 / comment 20403 at exact head `e7b29219e11efd0a19395156ac0b154bec0c3a73`: **REQUEST CHANGES**.
|
||||
- Blocker: the verifier echoed the pipeline commit but did not bind it to the current PR head; mutating only #2188's commit still returned terminal-green.
|
||||
- Remediation: require `--expect-commit <full-40>`, add a pipeline anomaly on missing/mismatched record commits, emit expected and observed values, wire both CI documentation and the merge-gate baseline to pass provider PR head, and add match/missing/mismatch tests.
|
||||
- This binding is not prohibited head-based clustering policy: it proves the evidence belongs to the commit under verdict. Runner/node/time/head correlation remains excluded from the teardown signature itself.
|
||||
- Review 69 later approved the commit-binding remediation at exact head `033b2ffb46674b2c0bcc5197273c109b461f62d9`; pipeline #2193 was 9/9 success. Before merge-gate, an independent adjudicator found that Python treats JSON `false == 0`, allowing a non-integer exit value to match. The prior gate-ready state was withdrawn. The type-strict set distinguishes genuine red-first controls (`false`, `0.0`, which wrongly exempted) from regression guards (`true`, `"0"`, `null`, which already blocked). Remediation requires the decoded type to be exactly `int` and excludes `bool` explicitly.
|
||||
|
||||
## Documentation checklist
|
||||
|
||||
- [x] CI contract documented in the canonical framework CI/CD guide.
|
||||
- [x] Operator command documented in the Woodpecker tool README.
|
||||
- [x] Merge-gate baseline points to the deterministic verifier and named retirement.
|
||||
- [x] Tracking and retirement cite issue #1000.
|
||||
- [x] Both positive and negative guarantee boundaries are stated.
|
||||
- [x] No API/auth/schema/user-facing navigation change; OpenAPI, user guide, and sitemap are not applicable.
|
||||
|
||||
## Risks
|
||||
|
||||
The controls establish discrimination for deterministic startup failure and an armed post-readiness postmaster crash on the current Woodpecker Kubernetes provider. They cannot prove that every future Kubernetes failure mode will preserve a non-zero exit before reconciliation. The exact matcher minimizes that residual risk, and issue #1000 remains the mandatory provider-seam closure and retirement trigger.
|
||||
@@ -1,71 +0,0 @@
|
||||
# #1019 — Zero-timeout queue-guard harness race
|
||||
|
||||
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
||||
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
||||
- **Owner:** `be-coder-08`
|
||||
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
||||
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
||||
|
||||
## Objective
|
||||
|
||||
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
||||
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
||||
2. Every case that intends status classification positively proves provider observation.
|
||||
3. Pending observes `pending` before deterministic virtual-time expiration.
|
||||
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
||||
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
||||
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
||||
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
||||
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
||||
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
||||
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
||||
|
||||
## Budget
|
||||
|
||||
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
||||
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
||||
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
||||
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
||||
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
||||
|
||||
## Review remediation — semantic timeout vs. liveness bound
|
||||
|
||||
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
||||
|
||||
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
||||
|
||||
Remediation:
|
||||
|
||||
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
||||
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
||||
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
||||
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
||||
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
||||
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
||||
|
||||
Post-review evidence:
|
||||
|
||||
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
||||
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
||||
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
||||
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
||||
|
||||
## 60% context hold
|
||||
|
||||
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
||||
@@ -1,115 +0,0 @@
|
||||
# #1051 — per-estate mosaic-brain installer
|
||||
|
||||
Last updated: 2026-08-05
|
||||
|
||||
## Objective
|
||||
|
||||
Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusions, non-destructive migration, doctor diagnostics/fixes, and credential-contract terminal-class handling. Live broker grants and live read/write round-trips remain gated on MC-CRED-01.
|
||||
|
||||
## Sources and bindings
|
||||
|
||||
- Provider issue: HOMELAB `git.mosaicstack.dev`, `GET /api/v1/repos/mosaicstack/stack/issues/1051`, `application/json;charset=utf-8`.
|
||||
- Issue requirements: R1–R8 read directly on 2026-08-05.
|
||||
- MC-CRED caller contract: v1.5, SHA-256 `4cecba3386b37431d4a075205c6dfe43555c7673922fed61b84f43cac1a6ae92` at the 2026-08-05 re-derivation. Earlier moving bindings were v1.5 `710d22d61a93a4b9c70fc55506a023a675a110417fa7a6e72dc051c0d9fe8237`, v1.4 `27f20158561ae8292f3bfc926b5e97f398de93db6a1cf65fcc215d08811d39af`/`d12ad4595b7aef078e392988a07ab5cb00244440775c9c733dc825746d7ac67b`, and v1.3 `8cfa4853d2b0b0e8cc9e792fa8411310e16d7704c06e0af9d9a57155131d8086`.
|
||||
- Fleet doctrine: SHA-256 `026b43322e0551ef15b646a9f30d3a6aef58c662a810b732be2a03b1ecf7d36e` at intake.
|
||||
- Intake base was HOMELAB provider `next` = `4df478cdd150fdf8d52ea109f02ade5d85017acd`; `main` = `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`. On 2026-08-05 `mos-claude` ruled that L0 trunk-based gate 15 requires all three lanes to retarget to `main`; `next` remains a non-merging integration branch. Never weaken or patch `pr-merge.sh`.
|
||||
|
||||
## Scope
|
||||
|
||||
### In now
|
||||
|
||||
- R2/Q1 target-host estate derivation using one registry.
|
||||
- R5 both-axis refusal parity and disagreement failure.
|
||||
- R6 exact secret exclusions and no secret-bearing diagnostics.
|
||||
- R7 detection plus non-destructive, collision-safe migration/reporting.
|
||||
- R8 doctor checks and approved-seam fix orchestration.
|
||||
- Red-first tests over all four contract terminal classes and stable reason codes.
|
||||
|
||||
### Gated / excluded
|
||||
|
||||
- R3 live grant: waits for working MC-CRED-01.
|
||||
- R4 live seat-owned read/write round-trip: waits for working MC-CRED-01.
|
||||
- No independent token lookup, grant helper, or shared-credential fallback.
|
||||
- No phase renumbering; C1 owns the phase machine and provides the P5→P7 seam.
|
||||
- No age/size reaping or deletion.
|
||||
|
||||
## Owner authority ruling and resolver seam
|
||||
|
||||
- Binding addendum: `/home/hermes/agent-work/tl-mosaic/CHARTER-MB-BRAIN-01-ADDENDUM.md`; re-read after compaction.
|
||||
- HOMELAB durable lane-archive owner and user-namespace brain owner are the human provider account selected by local estate policy (operator ruling: `jason.woltje`) with a required GLPI queue as the standing remediation process. The brain target is therefore `<policy-owner>/mosaic-brain` on the estate host, not `<installer-source-org>/mosaic-brain`. Framework source remains operator-agnostic: the actual login and queue are local policy, not hardcoded open-source context.
|
||||
- Provider lookup is anonymous because the ruled owner is public. It requires exact allowlisted login plus a same-invocation public known-good control, private 404 control, and generated absent 404 control. It sends no Authorization header and never widens token scope.
|
||||
- Provider `active` is deliberately ignored: non-admin reads return false for demonstrably active accounts. Resolvability + exact login + public visibility are the gate.
|
||||
- Private and absent principals both return anonymous 404. The fail-closed reason is `owner-not-resolvable`, never owner-not-found.
|
||||
- Caller `owner` strings and `validated=true` are ignored. Migration consumes only an injected source-of-truth resolver result. Owner grammar is NFKC-stable, ASCII allowlisted, exact-policy matched, and mission-seat class is excluded.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Pre-register acceptance tests and observe each requirement RED for its own missing behavior.
|
||||
2. Commit the red tests before implementation.
|
||||
3. Implement a narrow brain provisioning/doctor helper that consumes broker JSON outcomes and the shared estate registry without credential resolution.
|
||||
4. Implement safe migration and exact brain skeleton/ignore policy.
|
||||
5. Integrate the helper into C1's P7 seam and `mosaic doctor` after C1 lands/rebase.
|
||||
6. Run focused, package, installer, lint, typecheck, format, and situational security tests.
|
||||
7. Run independent code and security reviews in parallel; remediate and re-review.
|
||||
8. Push after HOMELAB queue guard, open the reviewed PR to `main`, and preserve merge order C1 → MC-CRED → MB-BRAIN. Do not modify the merge guard; `next` is non-merging integration only.
|
||||
9. Re-take CI measurement at the rebased exact head; do not rework code solely because base evidence moved.
|
||||
|
||||
## Acceptance interpretation registered before results
|
||||
|
||||
- `ok/0`: complete authoritative evidence only.
|
||||
- `refused/10`: complete authoritative denial only.
|
||||
- `error/20`: local contract/control failure; never reinterpret as denial.
|
||||
- `indeterminate/30`: incomplete/disagreeing evidence; fail closed, never resolve permissively.
|
||||
- Both Git and API axes must return authoritative `refused` with the same stable reason code for R5. Any axis disagreement is `indeterminate`. A provider `/user` login mismatch is first-class `provider-identity-mismatch`; credential filenames never establish principal identity.
|
||||
- Migration publication requires the durable object to contain the approved snapshot and no overwrite. Automatic path-based source deletion is parked; every source is retained and reported.
|
||||
- Secret exclusion is tested through exact ignore rules, nested secret-shaped paths, bounded UTF-8 content controls, and an approved-scanner gate bound to the exact source snapshot. Without an approved scanner, even benign content is retained and reported rather than committed.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token ceiling was supplied. Working cap: 55K tokens for implementation/review and 3 focused remediation attempts per failure class. Reduce optional refactoring and documentation breadth before touching required acceptance scope.
|
||||
|
||||
## Risks
|
||||
|
||||
- C1 and MC-CRED branches have not merged into `main`; integration edits must wait for their exact interfaces or be confined to stable contract seams.
|
||||
- A broker runtime test before MC-CRED lands would either fail for an irrelevant reason or pressure a hand-rolled workaround; contract fixtures are allowed, live capability claims are not.
|
||||
- Migration can lose data through overwrite, cross-device move failure, or partial copy. Implementation must stage, verify resulting bytes, and retain/report source on incomplete transfer.
|
||||
- `~/.mosaic` is a git repo, while current working state may live under multiple local roots; detection must be explicit and cannot treat age/size as ownership.
|
||||
|
||||
## Progress / evidence
|
||||
|
||||
- [x] Charter receipt accepted by `tl-mosaic`.
|
||||
- [x] Issue #1051 R1–R8 read directly from provider.
|
||||
- [x] Contract re-derived through v1.3, moving v1.4, and v1.5 before R5 integration. v1.5 separates in-scope repository capability from `/user` identity measurement: 401 is `credential-rejected`/refused, 403/404 may become `identity-not-measured` only after in-scope capability succeeds, and 200 login mismatch is refused. `identity-not-found` is not reachable from `validate`.
|
||||
- [x] C1 P5→P7 seam receipt read; no brain implementation is in C1.
|
||||
- [x] RED acceptance set committed at `cf11c6c86abae073d8b02b4014cd5447ba67f12a`; author and committer read back as `be-coder-07` and branch reachability was independently verified by `tl-mosaic`.
|
||||
- [x] Moving-contract REDs observed independently for v1.4 mismatch, R8 prerequisite ordering, owner resolver seam/allowlist, tracked skeleton/no-follow behavior, runtime observation/publication, and provider owner resolution.
|
||||
- [x] Focused implementation includes secure migration, v1.5 write-differential/subject binding, production Git+API refusal parity, provider-backed durable owner resolution that ignores non-admin `active`, required GLPI standing-process policy, P7 provision orchestration, an internal installer command, and installed `mosaic doctor` wiring. Latest focused result: 97/97 (secure config 4, store 45, runtime 19, owner resolver 16, provision 5, provision command 3, installed doctor 5).
|
||||
- [x] MC-CRED added the required canonical reverse registry seam `ParsedCredentialEstateRegistry.resolveByHost()`; the 32-line permissive shim was removed. After exact-head CI proved the cross-PR source dependency was absent, the provider-fetched canonical registry implementation, DTO dependencies, and registry tests were tracked byte-for-byte on this branch so a fresh checkout validates the real seam rather than a stub. A later rebase onto merged MC-CRED should recognize those identical files as upstream.
|
||||
- [x] Identity gotcha measured: inline `MOSAIC_GIT_IDENTITY=be-coder-07` controls credential resolution but does not override `user.name`/`user.email` inherited from the linked worktree common-dir config (`coder-mos1`). The first local P7 RED commit was immediately amended before push with command-scoped `GIT_AUTHOR_*` + `GIT_COMMITTER_*`; resulting author and committer both read back as `be-coder-07`. Every subsequent authoring command must carry both identity sets and be verified.
|
||||
- [x] R6 migration reports filename- or content-secret-shaped files without copying them; arbitrary legacy content requires an approved scanner bound to the exact source snapshot, and production currently retains/reports when no approved scanner is configured. `.gitignore` is canonical allowlisted content only: an existing noncanonical regular file fails closed and is never merged into publication. Symlinked `.gitignore`, layout directories, and nested migration destinations fail closed; a dirty checkout blocks provisioning before skeleton publication. The brain root is principal-owned mode `0700` before clone and after clone, all memory-bearing layout directories are mode `0700` even under umask `0022`, and doctor reports owner-accessible roots as hard unsafe findings.
|
||||
- [x] Provider owner lookup uses manual redirect handling, a five-second abort signal, strict JSON content type/shape, and an incrementally enforced 256 KiB response ceiling.
|
||||
- [x] Security-critical owner policy/registry reads have direct controls for principal UID ownership, file/ancestor permissions, and descriptor-safe regular-file reads.
|
||||
- [x] Automatic source deletion is parked per the shared-Git-identity governance ruling; remotely reachable snapshots still leave and report every source.
|
||||
- [x] Multi-host push-on-write uses an isolated temporary Git index populated from approved in-memory blobs rather than pathname re-reads, verifies each committed blob ID, the exact changed-path allowlist, and both author/committer trailers before push, then reconciles only approved paths into the real checkout index. Real-repository controls prove a clean checkout remains clean, a concurrent non-fast-forward fetch/rebase/push remains clean and preserves both findings, destination-path substitution cannot change committed bytes, and unrelated pre-staged secret-shaped content remains staged but never enters the published commit.
|
||||
- [x] Doctor Git observations preserve three states: `clean`, `dirty`, and `unmeasurable`; failed remote, branch, or status measurements emit hard `brain-git-state-indeterminate` findings rather than mismatch or ready. The boolean-literal guard sweep covered all MB-BRAIN production files in the 20-file PR population: its only remaining `=== false` guard is the non-nullable `isAbsolute()` predicate; no nullable boolean measurement guards remain.
|
||||
- [x] Author-run Review 10 and focused 88/88 evidence were declared void when blocker fixes changed the head; neither is an independent gate pass.
|
||||
- [ ] Installer shell P7 invocation after C1 + MC-CRED integration; production command is registered but the C1 shell has not yet called it.
|
||||
- [ ] Implementation green on merged dependency base.
|
||||
- [ ] Independent code review.
|
||||
- [ ] Independent security review.
|
||||
- [ ] HOMELAB CI terminal green at exact head.
|
||||
- [ ] Reviewed PR retargeted to `main` after C1 and MC-CRED; `next` remains non-merging integration only.
|
||||
|
||||
## Exact-head CI dependency remediation
|
||||
|
||||
- Pipeline `#2222` at `a50b5a6b` ran the sole pull-request-eligible workflow (`ci`, 1/3 defined workflows) and failed `typecheck` with two `TS2307` errors before lint, format, or tests could execute.
|
||||
- History establishes that the imports are intentional: commit `2451c2f` introduced both consumers, while the contemporaneous registry-seam report explicitly called the local 32-line implementation a disposable scaffold and required MC-CRED's canonical parser. This was a deliberate cross-PR dependency, not a wrong import or forgotten shim add.
|
||||
- RED-first root typecheck reproduced the two missing-module errors. The fix tracks the provider-fetched MC-CRED registry, its two DTO dependencies, its unit test, and the result DTO dependency. Three DTO files remain byte-identical to `fbff4ffa`; author review found that the canonical parser accepted a trailing-slash origin which MB-BRAIN consumers concatenate into double-slash URLs, so the parser and test are intentionally hardened here pending propagation to MC-CRED.
|
||||
- R7 removed the tracked registry module and root typecheck returned RED with three missing-module errors (the two production consumers plus the registry unit test); restoring the same SHA-256 returned typecheck to 45/45 tasks.
|
||||
- With the suppressing typecheck failure removed, lint ran 25/25 tasks and formatting passed. Full tests actually ran: 1,607/1,614 passed; the seven failures are the four pre-registered P7 integration tests intentionally held for C1/MC-CRED integration plus the three previously disclosed ambient update-banner CLI smoke failures. No test was weakened. Build ran 25/25 tasks.
|
||||
- Author review's trailing-slash finding was reproduced RED (`https://git.example.invalid/` accepted), then fixed by requiring the configured source to equal `URL.origin`; the control reran GREEN. Security review reported risk `none` with zero findings; final code re-review remains required after remediation.
|
||||
- This is a second instance of the `#1068` suppression class: an early integrity failure prevented every downstream stage carrying behavioral evidence from running while the workflow's aggregate failure looked like a completed check. Workflow reordering remains `#1068` scope and is not changed here.
|
||||
|
||||
## Completion language
|
||||
|
||||
After reviewed merge to `main`, only: **believed-fixed, pending jarvis validation**. Issue #1051 remains open until W-jarvis validates the installed result.
|
||||
@@ -1,58 +0,0 @@
|
||||
# RM-01 — Reproducible checkout
|
||||
|
||||
- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking)
|
||||
- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats.
|
||||
- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation.
|
||||
- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge.
|
||||
- Acceptance: AC1–AC8 from the orchestrator dispatch/addendum.
|
||||
- Plan:
|
||||
1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation.
|
||||
2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation.
|
||||
3. Run focused tests, install/build/baseline gates, and explicit AC negative controls.
|
||||
4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`.
|
||||
- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup.
|
||||
- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification.
|
||||
|
||||
## Progress / evidence
|
||||
|
||||
- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`.
|
||||
- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`.
|
||||
- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness.
|
||||
|
||||
## Checkpoint evidence (c45e5e19)
|
||||
|
||||
- AC1 IN PROGRESS: non-root `pnpm install --frozen-lockfile --store-dir "$HOME/.local/share/pnpm/store"` exited 0; `pnpm exec turbo run typecheck --force` exited 0 (45/45 uncached). Clean CI-container run not performed.
|
||||
- AC2 DONE: with `node_modules` absent, `pnpm preflight` exited 42 with `MOSAIC_PREFLIGHT_MISSING_DEPS` and `run pnpm install`; after install it exited 0.
|
||||
- AC3 DONE: appending `export const x: number = "s"` to `packages/types/src/index.ts` made `pnpm -w typecheck` exit 2 with TS2322; reverting made it exit 0.
|
||||
- AC4 IN PROGRESS: local `pnpm -w build` exited 0 and `git status --porcelain` showed no generated residue beyond the intended RM-01 source changes. Fresh-clone proof not performed.
|
||||
- AC5 DONE: non-root install exited 0; `pnpm store path` resolved `/home/hermes/.local/share/pnpm/store/v10`; no `/root` write was attempted.
|
||||
- AC6 IN PROGRESS: focused failure/rollback tests passed, but final review found a concurrent-install race. Two installers can both observe `.husky/_` absent; after one installs successfully, the losing install's catch path can quarantine the winner's active hooks and restore stale Git config (`scripts/install-hooks.mjs`, activation/catch transaction). A RED regression is committed after the checkpoint.
|
||||
- AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially.
|
||||
- AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree.
|
||||
|
||||
## Continuation evidence
|
||||
|
||||
- AC6 DONE: the committed race reproducer was observed RED (`node --test --test-name-pattern='a competing successful installer is not removed by the losing process' scripts/install-hooks.test.mjs`, exit 1/ENOENT), then passed after cleanup became ownership-safe. The losing installer never removes an active hook set or restores Git configuration it did not activate. `pnpm test:checkout` passes 21/21, exit 0, including the original race and a post-rename peer-replacement regression.
|
||||
- Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs.
|
||||
- Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result.
|
||||
- Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974.
|
||||
- AC1 DONE at `0f706119`: a clean clone created inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest` ran the exact acceptance sequence `pnpm install --frozen-lockfile && pnpm -w typecheck`; exit 0 with 45/45 uncached typecheck tasks successful. An earlier bind-mounted clone attempt exited 1 because root in the container rejected the host-owned Git directory; that failed attempt is not counted as evidence.
|
||||
- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0.
|
||||
- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`.
|
||||
|
||||
## Review remediation — restated AC2
|
||||
|
||||
- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation.
|
||||
- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest.
|
||||
- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted.
|
||||
- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0.
|
||||
- Threat-model ruling: the manifest detects accidental, independent, stale, and foreign-residue mutation—the class exposed by the five-month-stale `.next` that produced 19 phantom TS2307 errors. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. RM-59 tracks the residual: executor/spine-side attestation outside worktree authority, dependent on RM-12, RM-21, and RM-25.
|
||||
- AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence.
|
||||
|
||||
## Handoff
|
||||
|
||||
1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation.
|
||||
2. The latest automated review rejected oldest-generated-file mtime as a false positive for valid incremental Next output. Use a source-content fingerprint marker written only after successful `next build`; do not continue tuning mtimes.
|
||||
3. For Husky, generation in an isolated temporary Git repo avoids mutating real `core.hooksPath` during staging. Preserve that design. Fix the losing concurrent process so it never removes a peer's completed hook set or restores stale config.
|
||||
4. Codex review runs in a read-only sandbox, so its attempts to run the fixture-writing Node tests report opaque test-file failures. The same tests run normally in the worktree.
|
||||
5. Full `pnpm test` is not green on this host: it exits 97 at the pre-existing Bash `BASH_LINENO` convention guard (#1003), after the changed checkout tests and package tests pass. Do not weaken that gate.
|
||||
@@ -1,120 +0,0 @@
|
||||
# RM-03 — CI Queue Guard Repair
|
||||
|
||||
- **Task:** RM-03
|
||||
- **Issue:** #1019
|
||||
- **Branch:** `fix/rm-03-queue-guard`
|
||||
- **Owner:** coder-mos1
|
||||
- **Reviewer:** rev-974 (independent; author != reviewer)
|
||||
- **Started:** 2026-08-01
|
||||
|
||||
## Objective
|
||||
|
||||
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
|
||||
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
|
||||
- TDD is mandatory. Every behavior case must be observed red before implementation.
|
||||
- No bypass flags or hook suppression.
|
||||
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
|
||||
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
|
||||
- No merge: coordinator holds the merge hand pending Jason.
|
||||
|
||||
## Design
|
||||
|
||||
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
|
||||
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
|
||||
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
|
||||
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
|
||||
|
||||
## Test matrix
|
||||
|
||||
| Case | Required outcome |
|
||||
| --- | --- |
|
||||
| success | exit 0; terminal-success |
|
||||
| pending | nonzero after bounded timeout |
|
||||
| failure | nonzero |
|
||||
| no-status | nonzero |
|
||||
| malformed | nonzero |
|
||||
| >=150 KiB payload | unchanged classification; never rc126 |
|
||||
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
|
||||
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
|
||||
| audit unavailable | nonzero |
|
||||
| implicit push branch | provider URL uses checked-out feature branch |
|
||||
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
|
||||
|
||||
## RED-first evidence
|
||||
|
||||
Observed against the unmodified `origin/main` implementation before source edits:
|
||||
|
||||
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
|
||||
- Success payload was reported `state=unknown`.
|
||||
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
|
||||
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
|
||||
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
|
||||
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
|
||||
- Audit-unavailable emitted no audit diagnostic.
|
||||
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
|
||||
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
|
||||
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
|
||||
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
|
||||
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
|
||||
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
|
||||
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
|
||||
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
|
||||
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
|
||||
|
||||
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
|
||||
- [x] Isolated worktree created and identity configured coherently.
|
||||
- [x] Mutant tests authored and observed red.
|
||||
- [x] Implementation green.
|
||||
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
|
||||
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
|
||||
- [ ] PR CI terminal-green at exact head by full step scan.
|
||||
- [ ] Merge-gate verdict issued against frozen head.
|
||||
|
||||
## Scope disposition
|
||||
|
||||
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
|
||||
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
|
||||
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
|
||||
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
|
||||
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
|
||||
|
||||
## Review remediation
|
||||
|
||||
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
|
||||
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
|
||||
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
|
||||
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
|
||||
|
||||
## Risks / boundaries
|
||||
|
||||
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
|
||||
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
|
||||
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
|
||||
|
||||
## Test evidence
|
||||
|
||||
Fresh after rescue checkpoint `b7175012`:
|
||||
|
||||
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
|
||||
- `bash -n` on the three production shell scripts passed.
|
||||
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
|
||||
- `pnpm typecheck` passed (45/45 Turbo tasks).
|
||||
- `pnpm lint` passed (25/25 Turbo tasks).
|
||||
- `pnpm format:check` passed.
|
||||
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
|
||||
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
|
||||
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
|
||||
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
|
||||
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
|
||||
|
||||
## Final evidence
|
||||
|
||||
Pending.
|
||||
+3
-6
@@ -6,14 +6,11 @@
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"preflight": "node scripts/preflight.mjs",
|
||||
"clean:generated": "node scripts/clean-generated.mjs",
|
||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||
"test:checkout": "node --test scripts/*.test.mjs",
|
||||
"test": "pnpm test:checkout && turbo run test",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"prepare": "node scripts/install-hooks.mjs"
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
|
||||
@@ -13,14 +13,7 @@ It is a **gate** role: the one and only merge path.
|
||||
2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges
|
||||
**exclusively** by calling **`pr-merge.sh`** (the merge action, which carries the
|
||||
authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green
|
||||
CI before merging). Before issuing a verdict, scan the full JSON/API child-step
|
||||
record (including `clone`) with **`verify-terminal-green.py --expect-commit
|
||||
<current-provider-PR-head>`** and record the equal expected/observed full-40
|
||||
commits, exact step count, anomalies, and named exemptions. Missing or mismatched
|
||||
commit binding is a hard refusal. The verifier's sole interim
|
||||
exemption is `WP-K8S-1000-CI-POSTGRES-TEARDOWN`; it is signature-scoped, tracked
|
||||
by #1000, and retires when #1000 is fixed. These scripts are the _only_
|
||||
sanctioned merge path.
|
||||
CI before merging). These two scripts are the _only_ sanctioned merge path.
|
||||
3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw
|
||||
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
|
||||
and `pr-ci-wait.sh`.
|
||||
|
||||
@@ -868,38 +868,6 @@ steps:
|
||||
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
||||
8. **Verify images appear** in Gitea Packages tab after successful pipeline
|
||||
|
||||
## Terminal-Green Full-Step Contract
|
||||
|
||||
A successful pipeline summary is not sufficient: verification MUST consume the full JSON/API child-step record, including `clone`.
|
||||
|
||||
```bash
|
||||
PR_HEAD=<full-40-hex-provider-head>
|
||||
~/.config/mosaic/tools/woodpecker/pipeline-status.sh \
|
||||
-r mosaicstack/stack -n <pipeline-number> -f json \
|
||||
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py \
|
||||
--expect-commit "$PR_HEAD" -
|
||||
```
|
||||
|
||||
`PR_HEAD` MUST come from the current provider PR metadata and MUST be the full 40-hex head, not a local branch guess. The verifier fails if the argument is missing, malformed, absent from the pipeline record, or differs from that record.
|
||||
|
||||
The verifier reports the expected and observed commits, total step count, state counts, anomalies, and any applied exemption. Exit `0` means the record satisfies the contract; exit `1` means the commit binding or at least one pipeline, workflow, or child-step state blocks terminal-green; exit `2` means the invocation or JSON input could not be verified.
|
||||
|
||||
### Named interim exemption: `WP-K8S-1000-CI-POSTGRES-TEARDOWN`
|
||||
|
||||
Only this exact conjunction is exempted:
|
||||
|
||||
- pipeline and workflow state are `success`;
|
||||
- exactly one non-success child exists;
|
||||
- its name is `ci-postgres` and type is `service`;
|
||||
- its state is `failure`, exit code is the JSON integer `0` (not boolean, float, string, or null); and
|
||||
- its error exactly matches `pods "wp-svc-<ULID>-ci-postgres" not found`.
|
||||
|
||||
Every near miss remains blocking, including non-zero service exits, startup failures, post-readiness crashes, connection errors, image-pull errors, skipped steps, another failed child, malformed pod names, duplicate matches, or a non-success pipeline/workflow.
|
||||
|
||||
**Boundary in both directions:** this exemption recognizes the observed Woodpecker Kubernetes reconciliation miss after an otherwise-successful run. It does not prove that every future PostgreSQL or Kubernetes failure is distinguishable. It does prove, through provider controls, that a deterministic startup failure (`exit_code=1`) and an armed post-readiness postmaster crash (`exit_code=137`, dependent probe `Connection refused`) do not match and remain red.
|
||||
|
||||
**Tracking and retirement:** [mosaicstack/stack#1000](https://git.mosaicstack.dev/mosaicstack/stack/issues/1000) owns the provider-seam fix. This exemption MUST be removed when #1000 is fixed. It is not authority to retry or re-trigger a pipeline, and no per-PR re-roll is part of the contract.
|
||||
|
||||
## Post-Merge CI Monitoring (Hard Rule)
|
||||
|
||||
For source-code delivery, completion is not allowed at "PR opened" stage.
|
||||
@@ -925,16 +893,14 @@ Woodpecker note:
|
||||
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
|
||||
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
|
||||
- If pipeline state is running/queued/pending, wait until queue clears.
|
||||
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
||||
|
||||
## Gitea as Unified Platform
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
|
||||
- PR target for delivery is `main`.
|
||||
- Direct pushes to `main` are prohibited.
|
||||
- Merge to `main` MUST be squash-only.
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
|
||||
|
||||
## Review Checklist
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
|
||||
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. `push` - push immediately after queue guard passes.
|
||||
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
|
||||
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
|
||||
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
|
||||
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
|
||||
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
|
||||
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||
> without asking.
|
||||
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
||||
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
|
||||
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
|
||||
|
||||
@@ -425,11 +425,11 @@ git push
|
||||
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
||||
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
||||
- Before merging, run queue guard:
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||
- Merge via wrapper:
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||
- Wait for terminal CI status:
|
||||
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- Close linked issue after merge + green CI:
|
||||
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
|
||||
|
||||
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
|
||||
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
|
||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||
|
||||
## Git Scripts
|
||||
|
||||
@@ -638,9 +638,8 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
||||
|
||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
||||
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
||||
|
||||
|
||||
@@ -23,12 +23,10 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
|
||||
# Milestones
|
||||
~/.config/mosaic/tools/git/milestone-create.sh
|
||||
|
||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||
# CI queue guard (required before push/merge)
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||
```
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -88,7 +88,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -97,7 +97,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -101,7 +101,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -87,7 +87,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
+2
-2
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -84,7 +84,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
+2
-2
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -85,7 +85,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -7,9 +7,7 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
BRANCH=""
|
||||
TARGET_REPO=""
|
||||
HEAD_SHA=""
|
||||
BRANCH="main"
|
||||
TIMEOUT_SEC=900
|
||||
INTERVAL_SEC=15
|
||||
PURPOSE="merge"
|
||||
@@ -17,12 +15,10 @@ REQUIRE_STATUS=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
|
||||
Options:
|
||||
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
||||
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
|
||||
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
|
||||
-B, --branch BRANCH Branch head to inspect (default: main)
|
||||
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||
--purpose VALUE Log context: push|merge (default: merge)
|
||||
@@ -31,65 +27,63 @@ Options:
|
||||
|
||||
Examples:
|
||||
$(basename "$0")
|
||||
$(basename "$0") --purpose push -t 600 -i 10
|
||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
||||
EOF
|
||||
}
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
|
||||
get_state_from_status_json() {
|
||||
# Python source comes from -c so the provider payload remains on stdin.
|
||||
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
|
||||
python3 -c '
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("status payload is not an object")
|
||||
except Exception:
|
||||
print("malformed")
|
||||
print("unknown")
|
||||
raise SystemExit(0)
|
||||
|
||||
raw_statuses = payload.get("statuses", [])
|
||||
raw_state = payload.get("state", "")
|
||||
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
statuses = raw_statuses
|
||||
state = raw_state.lower()
|
||||
statuses = payload.get("statuses") or []
|
||||
state = (payload.get("state") or "").lower()
|
||||
|
||||
pending_values = {"pending", "queued", "running", "waiting"}
|
||||
failure_values = {"failure", "error", "failed"}
|
||||
success_values = {"success"}
|
||||
|
||||
if state in pending_values:
|
||||
print("pending")
|
||||
raise SystemExit(0)
|
||||
if state in failure_values:
|
||||
print("terminal-failure")
|
||||
raise SystemExit(0)
|
||||
if state in success_values:
|
||||
print("terminal-success")
|
||||
raise SystemExit(0)
|
||||
|
||||
values = []
|
||||
for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
raw_value = item.get("status") or item.get("state")
|
||||
if not isinstance(raw_value, str) or not raw_value:
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
values.append(raw_value.lower())
|
||||
continue
|
||||
value = (item.get("status") or item.get("state") or "").lower()
|
||||
if value:
|
||||
values.append(value)
|
||||
|
||||
if any(value in pending_values for value in values) or state in pending_values:
|
||||
print("pending")
|
||||
elif any(value in failure_values for value in values) or state in failure_values:
|
||||
print("terminal-failure")
|
||||
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
||||
print("terminal-success")
|
||||
elif not values:
|
||||
if not values and not state:
|
||||
print("no-status")
|
||||
elif any(v in pending_values for v in values):
|
||||
print("pending")
|
||||
elif any(v in failure_values for v in values):
|
||||
print("terminal-failure")
|
||||
elif values and all(v in success_values for v in values):
|
||||
print("terminal-success")
|
||||
else:
|
||||
print("unknown")
|
||||
'
|
||||
PY
|
||||
}
|
||||
|
||||
print_pending_contexts() {
|
||||
python3 -c '
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -110,61 +104,17 @@ for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("context") or item.get("name") or "unknown-context"
|
||||
value = str(item.get("status") or item.get("state") or "unknown").lower()
|
||||
value = (item.get("status") or item.get("state") or "unknown").lower()
|
||||
target = item.get("target_url") or item.get("url") or ""
|
||||
if value in pending_values:
|
||||
found = True
|
||||
suffix = f" ({target})" if target else ""
|
||||
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||
if target:
|
||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
||||
else:
|
||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
||||
if not found:
|
||||
print("[ci-queue-wait] no pending contexts")
|
||||
'
|
||||
}
|
||||
|
||||
record_cannot_assert() {
|
||||
local reason="$1"
|
||||
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
|
||||
|
||||
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
|
||||
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path, reason, platform, purpose, branch, repo = sys.argv[1:]
|
||||
record = {
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"outcome": "CANNOT_ASSERT",
|
||||
"reason": reason,
|
||||
"platform": platform,
|
||||
"purpose": purpose,
|
||||
"disposition": "hold" if purpose == "merge" else "degraded-pass",
|
||||
"branch": branch,
|
||||
"repo": repo,
|
||||
}
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
PY
|
||||
then
|
||||
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if [[ "$PURPOSE" == "merge" ]]; then
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
|
||||
return 75
|
||||
fi
|
||||
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
github_get_branch_head_sha() {
|
||||
@@ -178,87 +128,7 @@ github_get_commit_status_json() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local sha="$3"
|
||||
local work_root status_file checks_file
|
||||
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
|
||||
mkdir -p "$work_root" || return 1
|
||||
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
|
||||
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
|
||||
rm -f "$status_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
|
||||
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
|
||||
rm -f "$status_file" "$checks_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - "$status_file" "$checks_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
status_pages = json.load(handle)
|
||||
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||
check_pages = json.load(handle)
|
||||
|
||||
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
|
||||
raise SystemExit(1)
|
||||
|
||||
# The statuses endpoint is newest-first and can contain retries for one context.
|
||||
# Keep only the newest entry per context after flattening every page.
|
||||
combined = []
|
||||
seen_contexts = set()
|
||||
for page in status_pages:
|
||||
if not isinstance(page, list):
|
||||
raise SystemExit(1)
|
||||
for status in page:
|
||||
if not isinstance(status, dict):
|
||||
raise SystemExit(1)
|
||||
context = status.get("context")
|
||||
if not isinstance(context, str) or not context or context in seen_contexts:
|
||||
continue
|
||||
seen_contexts.add(context)
|
||||
combined.append(status)
|
||||
|
||||
check_runs = []
|
||||
reported_total = 0
|
||||
for page in check_pages:
|
||||
if not isinstance(page, dict):
|
||||
raise SystemExit(1)
|
||||
page_runs = page.get("check_runs") or []
|
||||
total_count = page.get("total_count")
|
||||
if not isinstance(page_runs, list) or not isinstance(total_count, int):
|
||||
raise SystemExit(1)
|
||||
reported_total = max(reported_total, total_count)
|
||||
check_runs.extend(page_runs)
|
||||
if len(check_runs) < reported_total:
|
||||
raise SystemExit(1)
|
||||
|
||||
for run in check_runs:
|
||||
if not isinstance(run, dict):
|
||||
raise SystemExit(1)
|
||||
status = run.get("status")
|
||||
conclusion = run.get("conclusion")
|
||||
if status != "completed":
|
||||
value = "pending"
|
||||
elif conclusion == "success":
|
||||
value = "success"
|
||||
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
|
||||
value = "failure"
|
||||
else:
|
||||
value = "unknown"
|
||||
combined.append({
|
||||
"context": run.get("name") or "github-check",
|
||||
"status": value,
|
||||
"target_url": run.get("html_url") or run.get("details_url") or "",
|
||||
})
|
||||
|
||||
json.dump({"state": "", "statuses": combined}, sys.stdout)
|
||||
PY
|
||||
local status=$?
|
||||
rm -f "$status_file" "$checks_file"
|
||||
return "$status"
|
||||
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
||||
}
|
||||
|
||||
gitea_get_branch_head_sha() {
|
||||
@@ -304,14 +174,6 @@ while [[ $# -gt 0 ]]; do
|
||||
BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-R|--repo)
|
||||
TARGET_REPO="$2"
|
||||
shift 2
|
||||
;;
|
||||
--sha)
|
||||
HEAD_SHA="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--timeout)
|
||||
TIMEOUT_SEC="$2"
|
||||
shift 2
|
||||
@@ -344,89 +206,45 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
|
||||
echo "Error: timeout and interval must be integer seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
|
||||
echo "Error: --repo must be OWNER/REPO." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||
echo "Error: --purpose must be push or merge." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER="unknown"
|
||||
REPO="unknown"
|
||||
PLATFORM="unknown"
|
||||
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
|
||||
record_cannot_assert "repository-owner-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
|
||||
record_cannot_assert "repository-name-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! detect_platform > /dev/null; then
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
fi
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
detect_platform > /dev/null
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
|
||||
if [[ -n "$TARGET_REPO" ]]; then
|
||||
OWNER="${TARGET_REPO%%/*}"
|
||||
REPO="${TARGET_REPO##*/}"
|
||||
fi
|
||||
|
||||
if [[ -z "$BRANCH" ]]; then
|
||||
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
|
||||
record_cannot_assert "current-branch-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
record_cannot_assert "github-cli-unavailable"
|
||||
exit $?
|
||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
||||
exit 1
|
||||
fi
|
||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
||||
record_cannot_assert "remote-host-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
||||
record_cannot_assert "credential-unresolvable"
|
||||
exit $?
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Could not determine remote host." >&2
|
||||
exit 1
|
||||
}
|
||||
TOKEN=$(get_gitea_token "$HOST") || {
|
||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||
exit 1
|
||||
}
|
||||
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
else
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
START_TS=$(date +%s)
|
||||
@@ -435,20 +253,14 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
||||
while true; do
|
||||
NOW_TS=$(date +%s)
|
||||
if (( NOW_TS > DEADLINE_TS )); then
|
||||
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
exit 124
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
||||
else
|
||||
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
||||
fi
|
||||
|
||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||
@@ -459,24 +271,21 @@ while true; do
|
||||
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
terminal-success)
|
||||
exit 0
|
||||
;;
|
||||
no-status)
|
||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||
else
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 3
|
||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
||||
exit 0
|
||||
;;
|
||||
terminal-failure|malformed|unknown)
|
||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
terminal-success|terminal-failure|unknown)
|
||||
# Queue guard only blocks on pending/running/queued states.
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
MERGE_METHOD="squash"
|
||||
DELETE_BRANCH=false
|
||||
SKIP_QUEUE_GUARD=false
|
||||
DRY_RUN=false
|
||||
EXPECT_HEAD=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -25,14 +25,15 @@ Options:
|
||||
-n, --number NUMBER PR number to merge (required)
|
||||
-m, --method METHOD Merge method: squash only (default: squash)
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--skip-queue-guard Skip CI queue guard wait before merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -n 42 # Merge PR #42
|
||||
$(basename "$0") -n 42 -m squash # Squash merge
|
||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
@@ -52,13 +53,14 @@ while [[ $# -gt 0 ]]; do
|
||||
DELETE_BRANCH=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
--skip-queue-guard)
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--expect-head)
|
||||
EXPECT_HEAD="$2"
|
||||
shift 2
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
@@ -84,36 +86,18 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
|
||||
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" != true ]]; then
|
||||
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||
--purpose merge \
|
||||
-B "$HEAD_BRANCH" \
|
||||
-R "$HEAD_REPO" \
|
||||
--sha "$HEAD_SHA" \
|
||||
-B "$BASE_BRANCH" \
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||
fi
|
||||
@@ -122,22 +106,31 @@ PLATFORM=$(detect_platform)
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
|
||||
is_known_tea_empty_identity_failure() {
|
||||
local error_file="$1"
|
||||
|
||||
python3 - "$error_file" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
||||
error = handle.read()
|
||||
|
||||
known_empty_identity = re.search(
|
||||
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
||||
error,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
raise SystemExit(0 if known_empty_identity else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
merge_gitea_with_api() {
|
||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
head_sha, delete_branch = sys.argv[1:]
|
||||
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||
if delete_branch == "true":
|
||||
payload["delete_branch_after_merge"] = True
|
||||
print(json.dumps(payload, separators=(",", ":")))
|
||||
PY
|
||||
)
|
||||
payload='{"Do":"squash"}'
|
||||
|
||||
token=$(get_gitea_token "$host" || true)
|
||||
if [[ -n "$token" ]]; then
|
||||
@@ -209,7 +202,7 @@ fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||
"${cmd[@]}"
|
||||
;;
|
||||
@@ -218,9 +211,32 @@ case "$PLATFORM" in
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||
# tea cannot express it, so exact-head merges use the authenticated API path.
|
||||
merge_gitea_with_api "$HOST"
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
|
||||
if [[ -n "$TEA_LOGIN" ]]; then
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
|
||||
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
merge_gitea_with_api "$HOST"
|
||||
else
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
||||
merge_gitea_with_api "$HOST"
|
||||
fi
|
||||
|
||||
# Delete branch after merge if requested
|
||||
if [[ "$DELETE_BRANCH" == true ]]; then
|
||||
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
|
||||
@@ -109,7 +109,7 @@ PY
|
||||
detect_platform > /dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
write_metadata "$METADATA"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
OWNER=$(get_repo_owner)
|
||||
@@ -182,25 +182,6 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
||||
data.get('head_ref'),
|
||||
head_ref,
|
||||
)
|
||||
head_sha = first_non_empty(
|
||||
nested(data, 'head', 'sha'),
|
||||
nested(data, 'head', 'id'),
|
||||
data.get('head_sha'),
|
||||
)
|
||||
head_repo = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'full_name'),
|
||||
nested(data, 'head', 'repo', 'name_with_owner'),
|
||||
)
|
||||
if not head_repo:
|
||||
head_repo_owner = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'owner', 'login'),
|
||||
nested(data, 'head', 'repo', 'owner', 'username'),
|
||||
nested(data, 'head', 'repo', 'owner_name'),
|
||||
)
|
||||
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
|
||||
if head_repo_owner and head_repo_name:
|
||||
head_repo = f'{head_repo_owner}/{head_repo_name}'
|
||||
|
||||
base_ref = first_non_empty(
|
||||
nested(data, 'base', 'ref'),
|
||||
nested(data, 'base', 'name'),
|
||||
@@ -226,8 +207,6 @@ normalized = {
|
||||
'state': data.get('state'),
|
||||
'author': nested(data, 'user', 'login') or '',
|
||||
'headRefName': head_ref,
|
||||
'headRefOid': head_sha,
|
||||
'headRepository': head_repo,
|
||||
'baseRefName': base_ref,
|
||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
||||
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# Covers:
|
||||
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
||||
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
||||
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
||||
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -62,7 +62,7 @@ case "$mode" in
|
||||
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
||||
500) code=500; body='{"message":"internal server error"}' ;;
|
||||
no-status) code=200; body='{}' ;;
|
||||
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
terminal-success) code=200; body='{"state":"success"}' ;;
|
||||
*)
|
||||
echo "curl stub: unknown mode=$mode" >&2
|
||||
exit 2
|
||||
@@ -91,7 +91,6 @@ run_ci_queue_wait() {
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
||||
)
|
||||
}
|
||||
@@ -132,26 +131,19 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
||||
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
|
||||
set +e
|
||||
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
||||
status_c=$?
|
||||
set -e
|
||||
if [[ "$status_c" -ne 0 ]]; then
|
||||
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
|
||||
if [[ "$status_c" -eq 0 ]]; then
|
||||
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" == *"queue clear"* ]]; then
|
||||
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
|
||||
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b fix/github-checks
|
||||
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
|
||||
|
||||
cat > "$STUB_DIR/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
endpoint=""
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == repos/* ]] && endpoint="$arg"
|
||||
done
|
||||
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
|
||||
case "$endpoint" in
|
||||
repos/acme/widgets/branches/fix/github-checks)
|
||||
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
|
||||
;;
|
||||
repos/acme/widgets/commits/*/statuses?per_page=100)
|
||||
printf '%s\n' '[[]]'
|
||||
;;
|
||||
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
|
||||
case "${MOSAIC_GH_CHECK_MODE:?}" in
|
||||
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
|
||||
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
|
||||
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
|
||||
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/gh"
|
||||
|
||||
run_guard() {
|
||||
local mode="$1"
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_GH_CHECK_MODE="$mode"
|
||||
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
assert_case() {
|
||||
local mode="$1" expected_rc="$2" expected_state="$3" output rc
|
||||
set +e
|
||||
output=$(run_guard "$mode" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
||||
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
|
||||
failures=$((failures + 1))
|
||||
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
|
||||
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$output" != *"state=$expected_state"* ]]; then
|
||||
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
: > "$WORK_DIR/gh-calls.log"
|
||||
assert_case success zero terminal-success
|
||||
assert_case pending nonzero pending
|
||||
assert_case failure nonzero terminal-failure
|
||||
assert_case late-failure nonzero terminal-failure
|
||||
|
||||
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
|
||||
echo "FAIL: expected every case to query all Checks API pages" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "GitHub check-runs regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
|
||||
@@ -1,364 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
|
||||
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
STATUS_OBSERVED="$WORK_DIR/status-observed"
|
||||
CLOCK_LOG="$WORK_DIR/clock.log"
|
||||
WATCHDOG_PYTHON="/usr/bin/python3"
|
||||
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
|
||||
WATCHDOG_TIMEOUT_SEC=5
|
||||
WATCHDOG_EXIT=90
|
||||
FEATURE_BRANCH="fix/rm-03-fixture"
|
||||
|
||||
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
|
||||
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
cat > "$WATCHDOG_SCRIPT" <<'PY'
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
raise SystemExit(2)
|
||||
|
||||
timeout_seconds = float(sys.argv[1])
|
||||
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
|
||||
try:
|
||||
return_code = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait()
|
||||
print(
|
||||
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
|
||||
"before completing its intended path",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(90)
|
||||
|
||||
if return_code < 0:
|
||||
raise SystemExit(128 - return_code)
|
||||
raise SystemExit(return_code)
|
||||
PY
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
url=""
|
||||
has_write_out=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-w) has_write_out=1 ;;
|
||||
http://*|https://*) url="$arg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/branches/*)
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
|
||||
while :; do :; done
|
||||
fi
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
||||
exit 7
|
||||
fi
|
||||
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
||||
if [[ "$has_write_out" -eq 1 ]]; then
|
||||
printf '%s\n200' "$body"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
;;
|
||||
*/status)
|
||||
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
|
||||
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
||||
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
||||
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
|
||||
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
|
||||
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
|
||||
malformed) printf '%s' 'not-json' ;;
|
||||
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
||||
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
||||
large-success)
|
||||
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
||||
;;
|
||||
unreachable) exit 7 ;;
|
||||
*) echo "unknown status mode" >&2; exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
cat > "$STUB_DIR/date" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
|
||||
echo "unexpected date invocation: $*" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
|
||||
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
printf '1002\n'
|
||||
else
|
||||
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
printf '1000\n'
|
||||
fi
|
||||
SH
|
||||
|
||||
cat > "$STUB_DIR/sleep" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
|
||||
|
||||
run_guard() {
|
||||
local status_mode="$1"
|
||||
local audit_log="${2:-$AUDIT_LOG}"
|
||||
shift 2 || true
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
if [[ "$status_mode" == "credential-unresolvable" ]]; then
|
||||
export HOME="$WORK_DIR/empty-home"
|
||||
mkdir -p "$HOME"
|
||||
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
|
||||
export MOSAIC_STUB_STATUS_MODE=success
|
||||
else
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
||||
fi
|
||||
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
|
||||
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
||||
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
|
||||
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
||||
# Provider observation is the synchronization event. The one-second
|
||||
# timeout is subject semantics under virtual time, never a wall wait.
|
||||
# The absolute Python runtime uses an internal monotonic wait and kills
|
||||
# the subject's isolated process group. Neither operation can resolve
|
||||
# to the virtual date/sleep stubs at the front of PATH.
|
||||
local subject_rc
|
||||
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
|
||||
subject_rc=0
|
||||
else
|
||||
subject_rc=$?
|
||||
fi
|
||||
return "$subject_rc"
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
assert_provider_observed() {
|
||||
local name="$1" require_expiration="${2:-0}"
|
||||
if [[ ! -e "$STATUS_OBSERVED" ]]; then
|
||||
echo "FAIL $name: status provider was not observed" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
|
||||
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$require_expiration" -eq 1 ]]; then
|
||||
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
|
||||
echo "FAIL $name: pending path did not expire after provider observation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
run_assertion() {
|
||||
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
||||
local output rc
|
||||
shift 4
|
||||
set +e
|
||||
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
case "$expected_rc" in
|
||||
zero)
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
nonzero)
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL $name: expected rc!=0, got rc=0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
not126)
|
||||
if [[ "$rc" -eq 126 ]]; then
|
||||
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [[ "$output" != *"$required_text"* ]]; then
|
||||
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$status_mode" != "credential-unresolvable" ]]; then
|
||||
if [[ "$status_mode" == "pending" ]]; then
|
||||
assert_provider_observed "$name" 1
|
||||
else
|
||||
assert_provider_observed "$name"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
: > "$WORK_DIR/urls.log"
|
||||
run_assertion success zero success 'state=terminal-success'
|
||||
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
|
||||
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
|
||||
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
||||
run_assertion large-payload not126 large-success 'state=terminal-success'
|
||||
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||
|
||||
# Positive liveness control: a subject mutant hangs before the branch lookup
|
||||
# can reach the status provider. Only the independent real-clock watchdog may
|
||||
# terminate it, and its failure must be distinct from subject timeout rc=124.
|
||||
set +e
|
||||
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
|
||||
watchdog_rc=$?
|
||||
set -e
|
||||
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
|
||||
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
|
||||
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
|
||||
printf '%s\n' "$watchdog_output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ -e "$STATUS_OBSERVED" ]]; then
|
||||
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
|
||||
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
|
||||
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||
set +e
|
||||
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
|
||||
merge_unreachable_rc=$?
|
||||
set -e
|
||||
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
assert_provider_observed merge-provider-unreachable
|
||||
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# A feature-branch push with no -B must inspect the checked-out feature branch.
|
||||
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
|
||||
exact_sha=0123456789abcdef0123456789abcdef01234567
|
||||
: > "$WORK_DIR/urls.log"
|
||||
run_assertion exact-fork-head zero success 'state=terminal-success' \
|
||||
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
|
||||
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
|
||||
audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
|
||||
set +e
|
||||
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
|
||||
unsupported_rc=$?
|
||||
set -e
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
|
||||
if [[ "$unsupported_rc" -ne 0 ]]; then
|
||||
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
|
||||
echo "FAIL unsupported-platform: expected an additional audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# A degraded pass is forbidden if the audit receipt cannot be written.
|
||||
mkdir -p "$WORK_DIR/not-a-directory"
|
||||
printf 'file' > "$WORK_DIR/not-a-directory/parent"
|
||||
set +e
|
||||
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
|
||||
audit_failure_rc=$?
|
||||
set -e
|
||||
if [[ "$audit_failure_rc" -eq 0 ]]; then
|
||||
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$audit_failure_output" != *"audit"* ]]; then
|
||||
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
assert_provider_observed audit-unavailable
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
|
||||
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -79,24 +79,15 @@ emit_response() {
|
||||
printf '200'
|
||||
fi
|
||||
}
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
|
||||
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
POST_DATA="$post_data" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
payload = json.loads(os.environ["POST_DATA"])
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
|
||||
}, payload
|
||||
PY
|
||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
||||
echo "unexpected merge payload: $post_data" >&2
|
||||
exit 96
|
||||
fi
|
||||
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||
exit 0
|
||||
fi
|
||||
@@ -116,8 +107,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
|
||||
export GITEA_TOKEN="redacted-test-token"
|
||||
|
||||
OUTPUT="$SANDBOX/output.log"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
||||
echo "--- output ---" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
echo "--- mock log ---" >&2
|
||||
@@ -136,6 +127,38 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||
if [[ "$*" == *"login list"* ]]; then
|
||||
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == *"pr merge"* ]]; then
|
||||
echo 'tea network timeout' >&2
|
||||
exit 2
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
: > "$LOG_FILE"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected arbitrary tea failure to remain blocking." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
|
||||
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'tea network timeout' "$OUTPUT"; then
|
||||
echo "Expected arbitrary tea error to be preserved in output." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
@@ -154,8 +177,8 @@ EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
unset GITEA_LOGIN
|
||||
: > "$LOG_FILE"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
@@ -192,7 +215,7 @@ cd "$REPO_DIR"
|
||||
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -217,7 +240,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -237,4 +260,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge.sh Gitea exact-head API regression passed"
|
||||
echo "pr-merge.sh Gitea fallback regression passed"
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
|
||||
# The commit whose CI was guarded must be the commit the provider atomically merges.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
|
||||
SHA=0123456789abcdef0123456789abcdef01234567
|
||||
|
||||
make_fixture() {
|
||||
local name="$1" remote="$2"
|
||||
local root="$WORK_DIR/$name"
|
||||
local tools="$root/tools/git"
|
||||
mkdir -p "$tools" "$root/repo"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
||||
git -C "$root/repo" init -q
|
||||
git -C "$root/repo" remote add origin "$remote"
|
||||
cat > "$tools/pr-metadata.sh" <<SH
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$tools"/*.sh
|
||||
}
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
make_fixture gitea https://git.example.test/acme/widgets.git
|
||||
make_fixture github https://github.com/acme/widgets.git
|
||||
|
||||
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
payload=""
|
||||
for ((i=1; i<=$#; i++)); do
|
||||
if [[ "${!i}" == "-d" ]]; then
|
||||
j=$((i + 1))
|
||||
payload="${!j}"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||
printf '200'
|
||||
SH
|
||||
chmod +x "$WORK_DIR/gitea/curl"
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
|
||||
) >"$WORK_DIR/gitea.out" 2>&1
|
||||
gitea_rc=$?
|
||||
set -e
|
||||
if [[ "$gitea_rc" -ne 0 ]]; then
|
||||
echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2
|
||||
cat "$WORK_DIR/gitea.out" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload
|
||||
assert payload.get("Do") == "squash", payload
|
||||
assert payload.get("head_commit_id") == sys.argv[2], payload
|
||||
PY
|
||||
|
||||
# A merge-gate verdict is commit-bound. A stale expected head must fail before merge.
|
||||
wrong_sha=ffffffffffffffffffffffffffffffffffffffff
|
||||
rm -f "$WORK_DIR/gitea-payload-stale.json"
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
|
||||
) >"$WORK_DIR/gitea-stale.out" 2>&1
|
||||
stale_rc=$?
|
||||
set -e
|
||||
if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then
|
||||
echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A merge-capable path cannot bypass the mandatory queue guard. The legacy
|
||||
# --skip-queue-guard option must be rejected before any provider merge call.
|
||||
cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 99
|
||||
SH
|
||||
chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh"
|
||||
rm -f "$WORK_DIR/gitea-payload-bypass.json"
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
|
||||
) >"$WORK_DIR/gitea-bypass.out" 2>&1
|
||||
bypass_rc=$?
|
||||
set -e
|
||||
if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then
|
||||
echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Dry-run is the only path that may omit the guard because it exits before the
|
||||
# provider merge dispatch. Prove the exit and absence of a merge payload.
|
||||
rm -f "$WORK_DIR/gitea-payload-dry-run.json"
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
|
||||
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
|
||||
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
|
||||
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$WORK_DIR/github/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}"
|
||||
SH
|
||||
chmod +x "$WORK_DIR/github/gh"
|
||||
(
|
||||
cd "$WORK_DIR/github/repo"
|
||||
export PATH="$WORK_DIR/github:$PATH"
|
||||
export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log"
|
||||
"$WORK_DIR/github/tools/git/pr-merge.sh" -n 123
|
||||
) >"$WORK_DIR/github.out" 2>&1
|
||||
if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then
|
||||
echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2
|
||||
cat "$WORK_DIR/github-call.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR merge exact-head pin regression passed (Gitea + GitHub)"
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# RM-03: pr-merge must guard the PR head branch, not its main base branch.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-queue-branch}"
|
||||
FIXTURE_DIR="$WORK_DIR/tools/git"
|
||||
CALL_LOG="$WORK_DIR/queue-call.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FIXTURE_DIR"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
||||
exit 42
|
||||
SH
|
||||
chmod +x "$FIXTURE_DIR"/*.sh
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
||||
"$FIXTURE_DIR/pr-merge.sh" -n 123
|
||||
) >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
if [[ "$rc" -ne 42 ]]; then
|
||||
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -s "$CALL_LOG" ]]; then
|
||||
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-B fix/rm-03-fixture' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive PR head branch" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q -- '-B main' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard still received the main base branch" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '--sha 0123456789abcdef0123456789abcdef01234567' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the exact PR head SHA" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge queue branch/repository/SHA regression passed"
|
||||
@@ -39,12 +39,11 @@ ORIG_PATH="$PATH"
|
||||
# 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"
|
||||
SIGNALED="$FW/.install-signal-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" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
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; }
|
||||
@@ -181,86 +180,41 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
||||
# ── 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. The earlier test used a child cp shim to signal its parent, making
|
||||
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
||||
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
||||
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
||||
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
||||
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
||||
# than manufacturing a pass. The only semantic difference between fixtures is
|
||||
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
||||
TERM_MARKER='[test-control] TERM handler entered'
|
||||
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||
|
||||
make_signal_installer() {
|
||||
local output="$1" handler="$2"
|
||||
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||
local target_cp=' cp "$abs" "$dst/$rel"'
|
||||
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
|
||||
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
|
||||
local inject_close=' fi'
|
||||
|
||||
if ! awk \
|
||||
-v target_trap="$target_trap" -v target_cp="$target_cp" \
|
||||
-v handler="$handler" -v inject_open="$inject_open" \
|
||||
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
|
||||
$0 == target_cp {
|
||||
print inject_open
|
||||
print inject_kill
|
||||
print inject_close
|
||||
injection_sites++
|
||||
}
|
||||
{ print }
|
||||
$0 == target_trap {
|
||||
print handler
|
||||
handler_sites++
|
||||
}
|
||||
END {
|
||||
if (handler_sites != 1 || injection_sites != 1) exit 42
|
||||
}
|
||||
' "$INSTALL" > "$output"; then
|
||||
rm -f "$output"
|
||||
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$output"
|
||||
# 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"
|
||||
}
|
||||
|
||||
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
||||
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
|
||||
signal_fixture_ready() {
|
||||
local fixture="$1" expected_handler="$2"
|
||||
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
|
||||
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
|
||||
&& grep -Fqx "$expected_handler" "$fixture"
|
||||
}
|
||||
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
|
||||
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
|
||||
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
|
||||
"signaled_fixture_ready"
|
||||
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
|
||||
"noexit_fixture_ready"
|
||||
chk "[control] removing the explicit TERM exit changes the fixture" \
|
||||
"! cmp -s '$SIGNALED' '$NOEXIT'"
|
||||
|
||||
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
|
||||
# the selected copy. Echoes "<exit>\t<out>\t<home>".
|
||||
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
||||
run_signal_upgrade() {
|
||||
local installer="$1" H OUT rc
|
||||
H=$(mktemp -d); OUT=$(mktemp)
|
||||
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="$ORIG_PATH" \
|
||||
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 "$SIGNALED")
|
||||
chk "[signal] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
|
||||
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" \
|
||||
@@ -268,13 +222,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
|
||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||
"! grep -q 'file phase complete' '$OUTC'"
|
||||
|
||||
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
chk "[control] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||
"[ '$rcD' -eq 0 ]"
|
||||
# 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'"
|
||||
|
||||
@@ -355,10 +309,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
|
||||
# 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 (generated installer controls are also removed by the EXIT trap).
|
||||
# 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" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
|
||||
@@ -26,13 +26,12 @@ A Woodpecker API token is required. To configure:
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
| -------------------------- | -------------------------------------------------------------- |
|
||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
||||
| `verify-terminal-green.py` | Verify every JSON/API child step under the bounded CI contract |
|
||||
| Script | Purpose |
|
||||
| --------------------- | -------------------------------------------- |
|
||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
||||
|
||||
## Common Options
|
||||
|
||||
@@ -60,9 +59,4 @@ A Woodpecker API token is required. To configure:
|
||||
|
||||
# Block until one or more pipelines finish (event-driven CI wait)
|
||||
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
|
||||
|
||||
# Verify the full JSON child-step record; do not use the text summary for this gate
|
||||
PR_HEAD=<full-40-hex-provider-head>
|
||||
~/.config/mosaic/tools/woodpecker/pipeline-status.sh -r mosaicstack/stack -n 2188 -f json \
|
||||
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py --expect-commit "$PR_HEAD" -
|
||||
```
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Red-first contract harness for RM-61 / #1000.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERIFIER="$SCRIPT_DIR/verify-terminal-green.py"
|
||||
EXPECTED_COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
write_fixture() {
|
||||
local file="$1" pipeline_status="$2" postgres_state="$3" postgres_exit="$4" postgres_error="$5" test_state="$6"
|
||||
python3 - "$file" "$pipeline_status" "$postgres_state" "$postgres_exit" "$postgres_error" "$test_state" <<'PY'
|
||||
import json, sys
|
||||
path, pipeline_status, pg_state, pg_exit, pg_error, test_state = sys.argv[1:]
|
||||
steps = [
|
||||
{"name": "clone", "type": "clone", "state": "success", "exit_code": 0, "error": None},
|
||||
{"name": "ci-postgres", "type": "service", "state": pg_state, "exit_code": int(pg_exit), "error": pg_error or None},
|
||||
{"name": "test", "type": "commands", "state": test_state, "exit_code": 0 if test_state == "success" else 1, "error": None},
|
||||
]
|
||||
json.dump({
|
||||
"number": 9999,
|
||||
"status": pipeline_status,
|
||||
"commit": "a" * 40,
|
||||
"workflows": [{"name": "ci", "state": pipeline_status, "children": steps}],
|
||||
}, open(path, "w"))
|
||||
PY
|
||||
}
|
||||
|
||||
expect_exit() {
|
||||
local expected_exit="$1" label="$2" file="$3" expected_commit="${4:-$EXPECTED_COMMIT}"
|
||||
set +e
|
||||
output=$(python3 "$VERIFIER" --expect-commit "$expected_commit" "$file" 2>&1)
|
||||
actual=$?
|
||||
set -e
|
||||
if [[ "$actual" -ne "$expected_exit" ]]; then
|
||||
printf 'FAIL %s: expected exit %s, got %s\n%s\n' "$label" "$expected_exit" "$actual" "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'PASS %s\n' "$label"
|
||||
printf '%s' "$output"
|
||||
}
|
||||
|
||||
# Ordinary terminal green.
|
||||
write_fixture "$TMP/green.json" success success 0 '' success
|
||||
out=$(expect_exit 0 green "$TMP/green.json")
|
||||
grep -q '"total_steps": 3' <<<"$out"
|
||||
grep -q '"exempted_steps": 0' <<<"$out"
|
||||
|
||||
# Exact, named #1000 teardown artifact: the only permitted non-success child.
|
||||
artifact='pods "wp-svc-01kyxzjhdf6w81swsnbfzh85z9-ci-postgres" not found'
|
||||
write_fixture "$TMP/artifact.json" success failure 0 "$artifact" success
|
||||
out=$(expect_exit 0 exact-artifact "$TMP/artifact.json")
|
||||
grep -q '"exemption_id": "WP-K8S-1000-CI-POSTGRES-TEARDOWN"' <<<"$out"
|
||||
grep -q '"exempted_steps": 1' <<<"$out"
|
||||
|
||||
# Negative controls: both real PostgreSQL failures must remain red.
|
||||
write_fixture "$TMP/startup.json" failure failure 1 '' failure
|
||||
expect_exit 1 startup-failure "$TMP/startup.json" >/dev/null
|
||||
write_fixture "$TMP/crash.json" failure failure 137 '' failure
|
||||
expect_exit 1 post-readiness-crash "$TMP/crash.json" >/dev/null
|
||||
|
||||
# The exemption is signature-scoped, not step-scoped.
|
||||
write_fixture "$TMP/wrong-error.json" success failure 0 'connection refused' success
|
||||
expect_exit 1 other-postgres-error "$TMP/wrong-error.json" >/dev/null
|
||||
write_fixture "$TMP/wrong-pod.json" success failure 0 'pods "other-ci-postgres" not found' success
|
||||
expect_exit 1 wrong-pod-signature "$TMP/wrong-pod.json" >/dev/null
|
||||
write_fixture "$TMP/nonzero-artifact.json" success failure 137 "$artifact" success
|
||||
expect_exit 1 nonzero-with-artifact-text "$TMP/nonzero-artifact.json" >/dev/null
|
||||
|
||||
# JSON booleans and non-integer zero look equal to 0 in Python but are not exit codes.
|
||||
python3 - "$TMP/artifact.json" "$TMP" <<'PY'
|
||||
import json, os, sys
|
||||
record = json.load(open(sys.argv[1]))
|
||||
for label, value in (("false", False), ("true", True), ("float", 0.0), ("string", "0"), ("null", None)):
|
||||
changed = json.loads(json.dumps(record))
|
||||
changed["workflows"][0]["children"][1]["exit_code"] = value
|
||||
json.dump(changed, open(os.path.join(sys.argv[2], f"exit-{label}.json"), "w"))
|
||||
PY
|
||||
for label in false true float string null; do
|
||||
expect_exit 1 "non-integer-exit-$label" "$TMP/exit-$label.json" >/dev/null
|
||||
done
|
||||
|
||||
# Exact artifact cannot mask any independent failure or non-success pipeline.
|
||||
write_fixture "$TMP/artifact-plus-failure.json" failure failure 0 "$artifact" failure
|
||||
expect_exit 1 artifact-plus-real-failure "$TMP/artifact-plus-failure.json" >/dev/null
|
||||
write_fixture "$TMP/skipped.json" success success 0 '' skipped
|
||||
expect_exit 1 skipped-step "$TMP/skipped.json" >/dev/null
|
||||
|
||||
# The scanned pipeline must be bound to an explicit, full PR-head commit.
|
||||
set +e
|
||||
missing_output=$(python3 "$VERIFIER" "$TMP/artifact.json" 2>&1)
|
||||
missing_rc=$?
|
||||
set -e
|
||||
if [[ "$missing_rc" -ne 2 ]] || ! grep -q -- '--expect-commit' <<<"$missing_output"; then
|
||||
printf 'FAIL missing-expected-commit: expected usage exit 2\n%s\n' "$missing_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
expect_exit 1 mismatched-expected-commit "$TMP/artifact.json" bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb >/dev/null
|
||||
|
||||
python3 - "$TMP/artifact.json" "$TMP/missing-record-commit.json" <<'PY'
|
||||
import json, sys
|
||||
record = json.load(open(sys.argv[1]))
|
||||
record.pop("commit")
|
||||
json.dump(record, open(sys.argv[2], "w"))
|
||||
PY
|
||||
expect_exit 1 missing-record-commit "$TMP/missing-record-commit.json" >/dev/null
|
||||
|
||||
printf 'terminal-green contract harness: PASS (17 cases)\n'
|
||||
@@ -1,230 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify Mosaic's full-step Woodpecker terminal-green contract.
|
||||
|
||||
RM-61 permits one named, signature-scoped exception for issue #1000. The
|
||||
exception retires when #1000 is fixed; all other non-success states block.
|
||||
This program consumes the JSON/API record emitted by pipeline-status.sh -f json.
|
||||
It does not fetch, retry, or re-trigger pipelines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
EXEMPTION_ID = "WP-K8S-1000-CI-POSTGRES-TEARDOWN"
|
||||
EXEMPTION_ISSUE = "https://git.mosaicstack.dev/mosaicstack/stack/issues/1000"
|
||||
POD_NOT_FOUND = re.compile(
|
||||
r'^pods "wp-svc-[0-9a-hjkmnp-tv-z]{26}-ci-postgres" not found$'
|
||||
)
|
||||
|
||||
|
||||
def fail_usage(message: str) -> int:
|
||||
print(f"terminal-green contract input error: {message}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def load_record(argument: str | None) -> dict[str, Any]:
|
||||
if argument in (None, "-"):
|
||||
value = json.load(sys.stdin)
|
||||
else:
|
||||
with Path(argument).open(encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("pipeline record must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def is_issue_1000_artifact(step: dict[str, Any]) -> bool:
|
||||
error = step.get("error")
|
||||
exit_code = step.get("exit_code")
|
||||
return (
|
||||
step.get("name") == "ci-postgres"
|
||||
and step.get("type") == "service"
|
||||
and step.get("state") == "failure"
|
||||
and type(exit_code) is int
|
||||
and not isinstance(exit_code, bool)
|
||||
and exit_code == 0
|
||||
and isinstance(error, str)
|
||||
and POD_NOT_FOUND.fullmatch(error) is not None
|
||||
)
|
||||
|
||||
|
||||
def verify(record: dict[str, Any], expected_commit: str) -> tuple[int, dict[str, Any]]:
|
||||
anomalies: list[dict[str, Any]] = []
|
||||
candidates: list[dict[str, Any]] = []
|
||||
steps: list[dict[str, Any]] = []
|
||||
|
||||
pipeline_status = record.get("status")
|
||||
actual_commit = record.get("commit")
|
||||
if actual_commit != expected_commit:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "pipeline",
|
||||
"name": str(record.get("number", "unknown")),
|
||||
"state": pipeline_status,
|
||||
"reason": "pipeline commit does not equal the expected PR head",
|
||||
"expected_commit": expected_commit,
|
||||
"actual_commit": actual_commit,
|
||||
}
|
||||
)
|
||||
if pipeline_status != "success":
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "pipeline",
|
||||
"name": str(record.get("number", "unknown")),
|
||||
"state": pipeline_status,
|
||||
"reason": "pipeline status is not success",
|
||||
}
|
||||
)
|
||||
|
||||
workflows = record.get("workflows")
|
||||
if not isinstance(workflows, list) or not workflows:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "pipeline",
|
||||
"name": str(record.get("number", "unknown")),
|
||||
"state": pipeline_status,
|
||||
"reason": "workflows are missing or empty",
|
||||
}
|
||||
)
|
||||
workflows = []
|
||||
|
||||
for workflow_index, workflow in enumerate(workflows):
|
||||
if not isinstance(workflow, dict):
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "workflow",
|
||||
"name": str(workflow_index),
|
||||
"state": None,
|
||||
"reason": "workflow is not an object",
|
||||
}
|
||||
)
|
||||
continue
|
||||
workflow_name = str(workflow.get("name", workflow_index))
|
||||
if workflow.get("state") != "success":
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "workflow",
|
||||
"name": workflow_name,
|
||||
"state": workflow.get("state"),
|
||||
"reason": "workflow state is not success",
|
||||
}
|
||||
)
|
||||
children = workflow.get("children")
|
||||
if not isinstance(children, list) or not children:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "workflow",
|
||||
"name": workflow_name,
|
||||
"state": workflow.get("state"),
|
||||
"reason": "child-step list is missing or empty",
|
||||
}
|
||||
)
|
||||
continue
|
||||
for child_index, child in enumerate(children):
|
||||
if not isinstance(child, dict):
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "step",
|
||||
"name": f"{workflow_name}[{child_index}]",
|
||||
"state": None,
|
||||
"reason": "step is not an object",
|
||||
}
|
||||
)
|
||||
continue
|
||||
steps.append(child)
|
||||
if child.get("state") == "success":
|
||||
continue
|
||||
if is_issue_1000_artifact(child):
|
||||
candidates.append(child)
|
||||
continue
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "step",
|
||||
"name": child.get("name"),
|
||||
"type": child.get("type"),
|
||||
"state": child.get("state"),
|
||||
"exit_code": child.get("exit_code"),
|
||||
"error": child.get("error"),
|
||||
"reason": "non-success step does not match the #1000 teardown signature",
|
||||
}
|
||||
)
|
||||
|
||||
if len(candidates) > 1:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "exemption",
|
||||
"name": EXEMPTION_ID,
|
||||
"state": "invalid",
|
||||
"reason": "the #1000 exemption may apply to exactly one step",
|
||||
}
|
||||
)
|
||||
|
||||
exemption_applies = len(candidates) == 1 and not anomalies
|
||||
state_counts = Counter(str(step.get("state", "missing")) for step in steps)
|
||||
result: dict[str, Any] = {
|
||||
"schema_version": "mosaic-terminal-green/v1",
|
||||
"verdict": "terminal-green" if not anomalies else "not-terminal-green",
|
||||
"pipeline_number": record.get("number"),
|
||||
"commit": actual_commit,
|
||||
"expected_commit": expected_commit,
|
||||
"pipeline_status": pipeline_status,
|
||||
"total_steps": len(steps),
|
||||
"state_counts": dict(sorted(state_counts.items())),
|
||||
"exempted_steps": 1 if exemption_applies else 0,
|
||||
"anomalies": anomalies,
|
||||
}
|
||||
if exemption_applies:
|
||||
candidate = candidates[0]
|
||||
result["exemptions"] = [
|
||||
{
|
||||
"exemption_id": EXEMPTION_ID,
|
||||
"step": candidate.get("name"),
|
||||
"signature": candidate.get("error"),
|
||||
"tracking_issue": EXEMPTION_ISSUE,
|
||||
"retires_when": "issue #1000 is fixed",
|
||||
}
|
||||
]
|
||||
else:
|
||||
result["exemptions"] = []
|
||||
|
||||
return (0 if not anomalies else 1), result
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="verify the full Woodpecker terminal-green child-step contract"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expect-commit",
|
||||
required=True,
|
||||
metavar="FULL_SHA",
|
||||
help="full 40-hex PR-head commit that the pipeline record must match",
|
||||
)
|
||||
parser.add_argument("record", nargs="?", default="-", help="pipeline JSON file or -")
|
||||
arguments = parser.parse_args()
|
||||
if re.fullmatch(r"[0-9a-fA-F]{40}", arguments.expect_commit) is None:
|
||||
parser.error("--expect-commit must be a full 40-hex commit")
|
||||
arguments.expect_commit = arguments.expect_commit.lower()
|
||||
return arguments
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = parse_arguments()
|
||||
try:
|
||||
record = load_record(arguments.record)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
return fail_usage(str(error))
|
||||
code, result = verify(record, arguments.expect_commit)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic",
|
||||
"version": "0.0.49",
|
||||
"version": "0.0.48",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -23,7 +23,6 @@ import { registerSkillCommand } from './commands/skill.js';
|
||||
import { registerLaunchCommands } from './commands/launch.js';
|
||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
||||
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
||||
import { registerBrainProvisionCommand } from './commands/brain-provision-command.js';
|
||||
import { registerAuthCommand } from './commands/auth.js';
|
||||
import { registerFederationCommand } from './commands/federation.js';
|
||||
import { registerGatewayCommand } from './commands/gateway.js';
|
||||
@@ -86,10 +85,6 @@ registerLeaseCapabilityProbe(program);
|
||||
|
||||
registerInstallOrderingGuardCommand(program);
|
||||
|
||||
// ─── durable brain P7 provisioner (hidden; #1051) ───────────────────────
|
||||
|
||||
registerBrainProvisionCommand(program);
|
||||
|
||||
// ─── login ──────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface CommandRequest {
|
||||
readonly program: 'git' | 'mosaic';
|
||||
readonly args: readonly string[];
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
readonly status: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
interface InstalledDoctorResult {
|
||||
readonly status: 'ok' | 'warn' | 'error';
|
||||
readonly findings: readonly {
|
||||
readonly code: string;
|
||||
readonly reasonCode: string | null;
|
||||
}[];
|
||||
readonly lines: readonly string[];
|
||||
}
|
||||
|
||||
interface BrainDoctorModule {
|
||||
runInstalledBrainDoctorCheck(
|
||||
options: {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity?: string;
|
||||
readonly fix: boolean;
|
||||
},
|
||||
run: (request: CommandRequest) => CommandResult,
|
||||
): InstalledDoctorResult;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-doctor-check.js';
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadDoctor(requirement: string): Promise<BrainDoctorModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as BrainDoctorModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: installed brain doctor check is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function tempRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-doctor-'));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function installConfig(root: string): { readonly home: string; readonly mosaicHome: string } {
|
||||
const home = join(root, 'home');
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
mkdirSync(join(mosaicHome, 'cred'), { recursive: true });
|
||||
mkdirSync(join(mosaicHome, 'brain'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(mosaicHome, 'cred', 'estates.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
writeFileSync(
|
||||
join(mosaicHome, 'brain', 'owners.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
estate: 'homelab',
|
||||
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||
controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
writeFileSync(
|
||||
join(mosaicHome, '.install-manifest.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
status: 'committed',
|
||||
sourceRepo: 'https://git.example.invalid/example/stack.git',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return { home, mosaicHome };
|
||||
}
|
||||
|
||||
function validateResult(outcome: 'ok' | 'refused' | 'indeterminate', reasonCode: string): string {
|
||||
const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30;
|
||||
return JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation: 'validate',
|
||||
outcome,
|
||||
exitCode,
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity: 'seat-a',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'durable-owner/mosaic-brain',
|
||||
},
|
||||
mutation: 'none',
|
||||
reason: { code: reasonCode, message: 'non-secret' },
|
||||
evidence: {
|
||||
providerIdentity:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
login: 'seat-a',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
repositoryPermission:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
requested: 'write',
|
||||
effective: 'write',
|
||||
endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
writeDifferential:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
state: 'can-write',
|
||||
credentialBinding: 'same-resolution',
|
||||
transportPrincipal: 'seat-a',
|
||||
authenticatedReceivePack: 'advertised',
|
||||
readOnlyControl: {
|
||||
identity: 'read-control',
|
||||
providerPermission: 'read',
|
||||
receivePack: 'refused',
|
||||
},
|
||||
unauthenticatedReceivePack: 'refused',
|
||||
artifactCreated: false,
|
||||
proves: 'non-secret evidence',
|
||||
doesNotProve: 'branch update acceptance',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
audit: { journalId: 'opaque', state: 'sealed' },
|
||||
});
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('installed mosaic doctor brain checks', (): void => {
|
||||
it('derives the target from the committed install manifest and surfaces a missing clone plus refusal', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-08 installed doctor missing clone');
|
||||
const config = installConfig(tempRoot());
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ ...config, identity: 'seat-a', fix: false },
|
||||
(request): CommandResult => {
|
||||
requests.push(request);
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('warn');
|
||||
expect(result.findings.map((finding) => finding.code)).toEqual(
|
||||
expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']),
|
||||
);
|
||||
expect(result.lines.join('\n')).toMatch(/brain-clone-missing/);
|
||||
expect(result.lines.join('\n')).toMatch(/no-token-for-identity/);
|
||||
expect(requests[0]?.args).toContain('durable-owner/mosaic-brain');
|
||||
});
|
||||
|
||||
it('fails closed without an explicit identity and performs no command', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-08 explicit identity');
|
||||
const config = installConfig(tempRoot());
|
||||
let calls = 0;
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ ...config, fix: false },
|
||||
(): CommandResult => {
|
||||
calls += 1;
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'error',
|
||||
findings: [{ code: 'brain-identity-required', reasonCode: 'identity-required' }],
|
||||
});
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it('treats identity-not-measured as an error, not no-write refusal and not a repairable grant case', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-08 identity measurement axis');
|
||||
const config = installConfig(tempRoot());
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ ...config, identity: 'seat-a', fix: true },
|
||||
(request): CommandResult => {
|
||||
requests.push(request);
|
||||
return {
|
||||
status: 30,
|
||||
stdout: validateResult('indeterminate', 'identity-not-measured'),
|
||||
stderr: 'identity not measured',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'brain-write-access-indeterminate',
|
||||
reasonCode: 'identity-not-measured',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is wired into the top-level mosaic doctor path before the shell audit runs', (): void => {
|
||||
const launch = readFileSync(join(process.cwd(), 'src', 'commands', 'launch.ts'), 'utf8');
|
||||
|
||||
expect(launch).toContain('runInstalledBrainDoctorCheck');
|
||||
expect(launch).toContain('defaultInstalledBrainDoctorOptions');
|
||||
expect(launch).toContain('systemCommandRunner');
|
||||
expect(launch).toMatch(/brainCheckFailed[\s\S]*runDoctorScriptAndExit/);
|
||||
});
|
||||
|
||||
it('reports a missing or unsafe registry/manifest as configuration error rather than defaulting estate', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-02 missing mapping fail-closed');
|
||||
const root = tempRoot();
|
||||
const home = join(root, 'home');
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
mkdirSync(mosaicHome, { recursive: true });
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ home, mosaicHome, identity: 'seat-a', fix: false },
|
||||
(): CommandResult => ({ status: 0, stdout: '', stderr: '' }),
|
||||
);
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.findings[0]?.code).toMatch(/brain-(estate-registry|install-manifest)-/);
|
||||
expect(result.lines.join('\n')).not.toMatch(/homelab|usc/);
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { readBrainConfigSecure } from './brain-secure-config.js';
|
||||
import { resolveBrainOwnerPolicy } from './brain-owner-resolver.js';
|
||||
import { deriveBrainTarget } from './brain-store.js';
|
||||
import {
|
||||
collectBrainDoctorReport,
|
||||
repairBrainDoctor,
|
||||
type CommandRunner,
|
||||
type DoctorRuntimeReport,
|
||||
} from './brain-store-runtime.js';
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const manifestSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
status: z.literal('committed'),
|
||||
sourceRepo: z.string().min(1),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export interface InstalledDoctorFinding {
|
||||
readonly code: string;
|
||||
readonly reasonCode: string | null;
|
||||
}
|
||||
|
||||
export interface InstalledDoctorResult {
|
||||
readonly status: 'ok' | 'warn' | 'error';
|
||||
readonly findings: readonly InstalledDoctorFinding[];
|
||||
readonly lines: readonly string[];
|
||||
}
|
||||
|
||||
function configurationError(code: string, reasonCode = code): InstalledDoctorResult {
|
||||
return {
|
||||
status: 'error',
|
||||
findings: [{ code, reasonCode }],
|
||||
lines: [`[mosaic-doctor] [ERROR] ${code}`],
|
||||
};
|
||||
}
|
||||
|
||||
function renderReport(report: DoctorRuntimeReport): InstalledDoctorResult {
|
||||
const findings = report.findings.map(
|
||||
(finding): InstalledDoctorFinding => ({
|
||||
code: finding.code,
|
||||
reasonCode: finding.reasonCode,
|
||||
}),
|
||||
);
|
||||
const hard = findings.some(
|
||||
(finding): boolean =>
|
||||
finding.code.endsWith('-error') ||
|
||||
finding.code.endsWith('-indeterminate') ||
|
||||
finding.code === 'brain-not-git-repository' ||
|
||||
finding.code === 'brain-root-permissions-unsafe',
|
||||
);
|
||||
const status: InstalledDoctorResult['status'] =
|
||||
findings.length === 0 ? 'ok' : hard ? 'error' : 'warn';
|
||||
const severity = status === 'error' ? 'ERROR' : status === 'warn' ? 'WARN' : 'OK';
|
||||
const lines =
|
||||
findings.length === 0
|
||||
? ['[mosaic-doctor] [OK] mosaic-brain ready']
|
||||
: findings.map(
|
||||
(finding): string =>
|
||||
`[mosaic-doctor] [${severity}] ${finding.code}${
|
||||
finding.reasonCode === null ? '' : ` reason=${finding.reasonCode}`
|
||||
}`,
|
||||
);
|
||||
return { status, findings, lines };
|
||||
}
|
||||
|
||||
export function runInstalledBrainDoctorCheck(
|
||||
options: {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity?: string;
|
||||
readonly fix: boolean;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): InstalledDoctorResult {
|
||||
if (options.identity === undefined || !IDENTITY.test(options.identity)) {
|
||||
return configurationError('brain-identity-required', 'identity-required');
|
||||
}
|
||||
|
||||
const registryPath = join(options.mosaicHome, 'cred', 'estates.json');
|
||||
const manifestPath = join(options.mosaicHome, '.install-manifest.json');
|
||||
const ownerPolicyPath = join(options.mosaicHome, 'brain', 'owners.json');
|
||||
let registrySource: string;
|
||||
try {
|
||||
registrySource = readBrainConfigSecure(registryPath, options.mosaicHome);
|
||||
} catch {
|
||||
return configurationError('brain-estate-registry-unavailable');
|
||||
}
|
||||
let manifestSource: string;
|
||||
try {
|
||||
manifestSource = readBrainConfigSecure(manifestPath, options.mosaicHome);
|
||||
} catch {
|
||||
return configurationError('brain-install-manifest-unavailable');
|
||||
}
|
||||
let manifestRaw: unknown;
|
||||
try {
|
||||
manifestRaw = JSON.parse(manifestSource);
|
||||
} catch {
|
||||
return configurationError('brain-install-manifest-invalid');
|
||||
}
|
||||
const manifest = manifestSchema.safeParse(manifestRaw);
|
||||
if (!manifest.success) return configurationError('brain-install-manifest-invalid');
|
||||
let ownerPolicySource: string;
|
||||
try {
|
||||
ownerPolicySource = readBrainConfigSecure(ownerPolicyPath, options.mosaicHome);
|
||||
} catch {
|
||||
return configurationError('brain-owner-policy-unavailable');
|
||||
}
|
||||
let preliminaryTarget: ReturnType<typeof deriveBrainTarget>;
|
||||
try {
|
||||
preliminaryTarget = deriveBrainTarget(registrySource, manifest.data.sourceRepo, 'policy-probe');
|
||||
} catch {
|
||||
return configurationError('brain-estate-registry-invalid');
|
||||
}
|
||||
const ownerPolicy = resolveBrainOwnerPolicy(ownerPolicySource, preliminaryTarget.estate);
|
||||
if (ownerPolicy === undefined) return configurationError('brain-owner-policy-invalid');
|
||||
|
||||
const input = {
|
||||
registrySource,
|
||||
targetGitUrl: manifest.data.sourceRepo,
|
||||
brainNamespace: ownerPolicy.brainNamespace,
|
||||
identity: options.identity,
|
||||
root: join(options.home, '.mosaic'),
|
||||
};
|
||||
try {
|
||||
return renderReport(
|
||||
options.fix ? repairBrainDoctor(input, run) : collectBrainDoctorReport(input, run),
|
||||
);
|
||||
} catch {
|
||||
return configurationError('brain-estate-registry-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultInstalledBrainDoctorOptions(fix: boolean): {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity?: string;
|
||||
readonly fix: boolean;
|
||||
} {
|
||||
const home = homedir();
|
||||
const identity = process.env['MOSAIC_GIT_IDENTITY'];
|
||||
return {
|
||||
mosaicHome: process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic'),
|
||||
home,
|
||||
...(identity === undefined ? {} : { identity }),
|
||||
fix,
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
function installerSource(): string {
|
||||
return readFileSync(join(process.cwd(), '..', '..', 'tools', 'install.sh'), 'utf8');
|
||||
}
|
||||
|
||||
describe('root installer P7 durable-brain integration', (): void => {
|
||||
it('records the configured source repository in the committed manifest for doctor derivation', (): void => {
|
||||
const installer = installerSource();
|
||||
|
||||
expect(installer).toContain('sourceRepo:');
|
||||
expect(installer).toMatch(/sourceRepo:\s*process\.argv\[/);
|
||||
expect(installer).toMatch(/MANIFEST_SOURCE_REPO/);
|
||||
});
|
||||
|
||||
it('invokes the broker-only provision command in P7 with explicit owner and refusal controls', (): void => {
|
||||
const installer = installerSource();
|
||||
const provision = installer.indexOf('__brain-provision');
|
||||
const p7 = installer.indexOf('state_phase_begin P7');
|
||||
|
||||
expect(provision).toBeGreaterThan(-1);
|
||||
expect(p7).toBeGreaterThan(-1);
|
||||
expect(provision).toBeGreaterThan(p7);
|
||||
for (const flag of [
|
||||
'--identity',
|
||||
'--target-url',
|
||||
'--owner',
|
||||
'--refusal-identity',
|
||||
'--lane',
|
||||
'--owner-policy',
|
||||
]) {
|
||||
expect(installer).toContain(flag);
|
||||
}
|
||||
expect(installer).not.toMatch(/__brain-provision[^\n]*(?:token|password|authorization)/i);
|
||||
});
|
||||
|
||||
it('journals ~/.mosaic and the owner policy as P7 mutations and checks the resulting object', (): void => {
|
||||
const installer = installerSource();
|
||||
|
||||
expect(installer).toContain('state_record_mutation P7 "$HOME/.mosaic"');
|
||||
expect(installer).toContain('state_record_mutation P7 "$MOSAIC_HOME/brain/owners.json"');
|
||||
expect(installer).toMatch(/P7\)[\s\S]*\.mosaic[\s\S]*(?:remote|get-url)[\s\S]*main/);
|
||||
});
|
||||
|
||||
it('discovers every legacy lane directory rather than silently migrating only one lane', (): void => {
|
||||
const installer = installerSource();
|
||||
|
||||
expect(installer).toMatch(/memory\/lanes/);
|
||||
expect(installer).toMatch(/for\s+[^\n]*lane/);
|
||||
expect(installer).toMatch(/__brain-provision[\s\S]*--lane/);
|
||||
});
|
||||
});
|
||||
@@ -1,373 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Red-first owner-authority resolver contract for #1051.
|
||||
*
|
||||
* Fixtures are operator-agnostic. The HOMELAB owner name belongs in the local
|
||||
* estate policy, never in framework source. Anonymous lookup is intentional:
|
||||
* the ruled owner class is PUBLIC and least-privilege seats may lack read:user.
|
||||
*/
|
||||
|
||||
interface MigrationOwnerResolution {
|
||||
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
||||
readonly reasonCode: string;
|
||||
readonly principal: {
|
||||
readonly name: string;
|
||||
readonly kind: 'durable-human';
|
||||
} | null;
|
||||
readonly authority: {
|
||||
readonly system: 'gitea';
|
||||
readonly endpoint: string;
|
||||
readonly contentType: 'application/json';
|
||||
} | null;
|
||||
}
|
||||
|
||||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface OwnerResolverModule {
|
||||
resolveProviderDurableOwner(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly host: string;
|
||||
readonly requestedOwner: string;
|
||||
},
|
||||
dependencies: {
|
||||
readonly fetch: FetchLike;
|
||||
readonly absentControlName: () => string;
|
||||
},
|
||||
): Promise<MigrationOwnerResolution>;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-owner-resolver.js';
|
||||
|
||||
async function loadResolver(requirement: string): Promise<OwnerResolverModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as OwnerResolverModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: brain owner resolver is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function estateRegistry(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function ownerPolicy(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
estate: 'homelab',
|
||||
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||
controls: {
|
||||
publicIdentity: 'public-control',
|
||||
privateIdentity: 'private-control',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
function publicUser(login: string, active = false): Response {
|
||||
return jsonResponse(200, {
|
||||
id: 42,
|
||||
login,
|
||||
visibility: 'public',
|
||||
active,
|
||||
});
|
||||
}
|
||||
|
||||
function identityFromUrl(input: string | URL | Request): string {
|
||||
const value = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
return decodeURIComponent(new URL(value).pathname.split('/').at(-1) ?? '');
|
||||
}
|
||||
|
||||
function controlledFetch(
|
||||
overrides: Readonly<Record<string, Response>> = {},
|
||||
calls: Array<{ identity: string; authorization: string | null }> = [],
|
||||
): FetchLike {
|
||||
return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const identity = identityFromUrl(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({ identity, authorization: headers.get('authorization') });
|
||||
const override = overrides[identity];
|
||||
if (override !== undefined) return override.clone();
|
||||
if (identity === 'public-control') return publicUser('public-control');
|
||||
if (identity === 'private-control' || identity === 'generated-absent-control') {
|
||||
return jsonResponse(404, { message: 'not found' });
|
||||
}
|
||||
if (identity === 'durable-owner') return publicUser('durable-owner', false);
|
||||
return jsonResponse(404, { message: 'not found' });
|
||||
};
|
||||
}
|
||||
|
||||
describe('provider-backed durable owner resolver', (): void => {
|
||||
it('resolves an allowlisted PUBLIC owner by exact login with public/private/absent controls and ignores active=false', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 provider owner resolution');
|
||||
const calls: Array<{ identity: string; authorization: string | null }> = [];
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch({}, calls),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
verdict: 'resolved',
|
||||
reasonCode: 'owner-verified',
|
||||
principal: { name: 'user:durable-owner', kind: 'durable-human' },
|
||||
authority: {
|
||||
system: 'gitea',
|
||||
endpoint: 'GET /api/v1/users/durable-owner',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
});
|
||||
expect(calls.map((call) => call.identity)).toEqual([
|
||||
'public-control',
|
||||
'private-control',
|
||||
'generated-absent-control',
|
||||
'durable-owner',
|
||||
]);
|
||||
expect(calls.every((call) => call.authorization === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses provider redirects and configures a bounded no-redirect request', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 owner lookup SSRF boundary');
|
||||
const requests: RequestInit[] = [];
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: async (_input, init): Promise<Response> => {
|
||||
requests.push(init ?? {});
|
||||
return new Response(JSON.stringify({ message: 'redirect' }), {
|
||||
status: 302,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
location: 'http://127.0.0.1/internal',
|
||||
},
|
||||
});
|
||||
},
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'not-measured', reasonCode: 'owner-control-invalid' });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.redirect).toBe('manual');
|
||||
expect(requests[0]?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('cancels a chunked provider body as soon as it exceeds the byte ceiling', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 bounded owner response stream');
|
||||
let cancelled = false;
|
||||
const oversized = new ReadableStream<Uint8Array>({
|
||||
start(controller): void {
|
||||
controller.enqueue(new Uint8Array(200_000));
|
||||
controller.enqueue(new Uint8Array(100_000));
|
||||
},
|
||||
cancel(): void {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: async (): Promise<Response> =>
|
||||
new Response(oversized, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
verdict: 'not-measured',
|
||||
reasonCode: 'owner-unexpected-provider-shape',
|
||||
});
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('requires the GLPI standing remediation queue in the local estate policy', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 standing process policy');
|
||||
const raw = JSON.parse(ownerPolicy()) as { estates: Array<Record<string, unknown>> };
|
||||
delete raw.estates[0]?.['standingProcess'];
|
||||
let fetchCalls = 0;
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: JSON.stringify(raw),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: async (): Promise<Response> => {
|
||||
fetchCalls += 1;
|
||||
return publicUser('durable-owner');
|
||||
},
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-policy-invalid' });
|
||||
expect(fetchCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a provider-valid but unlisted principal before provider lookup', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 provider-valid unlisted owner refusal');
|
||||
const calls: Array<{ identity: string; authorization: string | null }> = [];
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:other-public-user',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch({ 'other-public-user': publicUser('other-public-user') }, calls),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-not-allowlisted' });
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['user:durable–owner', 'owner-name-invalid'],
|
||||
[' user:durable-owner ', 'owner-name-invalid'],
|
||||
['user:durable.owner', 'owner-not-allowlisted'],
|
||||
['user:durable owner', 'owner-name-invalid'],
|
||||
['user:durable-owner', 'owner-name-invalid'],
|
||||
['user:be-coder-07@mission-seat', 'owner-name-invalid'],
|
||||
] as const)(
|
||||
'rejects non-canonical, unlisted, or transient-seat presentation %s before lookup',
|
||||
async (name, reasonCode): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 owner allowlist grammar');
|
||||
let fetchCalls = 0;
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: name,
|
||||
},
|
||||
{
|
||||
fetch: async (): Promise<Response> => {
|
||||
fetchCalls += 1;
|
||||
return publicUser('durable-owner');
|
||||
},
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'refused', reasonCode });
|
||||
expect(fetchCalls).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('fails closed as not-resolvable rather than claiming a private-or-absent owner does not exist', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 private/absent ambiguity');
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch({ 'durable-owner': jsonResponse(404, { message: 'hidden' }) }),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
verdict: 'not-measured',
|
||||
reasonCode: 'owner-not-resolvable',
|
||||
principal: null,
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['public control hidden', { 'public-control': jsonResponse(404, {}) }],
|
||||
['public control login mismatch', { 'public-control': publicUser('other') }],
|
||||
['private control unexpectedly public', { 'private-control': publicUser('private-control') }],
|
||||
[
|
||||
'generated absent control unexpectedly resolves',
|
||||
{ 'generated-absent-control': publicUser('generated-absent-control') },
|
||||
],
|
||||
] as const)(
|
||||
'makes the whole result not-measured when %s',
|
||||
async (_caseName, overrides): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 owner resolver controls');
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch(overrides),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
verdict: 'not-measured',
|
||||
reasonCode: 'owner-control-invalid',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,280 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||
import type { MigrationOwnerResolution } from './brain-store.js';
|
||||
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
const LOGIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/;
|
||||
const REQUESTED_OWNER = /^user:(.+)$/;
|
||||
|
||||
const ownerPolicySchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
estates: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
estate: z.string().min(1),
|
||||
laneArchiveOwners: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('provider-user'),
|
||||
login: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.min(1),
|
||||
standingProcess: z
|
||||
.object({
|
||||
kind: z.literal('glpi-queue'),
|
||||
queue: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
||||
})
|
||||
.strict(),
|
||||
controls: z
|
||||
.object({
|
||||
publicIdentity: z.string().min(1),
|
||||
privateIdentity: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const providerUserSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
login: z.string().min(1),
|
||||
visibility: z.literal('public'),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type OwnerFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
function unresolved(reasonCode: string): MigrationOwnerResolution {
|
||||
return {
|
||||
verdict: 'not-measured',
|
||||
reasonCode,
|
||||
principal: null,
|
||||
authority: null,
|
||||
};
|
||||
}
|
||||
|
||||
function refused(reasonCode: string): MigrationOwnerResolution {
|
||||
return {
|
||||
verdict: 'refused',
|
||||
reasonCode,
|
||||
principal: null,
|
||||
authority: null,
|
||||
};
|
||||
}
|
||||
|
||||
function exactCanonicalLogin(value: string): boolean {
|
||||
return value.normalize('NFKC') === value && LOGIN.test(value);
|
||||
}
|
||||
|
||||
async function boundedJson(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (!contentType.toLowerCase().startsWith('application/json')) {
|
||||
throw new Error('owner-unexpected-content-type');
|
||||
}
|
||||
const declared = response.headers.get('content-length');
|
||||
let declaredSize: number | null = null;
|
||||
if (declared !== null) {
|
||||
if (!/^\d+$/.test(declared)) throw new Error('owner-unexpected-provider-shape');
|
||||
declaredSize = Number.parseInt(declared, 10);
|
||||
if (!Number.isSafeInteger(declaredSize) || declaredSize > MAX_BODY_BYTES) {
|
||||
throw new Error('owner-unexpected-provider-shape');
|
||||
}
|
||||
}
|
||||
if (response.body === null) throw new Error('owner-unexpected-provider-shape');
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
total += next.value.byteLength;
|
||||
if (total > MAX_BODY_BYTES) {
|
||||
await reader.cancel('owner response exceeds byte ceiling');
|
||||
throw new Error('owner-unexpected-provider-shape');
|
||||
}
|
||||
chunks.push(next.value);
|
||||
}
|
||||
if (declaredSize !== null && declaredSize !== total) {
|
||||
throw new Error('owner-unexpected-provider-shape');
|
||||
}
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
|
||||
} catch {
|
||||
throw new Error('owner-unexpected-provider-shape');
|
||||
}
|
||||
}
|
||||
|
||||
async function readPublicIdentity(
|
||||
origin: string,
|
||||
identity: string,
|
||||
fetchImpl: OwnerFetch,
|
||||
): Promise<{ readonly status: number; readonly user: unknown }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(`${origin}/api/v1/users/${encodeURIComponent(identity)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'mosaic-brain-owner/1',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
} catch {
|
||||
throw new Error('owner-provider-unavailable');
|
||||
}
|
||||
return { status: response.status, user: await boundedJson(response) };
|
||||
}
|
||||
|
||||
function publicIdentityMatches(value: unknown, identity: string): boolean {
|
||||
const parsed = providerUserSchema.safeParse(value);
|
||||
return parsed.success && parsed.data.login === identity;
|
||||
}
|
||||
|
||||
export interface BrainOwnerPolicyBinding {
|
||||
readonly brainNamespace: string;
|
||||
readonly publicControl: string;
|
||||
readonly privateControl: string;
|
||||
readonly standingQueue: string;
|
||||
}
|
||||
|
||||
export function resolveBrainOwnerPolicy(
|
||||
ownerPolicySource: string,
|
||||
estate: string,
|
||||
): BrainOwnerPolicyBinding | undefined {
|
||||
let rawPolicy: unknown;
|
||||
try {
|
||||
rawPolicy = JSON.parse(ownerPolicySource);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const policy = ownerPolicySchema.safeParse(rawPolicy);
|
||||
if (!policy.success) return undefined;
|
||||
const estatePolicies = policy.data.estates.filter(
|
||||
(candidate): boolean => candidate.estate === estate,
|
||||
);
|
||||
if (estatePolicies.length !== 1) return undefined;
|
||||
const estatePolicy = estatePolicies[0];
|
||||
if (estatePolicy === undefined || estatePolicy.laneArchiveOwners.length !== 1) return undefined;
|
||||
const brainNamespace = estatePolicy.laneArchiveOwners[0]?.login;
|
||||
if (brainNamespace === undefined || !exactCanonicalLogin(brainNamespace)) return undefined;
|
||||
return {
|
||||
brainNamespace,
|
||||
publicControl: estatePolicy.controls.publicIdentity,
|
||||
privateControl: estatePolicy.controls.privateIdentity,
|
||||
standingQueue: estatePolicy.standingProcess.queue,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRequestedOwner(requestedOwner: string): string | null {
|
||||
if (requestedOwner.normalize('NFKC') !== requestedOwner) return null;
|
||||
const match = REQUESTED_OWNER.exec(requestedOwner);
|
||||
const login = match?.[1];
|
||||
if (login === undefined || !exactCanonicalLogin(login)) return null;
|
||||
return login;
|
||||
}
|
||||
|
||||
export async function resolveProviderDurableOwner(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly host: string;
|
||||
readonly requestedOwner: string;
|
||||
},
|
||||
dependencies: {
|
||||
readonly fetch: OwnerFetch;
|
||||
readonly absentControlName: () => string;
|
||||
},
|
||||
): Promise<MigrationOwnerResolution> {
|
||||
const requestedLogin = parseRequestedOwner(input.requestedOwner);
|
||||
if (requestedLogin === null) return refused('owner-name-invalid');
|
||||
|
||||
const target = parseCredentialEstateRegistry(input.estateRegistrySource).resolveByHost(
|
||||
input.host,
|
||||
);
|
||||
if (target === undefined) return refused('estate-host-unmapped');
|
||||
|
||||
const policy = resolveBrainOwnerPolicy(input.ownerPolicySource, target.estate);
|
||||
if (policy === undefined) return refused('owner-policy-invalid');
|
||||
if (policy.brainNamespace !== requestedLogin) return refused('owner-not-allowlisted');
|
||||
|
||||
const publicControl = policy.publicControl;
|
||||
const privateControl = policy.privateControl;
|
||||
const absentControl = dependencies.absentControlName();
|
||||
if (
|
||||
!exactCanonicalLogin(publicControl) ||
|
||||
!exactCanonicalLogin(privateControl) ||
|
||||
!exactCanonicalLogin(absentControl) ||
|
||||
new Set([publicControl, privateControl, absentControl, requestedLogin]).size !== 4
|
||||
) {
|
||||
return refused('owner-policy-invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
const publicResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
publicControl,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (publicResult.status !== 200 || !publicIdentityMatches(publicResult.user, publicControl)) {
|
||||
return unresolved('owner-control-invalid');
|
||||
}
|
||||
|
||||
const privateResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
privateControl,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (privateResult.status !== 404) return unresolved('owner-control-invalid');
|
||||
|
||||
const absentResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
absentControl,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (absentResult.status !== 404) return unresolved('owner-control-invalid');
|
||||
|
||||
const ownerResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
requestedLogin,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (ownerResult.status === 401 || ownerResult.status === 403 || ownerResult.status === 404) {
|
||||
return unresolved('owner-not-resolvable');
|
||||
}
|
||||
if (ownerResult.status !== 200) return unresolved('owner-provider-unavailable');
|
||||
if (!publicIdentityMatches(ownerResult.user, requestedLogin)) {
|
||||
return unresolved('owner-provider-identity-mismatch');
|
||||
}
|
||||
return {
|
||||
verdict: 'resolved',
|
||||
reasonCode: 'owner-verified',
|
||||
principal: { name: `user:${requestedLogin}`, kind: 'durable-human' },
|
||||
authority: {
|
||||
system: 'gitea',
|
||||
endpoint: `GET /api/v1/users/${requestedLogin}`,
|
||||
contentType: 'application/json',
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const reason = error instanceof Error ? error.message : 'owner-provider-unavailable';
|
||||
if (reason === 'owner-unexpected-content-type') return unresolved(reason);
|
||||
if (reason === 'owner-unexpected-provider-shape') return unresolved(reason);
|
||||
return unresolved('owner-provider-unavailable');
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface BrainProvisionCommandModule {
|
||||
readonly BRAIN_PROVISION_COMMAND: string;
|
||||
registerBrainProvisionCommand(program: Command): void;
|
||||
executeBrainProvisionCommand(
|
||||
options: {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly targetUrl: string;
|
||||
readonly owner: string;
|
||||
readonly lane: string;
|
||||
readonly sourceRoot?: string;
|
||||
readonly brainRoot?: string;
|
||||
readonly ownerPolicy?: string;
|
||||
readonly registry?: string;
|
||||
},
|
||||
dependencies: {
|
||||
readonly run: () => never;
|
||||
readonly fetch: typeof fetch;
|
||||
readonly absentControlName: () => string;
|
||||
},
|
||||
): Promise<{
|
||||
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||
readonly reasonCode: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-provision-command.js';
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadCommand(requirement: string): Promise<BrainProvisionCommandModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as BrainProvisionCommandModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: brain provision command is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function tempRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-command-'));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('internal P7 brain provision command', (): void => {
|
||||
it('registers only explicit non-secret contract inputs and no credential/token lookup switches', async (): Promise<void> => {
|
||||
const module = await loadCommand('MB-REQ-03 broker-only provision command');
|
||||
const program = new Command();
|
||||
module.registerBrainProvisionCommand(program);
|
||||
const command = program.commands.find(
|
||||
(candidate) => candidate.name() === module.BRAIN_PROVISION_COMMAND,
|
||||
);
|
||||
|
||||
expect(command).toBeDefined();
|
||||
const flags = command?.options.map((option) => option.flags) ?? [];
|
||||
expect(flags.join(' ')).toContain('--identity');
|
||||
expect(flags.join(' ')).toContain('--target-url');
|
||||
expect(flags.join(' ')).toContain('--refusal-identity');
|
||||
expect(flags.join(' ')).toContain('--owner-policy');
|
||||
expect(flags.join(' ')).toContain('--owner');
|
||||
expect(flags.join(' ')).toContain('--lane');
|
||||
expect(flags.join(' ')).not.toMatch(/token|password|authorization|grant-authority/i);
|
||||
});
|
||||
|
||||
it('is registered by the shipped CLI', async (): Promise<void> => {
|
||||
const module = await loadCommand('MB-REQ-10 shipped P7 command');
|
||||
const cli = readFileSync(join(process.cwd(), 'src', 'cli.ts'), 'utf8');
|
||||
|
||||
expect(cli).toContain('registerBrainProvisionCommand');
|
||||
expect(cli).toContain(`registerBrainProvisionCommand(program)`);
|
||||
expect(module.BRAIN_PROVISION_COMMAND).toBe('__brain-provision');
|
||||
});
|
||||
|
||||
it('fails closed before commands when the local owner policy is absent', async (): Promise<void> => {
|
||||
const module = await loadCommand('MB-REQ-09 owner policy required');
|
||||
const root = tempRoot();
|
||||
const home = join(root, 'home');
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
mkdirSync(join(mosaicHome, 'cred'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(mosaicHome, 'cred', 'estates.json'),
|
||||
JSON.stringify({ version: 1, estates: [] }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
let commands = 0;
|
||||
|
||||
const result = await module.executeBrainProvisionCommand(
|
||||
{
|
||||
mosaicHome,
|
||||
home,
|
||||
identity: 'seat-a',
|
||||
refusalIdentity: 'outside-seat',
|
||||
targetUrl: 'https://git.example.invalid/example/stack.git',
|
||||
owner: 'user:durable-owner',
|
||||
lane: 'lane-a',
|
||||
},
|
||||
{
|
||||
run: (): never => {
|
||||
commands += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
fetch,
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'failed', reasonCode: 'owner-policy-unavailable' });
|
||||
expect(commands).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { readBrainConfigSecure } from './brain-secure-config.js';
|
||||
import { provisionBrain, type ProvisionResult } from './brain-provision.js';
|
||||
import { systemCommandRunner, type CommandRunner } from './brain-store-runtime.js';
|
||||
import type { OwnerFetch } from './brain-owner-resolver.js';
|
||||
|
||||
export const BRAIN_PROVISION_COMMAND = '__brain-provision';
|
||||
|
||||
interface BrainProvisionCommandOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly targetUrl: string;
|
||||
readonly owner: string;
|
||||
readonly lane: string;
|
||||
readonly sourceRoot?: string;
|
||||
readonly brainRoot?: string;
|
||||
readonly ownerPolicy?: string;
|
||||
readonly registry?: string;
|
||||
}
|
||||
|
||||
interface BrainProvisionCommandDependencies {
|
||||
readonly run: CommandRunner;
|
||||
readonly fetch: OwnerFetch;
|
||||
readonly absentControlName: () => string;
|
||||
}
|
||||
|
||||
function configFailure(reasonCode: string): ProvisionResult {
|
||||
return {
|
||||
status: 'failed',
|
||||
reasonCode,
|
||||
findings: [{ code: `brain-${reasonCode}`, reasonCode }],
|
||||
owner: null,
|
||||
migration: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeBrainProvisionCommand(
|
||||
options: BrainProvisionCommandOptions,
|
||||
dependencies: BrainProvisionCommandDependencies,
|
||||
): Promise<ProvisionResult> {
|
||||
const registry = options.registry ?? join(options.mosaicHome, 'cred', 'estates.json');
|
||||
const ownerPolicy = options.ownerPolicy ?? join(options.mosaicHome, 'brain', 'owners.json');
|
||||
let estateRegistrySource: string;
|
||||
try {
|
||||
estateRegistrySource = readBrainConfigSecure(registry, options.mosaicHome);
|
||||
} catch {
|
||||
return configFailure('estate-registry-unavailable');
|
||||
}
|
||||
let ownerPolicySource: string;
|
||||
try {
|
||||
ownerPolicySource = readBrainConfigSecure(ownerPolicy, options.mosaicHome);
|
||||
} catch {
|
||||
return configFailure('owner-policy-unavailable');
|
||||
}
|
||||
|
||||
try {
|
||||
return await provisionBrain(
|
||||
{
|
||||
estateRegistrySource,
|
||||
ownerPolicySource,
|
||||
targetGitUrl: options.targetUrl,
|
||||
requestedOwner: options.owner,
|
||||
identity: options.identity,
|
||||
refusalIdentity: options.refusalIdentity,
|
||||
root: options.brainRoot ?? join(options.home, '.mosaic'),
|
||||
sourceRoot: options.sourceRoot ?? join(options.mosaicHome, 'memory'),
|
||||
seat: options.identity,
|
||||
lane: options.lane,
|
||||
laneActive: false,
|
||||
},
|
||||
dependencies,
|
||||
);
|
||||
} catch {
|
||||
return configFailure('brain-provision-exception');
|
||||
}
|
||||
}
|
||||
|
||||
export function registerBrainProvisionCommand(program: Command): void {
|
||||
program
|
||||
.command(BRAIN_PROVISION_COMMAND, { hidden: true })
|
||||
.description('Internal installer P7 durable-brain provisioner')
|
||||
.requiredOption('--identity <name>', 'explicit fleet identity')
|
||||
.requiredOption('--target-url <url>', 'configured target git URL')
|
||||
.requiredOption('--refusal-identity <name>', 'explicit out-of-estate negative control')
|
||||
.requiredOption('--owner <owner>', 'policy-bound durable owner candidate')
|
||||
.requiredOption('--lane <name>', 'source lane to migrate')
|
||||
.option('--mosaic-home <path>', 'installed Mosaic home')
|
||||
.option('--home <path>', 'principal home')
|
||||
.option('--source-root <path>', 'legacy memory root')
|
||||
.option('--brain-root <path>', 'per-estate brain checkout root')
|
||||
.option('--owner-policy <path>', 'durable-owner allowlist policy')
|
||||
.option('--registry <path>', 'estate registry path')
|
||||
.action(async (raw: Record<string, string | undefined>): Promise<void> => {
|
||||
const home = raw['home'] ?? homedir();
|
||||
const mosaicHome =
|
||||
raw['mosaicHome'] ?? process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic');
|
||||
const result = await executeBrainProvisionCommand(
|
||||
{
|
||||
mosaicHome,
|
||||
home,
|
||||
identity: raw['identity']!,
|
||||
targetUrl: raw['targetUrl']!,
|
||||
refusalIdentity: raw['refusalIdentity']!,
|
||||
owner: raw['owner']!,
|
||||
lane: raw['lane']!,
|
||||
...(raw['sourceRoot'] === undefined ? {} : { sourceRoot: raw['sourceRoot'] }),
|
||||
...(raw['brainRoot'] === undefined ? {} : { brainRoot: raw['brainRoot'] }),
|
||||
...(raw['ownerPolicy'] === undefined ? {} : { ownerPolicy: raw['ownerPolicy'] }),
|
||||
...(raw['registry'] === undefined ? {} : { registry: raw['registry'] }),
|
||||
},
|
||||
{
|
||||
run: systemCommandRunner,
|
||||
fetch,
|
||||
absentControlName: (): string => `mosaic-absent-${randomUUID()}`,
|
||||
},
|
||||
);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
status: result.status,
|
||||
reasonCode: result.reasonCode,
|
||||
findings: result.findings,
|
||||
owner: result.owner,
|
||||
migration: result.migration,
|
||||
})}\n`,
|
||||
);
|
||||
if (result.status !== 'provisioned') process.exitCode = result.status === 'blocked' ? 30 : 20;
|
||||
});
|
||||
}
|
||||
@@ -1,561 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface CommandRequest {
|
||||
readonly program: 'git' | 'mosaic';
|
||||
readonly args: readonly string[];
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
readonly status: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
type CommandRunner = (request: CommandRequest) => CommandResult;
|
||||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface ProvisionResult {
|
||||
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||
readonly reasonCode: string;
|
||||
readonly findings: readonly { code: string; reasonCode: string | null }[];
|
||||
readonly owner: {
|
||||
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
||||
readonly reasonCode: string;
|
||||
} | null;
|
||||
readonly migration: {
|
||||
readonly status: 'migrated' | 'reported' | 'failed';
|
||||
readonly reported: readonly { path: string; reason: string }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ProvisionModule {
|
||||
provisionBrain(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly requestedOwner: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly root: string;
|
||||
readonly sourceRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
},
|
||||
dependencies: {
|
||||
readonly run: CommandRunner;
|
||||
readonly fetch: FetchLike;
|
||||
readonly absentControlName: () => string;
|
||||
readonly approveMigrationContent?: (path: string, content: Uint8Array) => boolean;
|
||||
},
|
||||
): Promise<ProvisionResult>;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-provision.js';
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadProvisioner(requirement: string): Promise<ProvisionModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as ProvisionModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: brain provisioner is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function tempRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-provision-'));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function estateRegistry(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function ownerPolicy(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
estate: 'homelab',
|
||||
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||
controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function validateResult(
|
||||
outcome: 'ok' | 'refused' | 'indeterminate',
|
||||
reasonCode: string,
|
||||
identity = 'seat-a',
|
||||
): string {
|
||||
const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30;
|
||||
return JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation: 'validate',
|
||||
outcome,
|
||||
exitCode,
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity,
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'durable-owner/mosaic-brain',
|
||||
},
|
||||
mutation: 'none',
|
||||
reason: { code: reasonCode, message: 'non-secret' },
|
||||
evidence: {
|
||||
providerIdentity:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
login: identity,
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
repositoryPermission:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
requested: 'write',
|
||||
effective: 'write',
|
||||
endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
writeDifferential:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
state: 'can-write',
|
||||
credentialBinding: 'same-resolution',
|
||||
transportPrincipal: identity,
|
||||
authenticatedReceivePack: 'advertised',
|
||||
readOnlyControl: {
|
||||
identity: 'read-control',
|
||||
providerPermission: 'read',
|
||||
receivePack: 'refused',
|
||||
},
|
||||
unauthenticatedReceivePack: 'refused',
|
||||
artifactCreated: false,
|
||||
proves: 'non-secret evidence',
|
||||
doesNotProve: 'branch update acceptance',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
audit: { journalId: 'opaque', state: 'sealed' },
|
||||
});
|
||||
}
|
||||
|
||||
function publicUser(login: string): Response {
|
||||
return new Response(JSON.stringify({ id: 1, login, visibility: 'public', active: false }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function ownerFetch(ownerStatus = 200): FetchLike {
|
||||
return async (input): Promise<Response> => {
|
||||
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
const identity = decodeURIComponent(new URL(raw).pathname.split('/').at(-1) ?? '');
|
||||
if (identity === 'public-control') return publicUser(identity);
|
||||
if (identity === 'private-control' || identity === 'generated-absent-control') {
|
||||
return new Response(JSON.stringify({ message: 'hidden or absent' }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (identity === 'durable-owner' && ownerStatus === 200) return publicUser(identity);
|
||||
return new Response(JSON.stringify({ message: 'hidden or absent' }), {
|
||||
status: ownerStatus,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function baseInput(root: string): {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly requestedOwner: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly root: string;
|
||||
readonly sourceRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
} {
|
||||
return {
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
targetGitUrl: 'https://git.example.invalid/example/stack.git',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
identity: 'seat-a',
|
||||
refusalIdentity: 'outside-seat',
|
||||
root: join(root, 'brain'),
|
||||
sourceRoot: join(root, 'local-memory'),
|
||||
seat: 'seat-a',
|
||||
lane: 'lane-a',
|
||||
laneActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('P7 brain provisioning orchestration', (): void => {
|
||||
it('requires the P5 write-capability postcondition and never grants or clones on refusal', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-10 P5 before P7');
|
||||
const root = tempRoot();
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = await provisioner.provisionBrain(baseInput(root), {
|
||||
run: (request): CommandResult => {
|
||||
requests.push(request);
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
},
|
||||
fetch: ownerFetch(),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
reasonCode: 'credential-postcondition-failed',
|
||||
});
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.program).toBe('mosaic');
|
||||
expect(requests[0]?.args.slice(0, 3)).toEqual(['cred', 'validate', 'seat-a']);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
expect(requests.some((request) => request.args.includes('clone'))).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks before clone when the out-of-estate Git and API axes disagree', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-05 P7 refusal control gate');
|
||||
const root = tempRoot();
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = await provisioner.provisionBrain(baseInput(root), {
|
||||
run: (request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic' && request.args[2] === 'outside-seat') {
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
if (request.program === 'mosaic') {
|
||||
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||
}
|
||||
if (request.args.includes('ls-remote')) {
|
||||
return { status: 0, stdout: 'refs are visible', stderr: '' };
|
||||
}
|
||||
return { status: 99, stdout: '', stderr: 'unexpected command' };
|
||||
},
|
||||
fetch: ownerFetch(),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
reasonCode: 'refusal-control-failed',
|
||||
});
|
||||
expect(requests.some((request) => request.args.includes('clone'))).toBe(false);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks before skeleton publication when the existing checkout is dirty', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-06 dirty checkout publication gate');
|
||||
const root = tempRoot();
|
||||
const input = baseInput(root);
|
||||
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = await provisioner.provisionBrain(input, {
|
||||
run: (request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic') {
|
||||
return request.args[2] === 'outside-seat'
|
||||
? {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
}
|
||||
: { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||
}
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('ls-remote')) {
|
||||
return {
|
||||
status: 128,
|
||||
stdout: '',
|
||||
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('remote get-url origin')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('branch --show-current')) {
|
||||
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('status --porcelain')) {
|
||||
return { status: 0, stdout: '?? .gitignore\n', stderr: '' };
|
||||
}
|
||||
return { status: 99, stdout: '', stderr: 'unexpected publication command' };
|
||||
},
|
||||
fetch: ownerFetch(),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
reasonCode: 'brain-postcondition-failed',
|
||||
});
|
||||
expect(requests.some((request) => request.args.includes('commit'))).toBe(false);
|
||||
expect(requests.some((request) => request.args.includes('push'))).toBe(false);
|
||||
});
|
||||
|
||||
it('clones, seeds, resolves owner, migrates, pushes on each write, and archives source only after reachability', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-07 complete migration transaction');
|
||||
const root = tempRoot();
|
||||
const input = baseInput(root);
|
||||
mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||
const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||
writeFileSync(source, 'durable finding\n');
|
||||
const requests: CommandRequest[] = [];
|
||||
let commitOrdinal = 0;
|
||||
let approvedPaths: string[] = [];
|
||||
let privateAtClone = false;
|
||||
const runner: CommandRunner = (request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic') {
|
||||
if (request.args[2] === 'outside-seat') {
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||
}
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('ls-remote')) {
|
||||
return {
|
||||
status: 128,
|
||||
stdout: '',
|
||||
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
if (request.args[0] === 'clone') {
|
||||
privateAtClone = existsSync(input.root) && (lstatSync(input.root).mode & 0o077) === 0;
|
||||
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('read-tree')) {
|
||||
approvedPaths = [];
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('hash-object')) {
|
||||
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
if (command.includes('update-index')) {
|
||||
const path = request.args.at(-1);
|
||||
if (path !== undefined) approvedPaths.push(path);
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) {
|
||||
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
if (command.includes('diff --cached --quiet')) {
|
||||
return { status: 1, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('diff-tree')) {
|
||||
return { status: 0, stdout: `${approvedPaths.join('\0')}\0`, stderr: '' };
|
||||
}
|
||||
if (command.includes('show -s')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'seat-a\[email protected]\0seat-a\[email protected]\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('remote get-url origin')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('branch --show-current')) {
|
||||
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('status --porcelain')) {
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse HEAD')) {
|
||||
commitOrdinal += 1;
|
||||
return {
|
||||
status: 0,
|
||||
stdout: `${commitOrdinal === 1 ? 'a' : 'c'.repeat(1)}`.repeat(40) + '\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('rev-parse origin/main')) {
|
||||
const value = commitOrdinal === 1 ? 'b' : 'd';
|
||||
return { status: 0, stdout: `${value.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
|
||||
const result = await provisioner.provisionBrain(input, {
|
||||
run: runner,
|
||||
fetch: ownerFetch(),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
approveMigrationContent: (): boolean => true,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'provisioned',
|
||||
reasonCode: 'brain-provisioned',
|
||||
owner: { verdict: 'resolved', reasonCode: 'owner-verified' },
|
||||
migration: { status: 'reported' },
|
||||
});
|
||||
expect(privateAtClone).toBe(true);
|
||||
expect(existsSync(source)).toBe(true);
|
||||
const imported = result.migration?.reported ?? [];
|
||||
expect(imported).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: source, reason: expect.stringMatching(/retained/i) }),
|
||||
]),
|
||||
);
|
||||
const laneImports = join(input.root, 'lanes', 'lane-a', 'findings', 'imports');
|
||||
const archiveImports = join(input.root, 'archives', 'imports', 'lane');
|
||||
expect(existsSync(laneImports)).toBe(true);
|
||||
expect(existsSync(archiveImports)).toBe(true);
|
||||
expect(
|
||||
requests.filter((request) => request.program === 'git' && request.args.includes('push')),
|
||||
).toHaveLength(2);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps every source and reports the owner ambiguity when the public owner cannot be resolved', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-07 owner-blocked detection/reporting');
|
||||
const root = tempRoot();
|
||||
const input = baseInput(root);
|
||||
mkdirSync(input.root, { recursive: true });
|
||||
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||
mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||
const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||
writeFileSync(source, 'retain me\n');
|
||||
let commitOrdinal = 0;
|
||||
|
||||
const result = await provisioner.provisionBrain(input, {
|
||||
run: (request): CommandResult => {
|
||||
if (request.program === 'mosaic') {
|
||||
if (request.args[2] === 'outside-seat') {
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||
}
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('ls-remote')) {
|
||||
return {
|
||||
status: 128,
|
||||
stdout: '',
|
||||
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('remote get-url origin')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('branch --show-current')) {
|
||||
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('status --porcelain')) {
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse HEAD')) {
|
||||
commitOrdinal += 1;
|
||||
return { status: 0, stdout: `${'a'.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse origin/main')) {
|
||||
return { status: 0, stdout: `${'b'.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
fetch: ownerFetch(404),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
reasonCode: 'owner-not-resolvable',
|
||||
owner: { verdict: 'not-measured', reasonCode: 'owner-not-resolvable' },
|
||||
migration: { status: 'reported' },
|
||||
});
|
||||
expect(readFileSync(source, 'utf8')).toBe('retain me\n');
|
||||
expect(result.migration?.reported).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: source, reason: expect.stringMatching(/owner/i) }),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/);
|
||||
expect(commitOrdinal).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { parseRequestedOwner, resolveProviderDurableOwner } from './brain-owner-resolver.js';
|
||||
import {
|
||||
createBrainSkeleton,
|
||||
deriveBrainTarget,
|
||||
discoverBrainMigration,
|
||||
ensureBrainRootPrivate,
|
||||
migrateBrainState,
|
||||
type MigrationResult,
|
||||
type MigrationOwnerResolution,
|
||||
type MigrationPublishEntry,
|
||||
} from './brain-store.js';
|
||||
import {
|
||||
collectBrainDoctorReport,
|
||||
collectBrainRefusalControl,
|
||||
publishBrainPaths,
|
||||
type CommandRequest,
|
||||
type CommandResult,
|
||||
type CommandRunner,
|
||||
} from './brain-store-runtime.js';
|
||||
import type { OwnerFetch } from './brain-owner-resolver.js';
|
||||
|
||||
export interface ProvisionResult {
|
||||
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||
readonly reasonCode: string;
|
||||
readonly findings: readonly {
|
||||
readonly code: string;
|
||||
readonly reasonCode: string | null;
|
||||
}[];
|
||||
readonly owner: Pick<MigrationOwnerResolution, 'verdict' | 'reasonCode'> | null;
|
||||
readonly migration: Pick<MigrationResult, 'status' | 'reported'> | null;
|
||||
}
|
||||
|
||||
function commandEnv(identity: string): Readonly<Record<string, string>> {
|
||||
return { MOSAIC_GIT_IDENTITY: identity, GIT_TERMINAL_PROMPT: '0' };
|
||||
}
|
||||
|
||||
function findingView(
|
||||
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||
): readonly { readonly code: string; readonly reasonCode: string | null }[] {
|
||||
return findings.map((finding): { readonly code: string; readonly reasonCode: string | null } => ({
|
||||
code: finding.code,
|
||||
reasonCode: finding.reasonCode,
|
||||
}));
|
||||
}
|
||||
|
||||
function blocked(
|
||||
reasonCode: string,
|
||||
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||
owner: MigrationOwnerResolution | null = null,
|
||||
migration: MigrationResult | null = null,
|
||||
): ProvisionResult {
|
||||
return {
|
||||
status: 'blocked',
|
||||
reasonCode,
|
||||
findings: findingView(findings),
|
||||
owner: owner === null ? null : { verdict: owner.verdict, reasonCode: owner.reasonCode },
|
||||
migration:
|
||||
migration === null ? null : { status: migration.status, reported: migration.reported },
|
||||
};
|
||||
}
|
||||
|
||||
function failed(
|
||||
reasonCode: string,
|
||||
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||
owner: MigrationOwnerResolution | null = null,
|
||||
migration: MigrationResult | null = null,
|
||||
): ProvisionResult {
|
||||
return {
|
||||
...blocked(reasonCode, findings, owner, migration),
|
||||
status: 'failed',
|
||||
};
|
||||
}
|
||||
|
||||
export async function provisionBrain(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly requestedOwner: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly root: string;
|
||||
readonly sourceRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
},
|
||||
dependencies: {
|
||||
readonly run: CommandRunner;
|
||||
readonly fetch: OwnerFetch;
|
||||
readonly absentControlName: () => string;
|
||||
readonly approveMigrationContent?: (path: string, content: Uint8Array) => boolean;
|
||||
},
|
||||
): Promise<ProvisionResult> {
|
||||
const brainNamespace = parseRequestedOwner(input.requestedOwner);
|
||||
if (brainNamespace === null) return blocked('owner-name-invalid', []);
|
||||
const target = deriveBrainTarget(input.estateRegistrySource, input.targetGitUrl, brainNamespace);
|
||||
if (existsSync(input.root)) {
|
||||
try {
|
||||
ensureBrainRootPrivate(input.root);
|
||||
} catch {
|
||||
return blocked('brain-root-permissions-unsafe', []);
|
||||
}
|
||||
}
|
||||
const doctorInput = {
|
||||
registrySource: input.estateRegistrySource,
|
||||
targetGitUrl: input.targetGitUrl,
|
||||
brainNamespace,
|
||||
identity: input.identity,
|
||||
root: input.root,
|
||||
};
|
||||
let report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||
if (report.access.outcome !== 'ok') {
|
||||
return blocked('credential-postcondition-failed', report.findings);
|
||||
}
|
||||
|
||||
const refusalControl = collectBrainRefusalControl(
|
||||
{
|
||||
registrySource: input.estateRegistrySource,
|
||||
targetGitUrl: input.targetGitUrl,
|
||||
brainNamespace,
|
||||
refusalIdentity: input.refusalIdentity,
|
||||
},
|
||||
dependencies.run,
|
||||
);
|
||||
if (!refusalControl.observed) {
|
||||
return blocked('refusal-control-failed', [
|
||||
...report.findings,
|
||||
{
|
||||
code: 'brain-refusal-control-indeterminate',
|
||||
reasonCode: refusalControl.reasonCode,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const owner = await resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: input.estateRegistrySource,
|
||||
ownerPolicySource: input.ownerPolicySource,
|
||||
host: target.host,
|
||||
requestedOwner: input.requestedOwner,
|
||||
},
|
||||
{
|
||||
fetch: dependencies.fetch,
|
||||
absentControlName: dependencies.absentControlName,
|
||||
},
|
||||
);
|
||||
if (owner.verdict !== 'resolved') {
|
||||
const plan = discoverBrainMigration(
|
||||
{
|
||||
sourceRoot: input.sourceRoot,
|
||||
brainRoot: input.root,
|
||||
seat: input.seat,
|
||||
lane: input.lane,
|
||||
laneActive: input.laneActive,
|
||||
},
|
||||
(): MigrationOwnerResolution => owner,
|
||||
);
|
||||
const migration = migrateBrainState(
|
||||
plan,
|
||||
(): never => {
|
||||
throw new Error('blocked owner cannot publish');
|
||||
},
|
||||
input.root,
|
||||
);
|
||||
return blocked(owner.reasonCode, report.findings, owner, migration);
|
||||
}
|
||||
|
||||
if (report.findings.some((finding): boolean => finding.code === 'brain-clone-missing')) {
|
||||
try {
|
||||
ensureBrainRootPrivate(input.root);
|
||||
} catch {
|
||||
return failed('brain-root-permissions-unsafe', report.findings);
|
||||
}
|
||||
const clone: CommandRequest = {
|
||||
program: 'git',
|
||||
args: ['clone', '--branch', 'main', '--single-branch', target.cloneUrl, input.root],
|
||||
env: commandEnv(input.identity),
|
||||
};
|
||||
const cloneResult: CommandResult = dependencies.run(clone);
|
||||
if (cloneResult.status !== 0) return failed('brain-clone-failed', report.findings);
|
||||
try {
|
||||
ensureBrainRootPrivate(input.root);
|
||||
} catch {
|
||||
return failed('brain-root-permissions-unsafe', report.findings);
|
||||
}
|
||||
report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||
}
|
||||
|
||||
const blockingCloneFindings = report.findings.filter(
|
||||
(finding): boolean =>
|
||||
finding.code === 'brain-clone-missing' ||
|
||||
finding.code === 'brain-not-git-repository' ||
|
||||
finding.code === 'brain-remote-mismatch' ||
|
||||
finding.code === 'brain-branch-mismatch' ||
|
||||
finding.code === 'brain-uncommitted-state' ||
|
||||
finding.code === 'brain-git-state-indeterminate' ||
|
||||
finding.code === 'brain-root-permissions-unsafe' ||
|
||||
finding.code.startsWith('brain-write-access-'),
|
||||
);
|
||||
if (blockingCloneFindings.length > 0) {
|
||||
return blocked('brain-postcondition-failed', report.findings);
|
||||
}
|
||||
|
||||
let skeletonEntries: readonly MigrationPublishEntry[];
|
||||
try {
|
||||
const skeleton = createBrainSkeleton(input.root);
|
||||
skeletonEntries = skeleton.publicationEntries;
|
||||
} catch {
|
||||
return failed('brain-skeleton-failed', report.findings);
|
||||
}
|
||||
if (skeletonEntries.length > 0) {
|
||||
try {
|
||||
const evidence = publishBrainPaths(
|
||||
{
|
||||
root: input.root,
|
||||
identity: input.identity,
|
||||
entries: skeletonEntries,
|
||||
message: 'chore: seed durable brain layout',
|
||||
},
|
||||
dependencies.run,
|
||||
);
|
||||
if (!evidence.reachable) return failed('brain-skeleton-not-reachable', report.findings);
|
||||
} catch {
|
||||
return failed('brain-skeleton-publish-failed', report.findings);
|
||||
}
|
||||
}
|
||||
|
||||
const plan = discoverBrainMigration(
|
||||
{
|
||||
sourceRoot: input.sourceRoot,
|
||||
brainRoot: input.root,
|
||||
seat: input.seat,
|
||||
lane: input.lane,
|
||||
laneActive: input.laneActive,
|
||||
},
|
||||
(): MigrationOwnerResolution => owner,
|
||||
dependencies.approveMigrationContent,
|
||||
);
|
||||
const migration = migrateBrainState(
|
||||
plan,
|
||||
(brainRoot: string, entries: readonly MigrationPublishEntry[]) =>
|
||||
publishBrainPaths(
|
||||
{
|
||||
root: brainRoot,
|
||||
identity: input.identity,
|
||||
entries,
|
||||
message: `migrate: archive ${input.lane} working memory`,
|
||||
},
|
||||
dependencies.run,
|
||||
),
|
||||
input.root,
|
||||
);
|
||||
|
||||
if (migration.status === 'failed') {
|
||||
return failed('brain-migration-publish-failed', report.findings, owner, migration);
|
||||
}
|
||||
|
||||
report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||
if (report.findings.length > 0) {
|
||||
return blocked('brain-final-postcondition-failed', report.findings, owner, migration);
|
||||
}
|
||||
return {
|
||||
status: 'provisioned',
|
||||
reasonCode: 'brain-provisioned',
|
||||
findings: [],
|
||||
owner: { verdict: owner.verdict, reasonCode: owner.reasonCode },
|
||||
migration: { status: migration.status, reported: migration.reported },
|
||||
};
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface SecureConfigModule {
|
||||
readBrainConfigSecure(path: string, root: string): string;
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadSecureConfig(): Promise<SecureConfigModule> {
|
||||
try {
|
||||
return (await import('./brain-secure-config.js')) as SecureConfigModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`MB-REQ-06 secure brain config reader is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(): { readonly root: string; readonly directory: string; readonly file: string } {
|
||||
const outer = mkdtempSync(join(tmpdir(), 'mosaic-brain-secure-config-'));
|
||||
roots.push(outer);
|
||||
const root = join(outer, 'mosaic');
|
||||
const directory = join(root, 'brain');
|
||||
const file = join(directory, 'owners.json');
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(file, '{"version":1}\n', { mode: 0o600 });
|
||||
return { root, directory, file };
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
vi.restoreAllMocks();
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('security-critical brain configuration reads', (): void => {
|
||||
it('reads a principal-owned non-writable regular file through the secure descriptor path', async (): Promise<void> => {
|
||||
const secure = await loadSecureConfig();
|
||||
const config = fixture();
|
||||
|
||||
expect(secure.readBrainConfigSecure(config.file, config.root)).toBe('{"version":1}\n');
|
||||
});
|
||||
|
||||
it('rejects a managed root owned by a UID other than the running principal', async (): Promise<void> => {
|
||||
const secure = await loadSecureConfig();
|
||||
const config = fixture();
|
||||
if (typeof process.getuid !== 'function') throw new Error('test requires POSIX getuid');
|
||||
const processWithUid = process as typeof process & { getuid: () => number };
|
||||
const actualUid = processWithUid.getuid();
|
||||
vi.spyOn(processWithUid, 'getuid').mockReturnValue(actualUid + 1);
|
||||
|
||||
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
|
||||
/config-ancestor-owner-unsafe/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a group/world-writable policy file', async (): Promise<void> => {
|
||||
const secure = await loadSecureConfig();
|
||||
const config = fixture();
|
||||
chmodSync(config.file, 0o666);
|
||||
|
||||
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
|
||||
/config-file-permissions-unsafe/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a group/world-writable managed ancestor', async (): Promise<void> => {
|
||||
const secure = await loadSecureConfig();
|
||||
const config = fixture();
|
||||
chmodSync(config.directory, 0o777);
|
||||
|
||||
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
|
||||
/config-ancestor-permissions-unsafe/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { dirname, relative, resolve, sep } from 'node:path';
|
||||
import { assertCanonicalContainment, readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
|
||||
const MAX_CONFIG_BYTES = 256 * 1024;
|
||||
const GROUP_OR_OTHER_WRITE = 0o022;
|
||||
|
||||
function currentUid(): number {
|
||||
if (typeof process.getuid !== 'function') {
|
||||
throw new Error('config-owner-check-unsupported');
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function assertOwnedNonWritableDirectory(path: string, uid: number): void {
|
||||
const status = lstatSync(path);
|
||||
if (!status.isDirectory() || status.isSymbolicLink() || status.uid !== uid) {
|
||||
throw new Error('config-ancestor-owner-unsafe');
|
||||
}
|
||||
if ((status.mode & GROUP_OR_OTHER_WRITE) !== 0) {
|
||||
throw new Error('config-ancestor-permissions-unsafe');
|
||||
}
|
||||
}
|
||||
|
||||
export function readBrainConfigSecure(path: string, root: string): string {
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalPath = resolve(path);
|
||||
assertCanonicalContainment(canonicalRoot, canonicalPath);
|
||||
const uid = currentUid();
|
||||
assertOwnedNonWritableDirectory(canonicalRoot, uid);
|
||||
let cursor = canonicalRoot;
|
||||
for (const component of relative(canonicalRoot, dirname(canonicalPath))
|
||||
.split(sep)
|
||||
.filter(Boolean)) {
|
||||
cursor = resolve(cursor, component);
|
||||
assertOwnedNonWritableDirectory(cursor, uid);
|
||||
}
|
||||
|
||||
const snapshot = readRegularFileSecure(canonicalPath, {
|
||||
root: canonicalRoot,
|
||||
maxBytes: MAX_CONFIG_BYTES,
|
||||
});
|
||||
if (snapshot.uid !== uid) throw new Error('config-file-owner-unsafe');
|
||||
if ((snapshot.mode & GROUP_OR_OTHER_WRITE) !== 0) {
|
||||
throw new Error('config-file-permissions-unsafe');
|
||||
}
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(snapshot.content);
|
||||
} catch {
|
||||
throw new Error('config-file-not-utf8');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,569 +0,0 @@
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import {
|
||||
assessCredentialResult,
|
||||
brainRootIsPrivate,
|
||||
deriveBrainTarget,
|
||||
ensureBrainRootPrivate,
|
||||
evaluateBrainDoctor,
|
||||
planBrainDoctorFix,
|
||||
type BrainDoctorFinding,
|
||||
type BrainDoctorObservation,
|
||||
type CredentialAssessment,
|
||||
} from './brain-store.js';
|
||||
|
||||
const COMMIT = /^[0-9a-f]{40}$/;
|
||||
const GIT_OBJECT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
||||
const MAX_PUBLISH_ENTRY_BYTES = 1024 * 1024;
|
||||
|
||||
export interface CommandRequest {
|
||||
readonly program: 'git' | 'mosaic';
|
||||
readonly args: readonly string[];
|
||||
readonly cwd?: string;
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
readonly stdin?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
readonly status: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
export type CommandRunner = (request: CommandRequest) => CommandResult;
|
||||
|
||||
export const systemCommandRunner: CommandRunner = (request: CommandRequest): CommandResult => {
|
||||
const result = spawnSync(request.program, request.args, {
|
||||
cwd: request.cwd,
|
||||
env: { ...process.env, ...request.env },
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
input: request.stdin,
|
||||
});
|
||||
return {
|
||||
status: result.status ?? 127,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? result.error?.message ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
export interface DoctorRuntimeReport {
|
||||
readonly findings: readonly BrainDoctorFinding[];
|
||||
readonly access: CredentialAssessment;
|
||||
readonly refusalControl: {
|
||||
readonly observed: boolean;
|
||||
readonly reasonCode: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BrainRefusalControlResult {
|
||||
readonly observed: boolean;
|
||||
readonly reasonCode: string | null;
|
||||
readonly gitReasonCode: string;
|
||||
readonly apiReasonCode: string;
|
||||
}
|
||||
|
||||
export interface PublishEvidence {
|
||||
readonly commit: string;
|
||||
readonly remoteHead: string;
|
||||
readonly reachable: boolean;
|
||||
}
|
||||
|
||||
function commandEnv(identity: string): Readonly<Record<string, string>> {
|
||||
return {
|
||||
MOSAIC_GIT_IDENTITY: identity,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
};
|
||||
}
|
||||
|
||||
function integrationFailure(): CredentialAssessment {
|
||||
return {
|
||||
outcome: 'indeterminate',
|
||||
exitCode: 30,
|
||||
reasonCode: 'unexpected-provider-shape',
|
||||
diagnostic: 'indeterminate: unexpected-provider-shape',
|
||||
};
|
||||
}
|
||||
|
||||
function runGit(run: CommandRunner, identity: string, args: readonly string[]): CommandResult {
|
||||
return run({ program: 'git', args, env: commandEnv(identity) });
|
||||
}
|
||||
|
||||
function runGitWithEnv(
|
||||
run: CommandRunner,
|
||||
identity: string,
|
||||
args: readonly string[],
|
||||
env: Readonly<Record<string, string>>,
|
||||
stdin?: Uint8Array,
|
||||
): CommandResult {
|
||||
return run({ program: 'git', args, env: { ...commandEnv(identity), ...env }, stdin });
|
||||
}
|
||||
|
||||
export function collectBrainDoctorReport(
|
||||
input: {
|
||||
readonly registrySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly brainNamespace: string;
|
||||
readonly identity: string;
|
||||
readonly root: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): DoctorRuntimeReport {
|
||||
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
||||
const validation = run({
|
||||
program: 'mosaic',
|
||||
args: [
|
||||
'cred',
|
||||
'validate',
|
||||
input.identity,
|
||||
'--estate',
|
||||
target.estate,
|
||||
'--host',
|
||||
target.host,
|
||||
'--repo',
|
||||
target.repo,
|
||||
'--require',
|
||||
'write',
|
||||
'--json',
|
||||
],
|
||||
env: commandEnv(input.identity),
|
||||
});
|
||||
let access = assessCredentialResult(validation.stdout, {
|
||||
identity: input.identity,
|
||||
estate: target.estate,
|
||||
host: target.host,
|
||||
repo: target.repo,
|
||||
});
|
||||
if (validation.status !== access.exitCode) access = integrationFailure();
|
||||
|
||||
const rootExists = existsSync(input.root);
|
||||
let gitRepository = false;
|
||||
let remote: string | null = null;
|
||||
let branch: string | null = null;
|
||||
let worktreeState: BrainDoctorObservation['worktreeState'] = 'unmeasurable';
|
||||
if (rootExists) {
|
||||
const repository = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'rev-parse',
|
||||
'--is-inside-work-tree',
|
||||
]);
|
||||
gitRepository = repository.status === 0 && repository.stdout.trim() === 'true';
|
||||
if (gitRepository) {
|
||||
const remoteResult = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'remote',
|
||||
'get-url',
|
||||
'origin',
|
||||
]);
|
||||
const branchResult = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'branch',
|
||||
'--show-current',
|
||||
]);
|
||||
const statusResult = runGit(run, input.identity, ['-C', input.root, 'status', '--porcelain']);
|
||||
if (remoteResult.status === 0) remote = remoteResult.stdout.trim();
|
||||
if (branchResult.status === 0) branch = branchResult.stdout.trim();
|
||||
if (statusResult.status === 0) {
|
||||
worktreeState = statusResult.stdout.trim().length > 0 ? 'dirty' : 'clean';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observation: BrainDoctorObservation = {
|
||||
rootExists,
|
||||
rootPrivate: rootExists && brainRootIsPrivate(input.root),
|
||||
gitRepository,
|
||||
remote,
|
||||
branch,
|
||||
worktreeState,
|
||||
access,
|
||||
};
|
||||
const refusalMarker = `refused reason=${access.reasonCode}`;
|
||||
const refusalObserved =
|
||||
validation.status === 10 &&
|
||||
access.outcome === 'refused' &&
|
||||
access.reasonCode === 'no-token-for-identity' &&
|
||||
validation.stderr.includes(refusalMarker);
|
||||
return {
|
||||
findings: evaluateBrainDoctor(observation, target.cloneUrl),
|
||||
access,
|
||||
refusalControl: {
|
||||
observed: refusalObserved,
|
||||
reasonCode: refusalObserved ? access.reasonCode : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function collectBrainRefusalControl(
|
||||
input: {
|
||||
readonly registrySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly brainNamespace: string;
|
||||
readonly refusalIdentity: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): BrainRefusalControlResult {
|
||||
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(input.refusalIdentity)) {
|
||||
return {
|
||||
observed: false,
|
||||
reasonCode: 'permission-evidence-disagrees',
|
||||
gitReasonCode: 'invalid-control-identity',
|
||||
apiReasonCode: 'invalid-control-identity',
|
||||
};
|
||||
}
|
||||
const apiResult = run({
|
||||
program: 'mosaic',
|
||||
args: [
|
||||
'cred',
|
||||
'validate',
|
||||
input.refusalIdentity,
|
||||
'--estate',
|
||||
target.estate,
|
||||
'--host',
|
||||
target.host,
|
||||
'--repo',
|
||||
target.repo,
|
||||
'--require',
|
||||
'write',
|
||||
'--json',
|
||||
],
|
||||
env: commandEnv(input.refusalIdentity),
|
||||
});
|
||||
let api = assessCredentialResult(apiResult.stdout, {
|
||||
identity: input.refusalIdentity,
|
||||
estate: target.estate,
|
||||
host: target.host,
|
||||
repo: target.repo,
|
||||
});
|
||||
if (apiResult.status !== api.exitCode) api = integrationFailure();
|
||||
|
||||
const gitResult = runGit(run, input.refusalIdentity, ['ls-remote', target.cloneUrl, 'HEAD']);
|
||||
const marker = /(?:^|\s)reason=([a-z0-9-]+)(?:\s|$)/.exec(gitResult.stderr)?.[1];
|
||||
const stableRefusals = new Set([
|
||||
'identity-required',
|
||||
'estate-required',
|
||||
'estate-host-mismatch',
|
||||
'cross-estate-resolution',
|
||||
'no-token-for-identity',
|
||||
'tea-login-missing',
|
||||
'tea-login-host-mismatch',
|
||||
'provider-identity-mismatch',
|
||||
'credential-rejected',
|
||||
'permission-denied',
|
||||
'organization-membership-required',
|
||||
'team-membership-required',
|
||||
]);
|
||||
const gitReasonCode =
|
||||
gitResult.status === 0
|
||||
? 'transport-accepted'
|
||||
: marker !== undefined && stableRefusals.has(marker) && gitResult.stdout.length === 0
|
||||
? marker
|
||||
: 'transport-indeterminate';
|
||||
const observed =
|
||||
api.outcome === 'refused' &&
|
||||
gitReasonCode !== 'transport-accepted' &&
|
||||
gitReasonCode !== 'transport-indeterminate' &&
|
||||
gitReasonCode === api.reasonCode;
|
||||
return {
|
||||
observed,
|
||||
reasonCode: observed ? api.reasonCode : 'permission-evidence-disagrees',
|
||||
gitReasonCode,
|
||||
apiReasonCode: api.reasonCode,
|
||||
};
|
||||
}
|
||||
|
||||
export function repairBrainDoctor(
|
||||
input: {
|
||||
readonly registrySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly brainNamespace: string;
|
||||
readonly identity: string;
|
||||
readonly root: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): DoctorRuntimeReport {
|
||||
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
||||
let report = collectBrainDoctorReport(input, run);
|
||||
const actions = planBrainDoctorFix({
|
||||
findings: report.findings,
|
||||
target,
|
||||
identity: input.identity,
|
||||
root: input.root,
|
||||
});
|
||||
for (const action of actions) {
|
||||
if (action.program === 'mosaic') {
|
||||
run({ program: 'mosaic', args: action.args, env: commandEnv(input.identity) });
|
||||
report = collectBrainDoctorReport(input, run);
|
||||
if (report.access.outcome !== 'ok') return report;
|
||||
continue;
|
||||
}
|
||||
if (action.findingCode === 'brain-clone-missing') {
|
||||
try {
|
||||
ensureBrainRootPrivate(input.root);
|
||||
} catch {
|
||||
return collectBrainDoctorReport(input, run);
|
||||
}
|
||||
}
|
||||
const result = runGit(run, input.identity, action.args);
|
||||
if (result.status !== 0) return collectBrainDoctorReport(input, run);
|
||||
}
|
||||
return collectBrainDoctorReport(input, run);
|
||||
}
|
||||
|
||||
function requireSuccess(result: CommandResult, operation: string): void {
|
||||
if (result.status !== 0) throw new Error(`${operation}-failed`);
|
||||
}
|
||||
|
||||
function containedRelative(root: string, path: string): string {
|
||||
if (isAbsolute(path) === false) throw new Error('brain-publish-path-must-be-absolute');
|
||||
const absoluteRoot = resolve(root);
|
||||
const absolutePath = resolve(path);
|
||||
if (absolutePath === absoluteRoot || !absolutePath.startsWith(`${absoluteRoot}${sep}`)) {
|
||||
throw new Error('brain-publish-path-escaped-root');
|
||||
}
|
||||
return relative(absoluteRoot, absolutePath).split(sep).join('/');
|
||||
}
|
||||
|
||||
export function publishBrainPaths(
|
||||
input: {
|
||||
readonly root: string;
|
||||
readonly identity: string;
|
||||
readonly entries: readonly {
|
||||
readonly path: string;
|
||||
readonly content: Uint8Array;
|
||||
}[];
|
||||
readonly message: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): PublishEvidence {
|
||||
if (input.entries.length === 0) throw new Error('brain-publish-paths-empty');
|
||||
if (input.message.trim().length === 0) throw new Error('brain-publish-message-empty');
|
||||
const entries = input.entries.map((entry) => {
|
||||
if (entry.content.byteLength > MAX_PUBLISH_ENTRY_BYTES) {
|
||||
throw new Error('brain-publish-entry-too-large');
|
||||
}
|
||||
return {
|
||||
path: containedRelative(input.root, entry.path),
|
||||
content: Uint8Array.from(entry.content),
|
||||
};
|
||||
});
|
||||
const paths = entries.map((entry): string => entry.path);
|
||||
if (new Set(paths).size !== paths.length) throw new Error('brain-publish-path-duplicate');
|
||||
|
||||
const readHead = (): string => {
|
||||
const result = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'HEAD']);
|
||||
requireSuccess(result, 'brain-git-read-commit');
|
||||
const value = result.stdout.trim();
|
||||
if (!COMMIT.test(value)) throw new Error('brain-git-commit-shape-invalid');
|
||||
return value;
|
||||
};
|
||||
const verifyCommitIdentity = (commit: string): void => {
|
||||
const result = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'show',
|
||||
'-s',
|
||||
'--format=%an%x00%ae%x00%cn%x00%ce',
|
||||
commit,
|
||||
]);
|
||||
requireSuccess(result, 'brain-git-read-commit-identity');
|
||||
const expectedEmail = `${input.identity}@fleet.mosaicstack.dev`;
|
||||
const [author, authorEmail, committer, committerEmail] = result.stdout.trimEnd().split('\0');
|
||||
if (
|
||||
author !== input.identity ||
|
||||
authorEmail !== expectedEmail ||
|
||||
committer !== input.identity ||
|
||||
committerEmail !== expectedEmail
|
||||
) {
|
||||
throw new Error('brain-git-commit-identity-mismatch');
|
||||
}
|
||||
};
|
||||
const expectedObjects = new Map<string, string>();
|
||||
const verifyCommitObjects = (commit: string): void => {
|
||||
for (const [path, expected] of expectedObjects) {
|
||||
const result = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'rev-parse',
|
||||
`${commit}:${path}`,
|
||||
]);
|
||||
requireSuccess(result, 'brain-git-read-commit-object');
|
||||
if (result.stdout.trim() !== expected) throw new Error('brain-git-commit-content-mismatch');
|
||||
}
|
||||
};
|
||||
const reconcileRealIndex = (): void => {
|
||||
for (const [path, objectId] of expectedObjects) {
|
||||
requireSuccess(
|
||||
runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'update-index',
|
||||
'--add',
|
||||
'--cacheinfo',
|
||||
'100644',
|
||||
objectId,
|
||||
path,
|
||||
]),
|
||||
'brain-git-reconcile-checkout-index',
|
||||
);
|
||||
}
|
||||
};
|
||||
const verifyCommitPaths = (commit: string): void => {
|
||||
const changed = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'diff-tree',
|
||||
'--root',
|
||||
'--no-commit-id',
|
||||
'--name-only',
|
||||
'-r',
|
||||
'-z',
|
||||
commit,
|
||||
]);
|
||||
requireSuccess(changed, 'brain-git-read-commit-paths');
|
||||
const names = changed.stdout.split('\0').filter(Boolean);
|
||||
const approved = new Set(paths);
|
||||
if (names.length === 0 || names.some((name: string): boolean => !approved.has(name))) {
|
||||
throw new Error('brain-git-commit-paths-unapproved');
|
||||
}
|
||||
};
|
||||
|
||||
const base = readHead();
|
||||
const indexRoot = mkdtempSync(join(tmpdir(), 'mosaic-brain-index-'));
|
||||
const isolatedEnv = { GIT_INDEX_FILE: join(indexRoot, 'index') };
|
||||
let commit = base;
|
||||
let createdCommit = false;
|
||||
try {
|
||||
requireSuccess(
|
||||
runGitWithEnv(run, input.identity, ['-C', input.root, 'read-tree', base], isolatedEnv),
|
||||
'brain-git-isolated-index-init',
|
||||
);
|
||||
for (const entry of entries) {
|
||||
const object = runGitWithEnv(
|
||||
run,
|
||||
input.identity,
|
||||
['-C', input.root, 'hash-object', '-w', '--stdin'],
|
||||
isolatedEnv,
|
||||
entry.content,
|
||||
);
|
||||
requireSuccess(object, 'brain-git-write-approved-object');
|
||||
const objectId = object.stdout.trim();
|
||||
if (!GIT_OBJECT.test(objectId)) throw new Error('brain-git-object-shape-invalid');
|
||||
expectedObjects.set(entry.path, objectId);
|
||||
requireSuccess(
|
||||
runGitWithEnv(
|
||||
run,
|
||||
input.identity,
|
||||
[
|
||||
'-C',
|
||||
input.root,
|
||||
'update-index',
|
||||
'--add',
|
||||
'--cacheinfo',
|
||||
'100644',
|
||||
objectId,
|
||||
entry.path,
|
||||
],
|
||||
isolatedEnv,
|
||||
),
|
||||
'brain-git-stage-approved-object',
|
||||
);
|
||||
}
|
||||
const difference = runGitWithEnv(
|
||||
run,
|
||||
input.identity,
|
||||
['-C', input.root, 'diff', '--cached', '--quiet', '--exit-code', base, '--', ...paths],
|
||||
isolatedEnv,
|
||||
);
|
||||
if (difference.status === 1) {
|
||||
requireSuccess(
|
||||
runGitWithEnv(
|
||||
run,
|
||||
input.identity,
|
||||
[
|
||||
'-C',
|
||||
input.root,
|
||||
'-c',
|
||||
`user.name=${input.identity}`,
|
||||
'-c',
|
||||
`user.email=${input.identity}@fleet.mosaicstack.dev`,
|
||||
'commit',
|
||||
'-m',
|
||||
input.message,
|
||||
],
|
||||
isolatedEnv,
|
||||
),
|
||||
'brain-git-commit',
|
||||
);
|
||||
commit = readHead();
|
||||
verifyCommitPaths(commit);
|
||||
verifyCommitObjects(commit);
|
||||
verifyCommitIdentity(commit);
|
||||
reconcileRealIndex();
|
||||
createdCommit = true;
|
||||
} else if (difference.status !== 0) {
|
||||
throw new Error('brain-git-isolated-diff-failed');
|
||||
}
|
||||
} finally {
|
||||
rmSync(indexRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
let pushed = !createdCommit;
|
||||
for (let attempt = 0; createdCommit && attempt < 3; attempt += 1) {
|
||||
const push = runGit(run, input.identity, ['-C', input.root, 'push', 'origin', 'HEAD:main']);
|
||||
if (push.status === 0) {
|
||||
pushed = true;
|
||||
break;
|
||||
}
|
||||
const concurrentUpdate = /non-fast-forward|fetch first|\[rejected\]/i.test(push.stderr);
|
||||
if (!concurrentUpdate || attempt === 2) throw new Error('brain-git-push-failed');
|
||||
requireSuccess(
|
||||
runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']),
|
||||
'brain-git-fetch-concurrent',
|
||||
);
|
||||
requireSuccess(
|
||||
runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'-c',
|
||||
`user.name=${input.identity}`,
|
||||
'-c',
|
||||
`user.email=${input.identity}@fleet.mosaicstack.dev`,
|
||||
'rebase',
|
||||
'origin/main',
|
||||
]),
|
||||
'brain-git-rebase-concurrent',
|
||||
);
|
||||
commit = readHead();
|
||||
verifyCommitPaths(commit);
|
||||
verifyCommitObjects(commit);
|
||||
verifyCommitIdentity(commit);
|
||||
}
|
||||
if (!pushed) throw new Error('brain-git-push-failed');
|
||||
requireSuccess(
|
||||
runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']),
|
||||
'brain-git-fetch-readback',
|
||||
);
|
||||
const reachableResult = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'merge-base',
|
||||
'--is-ancestor',
|
||||
commit,
|
||||
'origin/main',
|
||||
]);
|
||||
if (reachableResult.status !== 0 && reachableResult.status !== 1) {
|
||||
throw new Error('brain-git-reachability-check-failed');
|
||||
}
|
||||
const remoteResult = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'origin/main']);
|
||||
requireSuccess(remoteResult, 'brain-git-read-remote-head');
|
||||
const remoteHead = remoteResult.stdout.trim();
|
||||
if (!COMMIT.test(remoteHead)) throw new Error('brain-git-remote-head-shape-invalid');
|
||||
return { commit, remoteHead, reachable: reachableResult.status === 0 };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -29,11 +29,6 @@ import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
||||
import {
|
||||
defaultInstalledBrainDoctorOptions,
|
||||
runInstalledBrainDoctorCheck,
|
||||
} from './brain-doctor-check.js';
|
||||
import { systemCommandRunner } from './brain-store-runtime.js';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||
@@ -1262,11 +1257,8 @@ export function registerLaunchCommands(program: Command): void {
|
||||
});
|
||||
}
|
||||
|
||||
// `doctor` — the framework drift audit (bash script), the #869
|
||||
// Point-1 C5 lease-enforcement activation check, and the #1051 per-estate
|
||||
// durable brain check. Both TS checks run before the bash audit and can
|
||||
// force a non-zero result for hard/indeterminate failures.
|
||||
// The lease check reuses C1's
|
||||
// `doctor` — the framework drift audit (bash script) PLUS the #869
|
||||
// Point-1 C5 lease-enforcement activation check (TS, reusing C1's
|
||||
// `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`).
|
||||
// Kept out of the generic `directCommands` loop above because this check
|
||||
// must run and report BEFORE the bash script's own exit, and must be able
|
||||
@@ -1275,29 +1267,14 @@ export function registerLaunchCommands(program: Command): void {
|
||||
// undiagnosed (see lease-doctor-check.ts docstring).
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Health audit — detect drift, lease gaps, and per-estate brain defects')
|
||||
.description('Health audit — detect drift, missing files, and #869 lease-activation gaps')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action(async (_opts: unknown, cmd: Command) => {
|
||||
checkMosaicHome();
|
||||
const leaseCheck = await runLeaseEnforcementDoctorCheck();
|
||||
const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck);
|
||||
const fix = cmd.args.includes('--fix');
|
||||
const brainCheck = runInstalledBrainDoctorCheck(
|
||||
defaultInstalledBrainDoctorOptions(fix),
|
||||
systemCommandRunner,
|
||||
);
|
||||
for (const line of brainCheck.lines) {
|
||||
(brainCheck.status === 'ok' ? console.log : console.error)(line);
|
||||
}
|
||||
const brainCheckFailed =
|
||||
brainCheck.status === 'error' ||
|
||||
(brainCheck.status === 'warn' && cmd.args.includes('--fail-on-warn'));
|
||||
runDoctorScriptAndExit(
|
||||
fwScript('mosaic-doctor'),
|
||||
cmd.args,
|
||||
leaseCheckFailed || brainCheckFailed,
|
||||
);
|
||||
runDoctorScriptAndExit(fwScript('mosaic-doctor'), cmd.args, leaseCheckFailed);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type {
|
||||
ProviderIdentityEvidenceDto,
|
||||
ReceivePackEvidenceDto,
|
||||
RepositoryPermission,
|
||||
RepositoryPermissionEvidenceDto,
|
||||
} from './credential-result.dto.js';
|
||||
|
||||
export interface ResolvedCredential {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly resolutionId: string;
|
||||
readonly secret: Uint8Array;
|
||||
}
|
||||
|
||||
export interface CredentialResolver {
|
||||
resolve(identity: string, estate: string, host: string): Promise<ResolvedCredential | undefined>;
|
||||
}
|
||||
|
||||
export interface GiteaCredentialProvider {
|
||||
readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto>;
|
||||
readRepositoryPermission(
|
||||
resolved: ResolvedCredential,
|
||||
repo: string,
|
||||
): Promise<RepositoryPermissionEvidenceDto>;
|
||||
probeReceivePack(
|
||||
resolved: ResolvedCredential | undefined,
|
||||
repo: string,
|
||||
): Promise<ReceivePackEvidenceDto>;
|
||||
}
|
||||
|
||||
export interface CredentialEstateRegistry {
|
||||
matches(estate: string, host: string): boolean;
|
||||
}
|
||||
|
||||
export interface CredentialValidationDependencies {
|
||||
readonly resolver: CredentialResolver;
|
||||
readonly provider: GiteaCredentialProvider;
|
||||
readonly estateRegistry: CredentialEstateRegistry;
|
||||
}
|
||||
|
||||
export interface GiteaReadValidationRequestDto {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly repo: string;
|
||||
readonly requiredPermission?: RepositoryPermission;
|
||||
}
|
||||
|
||||
export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto {
|
||||
readonly readOnlyControlIdentity: string;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate';
|
||||
export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown';
|
||||
export type RepositoryPermission = 'none' | 'read' | 'write' | 'admin';
|
||||
export type ReceivePackState = 'advertised' | 'refused';
|
||||
|
||||
export interface CredentialReasonDto {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface CredentialSubjectDto {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly repo: string;
|
||||
}
|
||||
|
||||
export interface ProviderIdentityEvidenceDto {
|
||||
readonly login: string;
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface RepositoryPermissionEvidenceDto {
|
||||
readonly effective: RepositoryPermission;
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface ReceivePackEvidenceDto {
|
||||
readonly state: ReceivePackState;
|
||||
readonly principal: string | null;
|
||||
readonly resolutionId: string | null;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface ReadOnlyControlEvidenceDto {
|
||||
readonly identity: string;
|
||||
readonly providerPermission: RepositoryPermission;
|
||||
readonly receivePack: ReceivePackState;
|
||||
}
|
||||
|
||||
export interface WriteDifferentialEvidenceDto {
|
||||
readonly state: 'can-write';
|
||||
readonly credentialBinding: 'same-resolution';
|
||||
readonly transportPrincipal: string;
|
||||
readonly authenticatedReceivePack: 'advertised';
|
||||
readonly readOnlyControl: ReadOnlyControlEvidenceDto;
|
||||
readonly unauthenticatedReceivePack: 'refused';
|
||||
readonly artifactCreated: false;
|
||||
readonly proves: string;
|
||||
readonly doesNotProve: string;
|
||||
}
|
||||
|
||||
export interface TokenCapabilitiesEvidenceDto {
|
||||
readonly state: 'measured' | 'not-measured';
|
||||
readonly scopes: readonly string[];
|
||||
readonly source: 'provider-token-object' | 'runtime-not-authorized';
|
||||
}
|
||||
|
||||
export interface CredentialValidationEvidenceDto {
|
||||
readonly providerIdentity: ProviderIdentityEvidenceDto | null;
|
||||
readonly tokenCapabilities: TokenCapabilitiesEvidenceDto;
|
||||
readonly repositoryPermission: RepositoryPermissionEvidenceDto | null;
|
||||
readonly writeDifferential: WriteDifferentialEvidenceDto | null;
|
||||
}
|
||||
|
||||
export interface CredentialAuditResultDto {
|
||||
readonly journalId: string | null;
|
||||
readonly state: 'not-started' | 'open' | 'sealed';
|
||||
}
|
||||
|
||||
export interface CredentialValidationResultDto {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'validate' | 'whoami';
|
||||
readonly outcome: CredentialOutcome;
|
||||
readonly exitCode: 0 | 10 | 20 | 30;
|
||||
readonly retryable: boolean;
|
||||
readonly subject: CredentialSubjectDto;
|
||||
readonly mutation: CredentialMutationState;
|
||||
readonly reason: CredentialReasonDto;
|
||||
readonly evidence: CredentialValidationEvidenceDto;
|
||||
readonly audit: CredentialAuditResultDto;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export type CredentialProviderKind = 'gitea';
|
||||
|
||||
export interface CredentialHostConfigDto {
|
||||
readonly host: string;
|
||||
readonly provider: CredentialProviderKind;
|
||||
readonly apiBaseUrl: string;
|
||||
readonly tokenPrefix: string;
|
||||
}
|
||||
|
||||
export interface CredentialEstateConfigDto {
|
||||
readonly name: string;
|
||||
readonly readOnlyControlIdentity?: string;
|
||||
readonly inventoryAuthorityIdentity?: string;
|
||||
readonly hosts: readonly CredentialHostConfigDto[];
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
||||
|
||||
const validRegistry = JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('credential estate registry', (): void => {
|
||||
it('requires an exact declared estate-host pair', (): void => {
|
||||
const registry = parseCredentialEstateRegistry(validRegistry);
|
||||
|
||||
expect(registry.matches('homelab', 'git.example.invalid')).toBe(true);
|
||||
expect(registry.matches('usc', 'git.example.invalid')).toBe(false);
|
||||
expect(registry.matches('homelab', 'other.example.invalid')).toBe(false);
|
||||
expect(registry.resolveByHost('git.example.invalid')).toMatchObject({
|
||||
estate: 'homelab',
|
||||
host: { host: 'git.example.invalid', provider: 'gitea' },
|
||||
});
|
||||
expect(registry.resolveByHost('other.example.invalid')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a provider URL whose host differs from the declared host', (): void => {
|
||||
const source = validRegistry.replace(
|
||||
'https://git.example.invalid',
|
||||
'https://other.example.invalid',
|
||||
);
|
||||
|
||||
expect(() => parseCredentialEstateRegistry(source)).toThrow(/api-host-mismatch/);
|
||||
});
|
||||
|
||||
it('rejects one host assigned to multiple estates', (): void => {
|
||||
const source = JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'other',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-other',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => parseCredentialEstateRegistry(source)).toThrow(/duplicate-host/);
|
||||
});
|
||||
|
||||
it('rejects URLs with userinfo, path, query, fragment, trailing slash, or non-HTTPS scheme', (): void => {
|
||||
for (const apiBaseUrl of [
|
||||
'http://git.example.invalid',
|
||||
'https://[email protected]',
|
||||
'https://git.example.invalid/',
|
||||
'https://git.example.invalid/api',
|
||||
'https://git.example.invalid?x=1',
|
||||
'https://git.example.invalid#x',
|
||||
]) {
|
||||
const source = validRegistry.replace('https://git.example.invalid', apiBaseUrl);
|
||||
expect(() => parseCredentialEstateRegistry(source), apiBaseUrl).toThrow(/invalid-api-url/);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a configured read-only control for write validation', (): void => {
|
||||
const registry = parseCredentialEstateRegistry(validRegistry);
|
||||
const withoutControl = parseCredentialEstateRegistry(
|
||||
validRegistry.replace('"readOnlyControlIdentity":"read-control",', ''),
|
||||
);
|
||||
|
||||
expect(registry.readOnlyControl('homelab')).toBe('read-control');
|
||||
expect(() => withoutControl.readOnlyControl('homelab')).toThrow(/read-only-control-missing/);
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import type { CredentialEstateRegistry } from './credential-provider.dto.js';
|
||||
import type { CredentialEstateConfigDto, CredentialHostConfigDto } from './estate-registry.dto.js';
|
||||
|
||||
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const HOST = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
||||
|
||||
const hostSchema = z
|
||||
.object({
|
||||
host: z.string().regex(HOST),
|
||||
provider: z.literal('gitea'),
|
||||
apiBaseUrl: z.string(),
|
||||
tokenPrefix: z.string().regex(NAME),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const estateSchema = z
|
||||
.object({
|
||||
name: z.string().regex(NAME),
|
||||
readOnlyControlIdentity: z.string().regex(IDENTITY).optional(),
|
||||
inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(),
|
||||
hosts: z.array(hostSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const registrySchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
estates: z.array(estateSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export class CredentialEstateRegistryError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(`Credential estate registry rejected: code=${code} ${message}`);
|
||||
this.name = 'CredentialEstateRegistryError';
|
||||
}
|
||||
}
|
||||
|
||||
function validateApiUrl(host: CredentialHostConfigDto): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(host.apiBaseUrl);
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new CredentialEstateRegistryError('invalid-api-url', detail);
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username !== '' ||
|
||||
url.password !== '' ||
|
||||
url.pathname !== '/' ||
|
||||
host.apiBaseUrl !== url.origin ||
|
||||
url.search !== '' ||
|
||||
url.hash !== ''
|
||||
) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'invalid-api-url',
|
||||
'provider API URL must be an HTTPS origin without userinfo, path, query, or fragment',
|
||||
);
|
||||
}
|
||||
if (url.hostname !== host.host) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'api-host-mismatch',
|
||||
'provider API URL hostname does not equal the declared host',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry {
|
||||
private readonly estates: ReadonlyMap<string, CredentialEstateConfigDto>;
|
||||
|
||||
constructor(estates: readonly CredentialEstateConfigDto[]) {
|
||||
this.estates = new Map(
|
||||
estates.map(
|
||||
(estate: CredentialEstateConfigDto): readonly [string, CredentialEstateConfigDto] => [
|
||||
estate.name,
|
||||
estate,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
matches(estate: string, host: string): boolean {
|
||||
return this.resolve(estate, host) !== undefined;
|
||||
}
|
||||
|
||||
resolve(estate: string, host: string): CredentialHostConfigDto | undefined {
|
||||
return this.estates
|
||||
.get(estate)
|
||||
?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host);
|
||||
}
|
||||
|
||||
resolveByHost(
|
||||
host: string,
|
||||
): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined {
|
||||
for (const [estate, config] of this.estates) {
|
||||
const match = config.hosts.find(
|
||||
(candidate: CredentialHostConfigDto): boolean => candidate.host === host,
|
||||
);
|
||||
if (match !== undefined) return { estate, host: match };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
inventoryAuthority(estate: string): string {
|
||||
const identity = this.estates.get(estate)?.inventoryAuthorityIdentity;
|
||||
if (identity === undefined) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'inventory-authority-missing',
|
||||
`estate ${estate} has no delegated inventory authority identity`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
readOnlyControl(estate: string): string {
|
||||
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
|
||||
if (identity === undefined) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'read-only-control-missing',
|
||||
`estate ${estate} has no provider-confirmed read-only control identity`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCredentialEstateRegistry(source: string): ParsedCredentialEstateRegistry {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new CredentialEstateRegistryError('invalid-json', detail);
|
||||
}
|
||||
|
||||
const parsed = registrySchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new CredentialEstateRegistryError(
|
||||
'invalid-schema',
|
||||
parsed.error.issues[0]?.message ?? 'invalid',
|
||||
);
|
||||
}
|
||||
|
||||
const estateNames = new Set<string>();
|
||||
const hostNames = new Set<string>();
|
||||
for (const estate of parsed.data.estates) {
|
||||
if (estateNames.has(estate.name)) {
|
||||
throw new CredentialEstateRegistryError('duplicate-estate', estate.name);
|
||||
}
|
||||
estateNames.add(estate.name);
|
||||
for (const host of estate.hosts) {
|
||||
validateApiUrl(host);
|
||||
if (hostNames.has(host.host)) {
|
||||
throw new CredentialEstateRegistryError('duplicate-host', host.host);
|
||||
}
|
||||
hostNames.add(host.host);
|
||||
}
|
||||
}
|
||||
|
||||
return new ParsedCredentialEstateRegistry(parsed.data.estates);
|
||||
}
|
||||
@@ -22,8 +22,6 @@ export interface SecureFileSnapshot {
|
||||
mode: number;
|
||||
dev: number | bigint;
|
||||
ino: number | bigint;
|
||||
uid: number;
|
||||
gid: number;
|
||||
}
|
||||
|
||||
function sameIdentity(
|
||||
@@ -237,8 +235,6 @@ export function readRegularFileSecure(
|
||||
mode: Number(opened.mode),
|
||||
dev: opened.dev,
|
||||
ino: opened.ino,
|
||||
uid: opened.uid,
|
||||
gid: opened.gid,
|
||||
};
|
||||
} finally {
|
||||
closeDescriptors(openedFile.descriptors);
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs';
|
||||
|
||||
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function run(command, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, options);
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(signal ? `next build terminated by ${signal}` : `next build exited ${code}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function requireRealDirectory(target, { allowMissing = false } = {}) {
|
||||
try {
|
||||
const stats = await lstat(target);
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
throw new Error(`${target} must be a real directory, not a symbolic link.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (allowMissing && error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireBuildLock(root) {
|
||||
const workRoot = path.join(root, '.mosaic-test-work');
|
||||
const lock = path.join(workRoot, 'web-build.lock');
|
||||
const nonce = randomUUID();
|
||||
const owner = JSON.stringify({ pid: process.pid, nonce });
|
||||
const deadline = Date.now() + 120_000;
|
||||
await mkdir(workRoot, { recursive: true });
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await mkdir(lock);
|
||||
await writeFile(path.join(lock, 'owner.json'), owner, { mode: 0o600 });
|
||||
return async () => {
|
||||
const current = await readFile(path.join(lock, 'owner.json'), 'utf8');
|
||||
if (current !== owner) throw new Error('Web build lock ownership changed before release.');
|
||||
const released = `${lock}.released-${nonce}`;
|
||||
await rename(lock, released);
|
||||
await rm(released, { recursive: true, force: true });
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
let lockOwner;
|
||||
try {
|
||||
lockOwner = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8'));
|
||||
} catch (ownerError) {
|
||||
if (ownerError.code === 'ENOENT') {
|
||||
await delay(25);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Web build lock is unreadable at ${lock}.`, { cause: ownerError });
|
||||
}
|
||||
try {
|
||||
process.kill(lockOwner.pid, 0);
|
||||
} catch (processError) {
|
||||
if (processError.code !== 'ESRCH') throw processError;
|
||||
const stale = `${lock}.stale-${nonce}`;
|
||||
try {
|
||||
await rename(lock, stale);
|
||||
await rm(stale, { recursive: true, force: true });
|
||||
} catch (renameError) {
|
||||
if (renameError.code !== 'ENOENT') throw renameError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await delay(25);
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for the web build lock at ${lock}.`);
|
||||
}
|
||||
|
||||
export async function buildWeb({
|
||||
root = scriptRoot,
|
||||
fingerprint = sourceFingerprint,
|
||||
runBuild = async (webDir) =>
|
||||
run(path.join(webDir, 'node_modules', '.bin', 'next'), ['build'], {
|
||||
cwd: webDir,
|
||||
stdio: 'inherit',
|
||||
}),
|
||||
} = {}) {
|
||||
const releaseLock = await acquireBuildLock(root);
|
||||
try {
|
||||
const webDir = path.join(root, 'apps', 'web');
|
||||
const nextDir = path.join(webDir, '.next');
|
||||
const certificationMarker = path.join(nextDir, '.mosaic-source-hash');
|
||||
const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest');
|
||||
const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`;
|
||||
const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`;
|
||||
const before = await fingerprint(root);
|
||||
|
||||
await requireRealDirectory(nextDir, { allowMissing: true });
|
||||
await Promise.all([
|
||||
rm(certificationMarker, { force: true }),
|
||||
rm(symlinkManifest, { force: true }),
|
||||
]);
|
||||
await runBuild(webDir);
|
||||
await requireRealDirectory(nextDir);
|
||||
|
||||
const after = await fingerprint(root);
|
||||
if (after !== before) {
|
||||
throw new Error(
|
||||
'Web build inputs changed during next build; generated output was not certified.',
|
||||
);
|
||||
}
|
||||
|
||||
const manifestContents = await generatedSymlinkManifest(nextDir);
|
||||
const certificationContents = `${JSON.stringify({
|
||||
version: 1,
|
||||
sourceFingerprint: before,
|
||||
symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'),
|
||||
})}\n`;
|
||||
await Promise.all([
|
||||
writeFile(certificationTemporary, certificationContents, { mode: 0o600 }),
|
||||
writeFile(manifestTemporary, manifestContents, { mode: 0o600 }),
|
||||
]);
|
||||
// The certification marker is the commit point. Publishing the manifest first
|
||||
// leaves interrupted builds untrusted because the marker remains absent.
|
||||
await rename(manifestTemporary, symlinkManifest);
|
||||
await rename(certificationTemporary, certificationMarker);
|
||||
} finally {
|
||||
await releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await buildWeb();
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildWeb } from './build-web.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `build-web-${process.pid}`);
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, 'apps', 'web', '.next'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('a successful web build atomically publishes its source and symlink certification', async () => {
|
||||
const root = await fixture('success');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
|
||||
await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} });
|
||||
|
||||
assert.deepEqual(JSON.parse(await readFile(marker, 'utf8')), {
|
||||
version: 1,
|
||||
sourceFingerprint: 'certified',
|
||||
symlinkManifestHash: '8a5a375cea6a55d24bd5f875856da63feba33adbefb15a92a0007719b84bcf11',
|
||||
});
|
||||
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||
});
|
||||
|
||||
test('a failed web build leaves no certification marker', async () => {
|
||||
const root = await fixture('failure');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
await writeFile(marker, 'stale\n');
|
||||
await writeFile(manifest, 'stale\n');
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'before',
|
||||
runBuild: async () => {
|
||||
throw new Error('build failed');
|
||||
},
|
||||
}),
|
||||
/build failed/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
});
|
||||
|
||||
test('overlapping web builds are serialized while the marker remains absent', async () => {
|
||||
const root = await fixture('overlap');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
await writeFile(marker, 'stale\n');
|
||||
await writeFile(manifest, 'stale\n');
|
||||
let releaseFirst;
|
||||
let secondEntered = false;
|
||||
const firstEntered = new Promise((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let markFirstEntered;
|
||||
const firstStarted = new Promise((resolve) => {
|
||||
markFirstEntered = resolve;
|
||||
});
|
||||
|
||||
const first = buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
markFirstEntered();
|
||||
await firstEntered;
|
||||
},
|
||||
});
|
||||
await firstStarted;
|
||||
const second = buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
secondEntered = true;
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
assert.equal(secondEntered, false);
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
|
||||
releaseFirst();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(secondEntered, true);
|
||||
assert.equal(JSON.parse(await readFile(marker, 'utf8')).sourceFingerprint, 'certified');
|
||||
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||
});
|
||||
|
||||
test('a build that replaces .next with a symbolic link cannot publish outside the checkout', async () => {
|
||||
const root = await fixture('symbolic-next');
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
const outside = path.join(root, 'outside-generated');
|
||||
await mkdir(outside);
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
await rm(nextDir, { recursive: true });
|
||||
await symlink(outside, nextDir);
|
||||
},
|
||||
}),
|
||||
/must be a real directory/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(path.join(outside, '.mosaic-source-hash')), false);
|
||||
assert.equal(await exists(path.join(outside, '.mosaic-symlink-manifest')), false);
|
||||
});
|
||||
|
||||
test('inputs changed during a web build are not certified', async () => {
|
||||
const root = await fixture('changed-inputs');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
const fingerprints = ['before', 'after'];
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => fingerprints.shift(),
|
||||
runBuild: async () => {},
|
||||
}),
|
||||
/inputs changed during next build/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { access, mkdir, rename, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const generated = path.join(root, 'apps', 'web', '.next');
|
||||
const quarantineRoot = path.join(root, '.mosaic-test-work', 'generated-quarantine');
|
||||
|
||||
try {
|
||||
await access(generated);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') process.exit(0);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await mkdir(quarantineRoot, { recursive: true });
|
||||
const quarantine = path.join(quarantineRoot, `web-next-${Date.now()}-${process.pid}`);
|
||||
try {
|
||||
await rename(generated, quarantine);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`MOSAIC_GENERATED_CLEAN_FAILED: could not quarantine apps/web/.next. Fix: sudo rm -rf '${generated}', then rerun pnpm preflight`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(quarantine, { recursive: true, force: true });
|
||||
} catch {
|
||||
console.warn(
|
||||
`Generated state was deactivated but could not be deleted; quarantined at ${quarantine}`,
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { access, lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function run(command, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, options);
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(signal ? `husky terminated by ${signal}` : `husky exited ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function directorySnapshot(root) {
|
||||
const snapshot = [];
|
||||
async function walk(current) {
|
||||
const children = await readdir(current, { withFileTypes: true });
|
||||
for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const target = path.join(current, child.name);
|
||||
const relative = path.relative(root, target);
|
||||
const stats = await lstat(target);
|
||||
if (child.isDirectory()) {
|
||||
snapshot.push([relative, 'directory', stats.mode & 0o777]);
|
||||
await walk(target);
|
||||
} else {
|
||||
snapshot.push([
|
||||
relative,
|
||||
'file',
|
||||
stats.mode & 0o777,
|
||||
(await readFile(target)).toString('base64'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
async function directoriesMatch(left, right) {
|
||||
return (await directorySnapshot(left)) === (await directorySnapshot(right));
|
||||
}
|
||||
|
||||
export async function installHooks({
|
||||
root = process.cwd(),
|
||||
disabled = process.env.HUSKY === '0',
|
||||
quarantineRoot = path.join(root, '.mosaic-test-work', 'husky-quarantine'),
|
||||
runHusky = async (_stagingHooks, stagingRepo) => {
|
||||
await execFileAsync('git', ['init', '--quiet', stagingRepo]);
|
||||
await run(path.join(root, 'node_modules', '.bin', 'husky'), ['.husky'], {
|
||||
cwd: stagingRepo,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
},
|
||||
activateHooks = async () => {
|
||||
await run('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: root, stdio: 'inherit' });
|
||||
},
|
||||
} = {}) {
|
||||
if (disabled) return;
|
||||
|
||||
const huskyDir = path.join(root, '.husky');
|
||||
const active = path.join(huskyDir, '_');
|
||||
const nonce = `${Date.now()}-${process.pid}`;
|
||||
const stagingRepo = path.join(root, '.mosaic-test-work', `husky-stage-${nonce}`);
|
||||
const stagingHooks = path.join(stagingRepo, '.husky');
|
||||
const quarantined = path.join(quarantineRoot, `${path.basename(root)}-${nonce}`);
|
||||
await mkdir(huskyDir, { recursive: true });
|
||||
await mkdir(quarantineRoot, { recursive: true });
|
||||
|
||||
const previousComplete = (await pathExists(active)) && (await pathExists(path.join(active, 'h')));
|
||||
let previousQuarantined = false;
|
||||
try {
|
||||
if ((await pathExists(active)) && !previousComplete) {
|
||||
await rename(active, quarantined);
|
||||
previousQuarantined = true;
|
||||
}
|
||||
await mkdir(stagingRepo, { recursive: true });
|
||||
await runHusky(stagingHooks, stagingRepo);
|
||||
const staged = path.join(stagingHooks, '_');
|
||||
if (!(await pathExists(path.join(staged, 'h')))) {
|
||||
throw new Error('husky did not produce its required h shim');
|
||||
}
|
||||
if (previousComplete) {
|
||||
if (!(await directoriesMatch(active, staged))) {
|
||||
throw new Error('existing complete hook set differs from the installed Husky version');
|
||||
}
|
||||
await rm(stagingRepo, { recursive: true, force: true });
|
||||
} else {
|
||||
await rename(staged, active);
|
||||
await rm(stagingRepo, { recursive: true, force: true });
|
||||
}
|
||||
await activateHooks();
|
||||
if (previousQuarantined) {
|
||||
try {
|
||||
await rm(quarantined, { recursive: true, force: true });
|
||||
} catch {
|
||||
console.warn(
|
||||
`Previous hook state was deactivated but remains quarantined at ${quarantined}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const cleanupFailures = [];
|
||||
try {
|
||||
if (await pathExists(stagingRepo)) {
|
||||
await rename(stagingRepo, `${quarantined}-staging`);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(`staging hooks: ${cleanupError.message}`);
|
||||
}
|
||||
const cleanup =
|
||||
cleanupFailures.length === 0
|
||||
? 'No partial hook set was activated.'
|
||||
: `Automatic cleanup was incomplete (${cleanupFailures.join('; ')}).`;
|
||||
throw new Error(
|
||||
`Hook installation failed: ${error.message}. ${cleanup} Fix: rm -rf .husky/_ && git config core.hooksPath .husky/_ && pnpm install --frozen-lockfile`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
await installHooks();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { installHooks } from './install-hooks.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `hooks-${process.pid}`);
|
||||
const quarantineRoot = path.join(fixtureRoot, 'quarantine');
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, '.husky'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('an interrupted install quarantines the partial active hook set and fails loudly', async () => {
|
||||
const root = await fixture('interrupted');
|
||||
let restoredHooksPath = 'not-called';
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot,
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'partial');
|
||||
throw new Error('simulated interruption');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async (value) => {
|
||||
restoredHooksPath = value;
|
||||
},
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Hook installation failed/);
|
||||
assert.match(error.message, /pnpm install --frozen-lockfile/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(await exists(path.join(root, '.husky', '_')), false);
|
||||
assert.equal(restoredHooksPath, 'not-called');
|
||||
const quarantined = await readdir(quarantineRoot);
|
||||
assert.equal(quarantined.length, 1);
|
||||
});
|
||||
|
||||
test('a failed replacement restores a previously complete active hook set', async () => {
|
||||
const root = await fixture('rollback');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'previous-complete');
|
||||
let previousRemainedActiveDuringStaging = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'rollback-quarantine'),
|
||||
runHusky: async () => {
|
||||
previousRemainedActiveDuringStaging =
|
||||
(await readFile(activeShim, 'utf8')) === 'previous-complete';
|
||||
throw new Error('simulated replacement failure');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => '.husky/_',
|
||||
restoreHooksPath: async () => {},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(previousRemainedActiveDuringStaging, true);
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'previous-complete');
|
||||
});
|
||||
|
||||
test('a mismatched complete hook set fails loudly instead of reporting a stale install as current', async () => {
|
||||
const root = await fixture('mismatch');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'old-complete');
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'mismatch-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'new-complete');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => '.husky/_',
|
||||
restoreHooksPath: async () => {},
|
||||
}),
|
||||
/Hook installation failed.*pnpm install --frozen-lockfile/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'old-complete');
|
||||
});
|
||||
|
||||
test('a competing successful installer is not removed by the losing process', async () => {
|
||||
const root = await fixture('concurrent');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
let restored = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'concurrent-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'ours');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'peer');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {
|
||||
restored = true;
|
||||
},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'peer');
|
||||
assert.equal(restored, false);
|
||||
});
|
||||
|
||||
test("a competing installer that replaces this installer's active set is preserved", async () => {
|
||||
const root = await fixture('concurrent-after-rename');
|
||||
const active = path.join(root, '.husky', '_');
|
||||
const activeShim = path.join(active, 'h');
|
||||
let restored = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'concurrent-after-rename-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'ours');
|
||||
},
|
||||
activateHooks: async () => {
|
||||
await rm(active, { recursive: true, force: true });
|
||||
await mkdir(active, { recursive: true });
|
||||
await writeFile(activeShim, 'peer');
|
||||
throw new Error('our activation lost to peer');
|
||||
},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {
|
||||
restored = true;
|
||||
},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'peer');
|
||||
assert.equal(restored, false);
|
||||
});
|
||||
|
||||
test('an explicit interactive HUSKY=0 opt-out preserves existing hooks without running installer', async () => {
|
||||
const root = await fixture('disabled');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'preserved');
|
||||
let ran = false;
|
||||
|
||||
await installHooks({
|
||||
root,
|
||||
disabled: true,
|
||||
runHusky: async () => {
|
||||
ran = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(ran, false);
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'preserved');
|
||||
});
|
||||
|
||||
test('a successful install leaves a complete active hook set', async () => {
|
||||
const root = await fixture('success');
|
||||
|
||||
await installHooks({
|
||||
root,
|
||||
quarantineRoot,
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'complete');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {},
|
||||
});
|
||||
|
||||
assert.equal(await exists(path.join(root, '.husky', '_', 'h')), true);
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { constants } from 'node:fs';
|
||||
import { access, lstat, readFile, readdir, readlink } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
export const MISSING_DEPS_EXIT = 42;
|
||||
export const GENERATED_STATE_EXIT = 43;
|
||||
|
||||
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
async function entries(root) {
|
||||
const result = [];
|
||||
async function walk(current) {
|
||||
let children;
|
||||
try {
|
||||
children = await readdir(current, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
for (const child of children) {
|
||||
const target = path.join(current, child.name);
|
||||
result.push(target);
|
||||
if (child.isDirectory() && !child.isSymbolicLink()) await walk(target);
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function generatedSymlinkManifest(nextDir) {
|
||||
const links = [];
|
||||
for (const target of (await entries(nextDir)).sort()) {
|
||||
const stats = await lstat(target);
|
||||
if (!stats.isSymbolicLink()) continue;
|
||||
links.push({
|
||||
path: path.relative(nextDir, target).split(path.sep).join('/'),
|
||||
target: await readlink(target),
|
||||
});
|
||||
}
|
||||
return `${JSON.stringify({ version: 1, links })}\n`;
|
||||
}
|
||||
|
||||
const webSourceRoots = (root) => [
|
||||
path.join(root, 'apps', 'web', 'src'),
|
||||
path.join(root, 'apps', 'web', 'public'),
|
||||
path.join(root, 'apps', 'web', 'next-env.d.ts'),
|
||||
path.join(root, 'apps', 'web', 'next.config.ts'),
|
||||
path.join(root, 'apps', 'web', 'postcss.config.mjs'),
|
||||
path.join(root, 'apps', 'web', 'package.json'),
|
||||
path.join(root, 'apps', 'web', 'tsconfig.json'),
|
||||
path.join(root, 'packages', 'design-tokens', 'src'),
|
||||
path.join(root, 'packages', 'design-tokens', 'package.json'),
|
||||
path.join(root, 'packages', 'design-tokens', 'tsconfig.json'),
|
||||
path.join(root, 'package.json'),
|
||||
path.join(root, 'tsconfig.base.json'),
|
||||
path.join(root, 'pnpm-lock.yaml'),
|
||||
path.join(root, 'pnpm-workspace.yaml'),
|
||||
path.join(root, 'turbo.json'),
|
||||
];
|
||||
|
||||
// next.config.ts currently reads no server-only environment. Add any future
|
||||
// server-side build inputs here; all resolved NEXT_PUBLIC_* inputs are automatic.
|
||||
const serverBuildEnvironmentKeys = [];
|
||||
|
||||
function publicBuildEnvironment(root) {
|
||||
const webDir = path.join(root, 'apps', 'web');
|
||||
const requireFromWeb = createRequire(path.join(scriptRoot, 'apps', 'web', 'package.json'));
|
||||
const requireFromNext = createRequire(requireFromWeb.resolve('next/package.json'));
|
||||
const { loadEnvConfig, resetEnv, updateInitialEnv } = requireFromNext('@next/env');
|
||||
const originalEnvironment = { ...process.env };
|
||||
updateInitialEnv(originalEnvironment);
|
||||
try {
|
||||
const { combinedEnv } = loadEnvConfig(webDir, false, { info() {}, error() {} }, true);
|
||||
return Object.fromEntries(
|
||||
Object.entries(combinedEnv).filter(
|
||||
([key, value]) =>
|
||||
value !== undefined &&
|
||||
(key.startsWith('NEXT_PUBLIC_') || serverBuildEnvironmentKeys.includes(key)),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
resetEnv();
|
||||
}
|
||||
}
|
||||
|
||||
export async function sourceFingerprint(root = process.cwd()) {
|
||||
const files = [];
|
||||
for (const sourceRoot of webSourceRoots(root)) {
|
||||
try {
|
||||
const stats = await lstat(sourceRoot);
|
||||
if (stats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Web build input must not be a symbolic link: ${path.relative(root, sourceRoot)}`,
|
||||
);
|
||||
}
|
||||
if (stats.isFile()) files.push(sourceRoot);
|
||||
if (stats.isDirectory()) {
|
||||
for (const target of await entries(sourceRoot)) {
|
||||
const targetStats = await lstat(target);
|
||||
if (targetStats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Web build input must not be a symbolic link: ${path.relative(root, target)}`,
|
||||
);
|
||||
}
|
||||
if (targetStats.isFile()) files.push(target);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const digest = createHash('sha256');
|
||||
for (const [key, value] of Object.entries(publicBuildEnvironment(root)).sort()) {
|
||||
digest.update(`env:${key}\0${value.length}\0${value}\0`);
|
||||
}
|
||||
for (const target of files.sort()) {
|
||||
const contents = await readFile(target);
|
||||
digest.update(path.relative(root, target).split(path.sep).join('/'));
|
||||
digest.update('\0');
|
||||
digest.update(String(contents.length));
|
||||
digest.update('\0');
|
||||
digest.update(contents);
|
||||
digest.update('\0');
|
||||
}
|
||||
return digest.digest('hex');
|
||||
}
|
||||
|
||||
export async function runPreflight({ root = process.cwd(), uid = process.getuid?.() } = {}) {
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
const requiredBinaries = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
||||
const missingBinaries = [];
|
||||
for (const binary of requiredBinaries) {
|
||||
try {
|
||||
await access(path.join(binDir, binary), constants.X_OK);
|
||||
} catch {
|
||||
missingBinaries.push(binary);
|
||||
}
|
||||
}
|
||||
if (missingBinaries.length > 0) {
|
||||
return {
|
||||
code: MISSING_DEPS_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_MISSING_DEPS: dependency installation is missing ${missingBinaries.join(', ')}; run pnpm install --frozen-lockfile`,
|
||||
};
|
||||
}
|
||||
|
||||
const buildLock = path.join(root, '.mosaic-test-work', 'web-build.lock');
|
||||
try {
|
||||
await lstat(buildLock);
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_GENERATED_STATE: web build is in progress or interrupted at ${buildLock}; wait for it to finish or rerun pnpm build to recover the stale lock`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
let generated = [];
|
||||
try {
|
||||
const nextStats = await lstat(nextDir);
|
||||
if (!nextStats.isDirectory() || nextStats.isSymbolicLink()) {
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message:
|
||||
'MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next must be a real directory, not a symbolic link, and is not trustworthy; run pnpm clean:generated, then rerun the gate',
|
||||
};
|
||||
}
|
||||
generated = [nextDir, ...(await entries(nextDir))];
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
if (generated.length > 0) {
|
||||
const foreign = [];
|
||||
for (const target of generated) {
|
||||
const stats = await lstat(target);
|
||||
if (uid !== undefined && stats.uid !== uid) foreign.push(path.relative(root, target));
|
||||
}
|
||||
|
||||
// Detects accidental, independent, stale, and foreign-residue mutation of
|
||||
// generated state: the class this check was born from was a five-month-stale
|
||||
// .next whose validator referenced deleted pages and produced 19 phantom TS2307
|
||||
// errors indistinguishable from real type errors.
|
||||
//
|
||||
// Does NOT defend against an actor with same-UID write access to the generated
|
||||
// tree, which can regenerate both the manifest and marker consistently
|
||||
// (CWE-345). No local construction can, absent a trust anchor outside that
|
||||
// actor's authority. RM-59 tracks executor/spine-side attestation.
|
||||
let certification = null;
|
||||
let certifiedManifest = null;
|
||||
try {
|
||||
const [certificationContents, manifestContents] = await Promise.all([
|
||||
readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8'),
|
||||
readFile(path.join(nextDir, '.mosaic-symlink-manifest'), 'utf8'),
|
||||
]);
|
||||
try {
|
||||
const parsed = JSON.parse(certificationContents);
|
||||
if (
|
||||
parsed.version === 1 &&
|
||||
typeof parsed.sourceFingerprint === 'string' &&
|
||||
typeof parsed.symlinkManifestHash === 'string'
|
||||
) {
|
||||
certification = parsed;
|
||||
certifiedManifest = manifestContents;
|
||||
}
|
||||
} catch {
|
||||
// Invalid certification is handled as untrusted generated state below.
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
const stale = certification?.sourceFingerprint !== (await sourceFingerprint(root));
|
||||
const actualManifest = await generatedSymlinkManifest(nextDir);
|
||||
const certifiedManifestHash =
|
||||
certifiedManifest === null
|
||||
? null
|
||||
: createHash('sha256').update(certifiedManifest).digest('hex');
|
||||
const changedSymlinks =
|
||||
certification?.symlinkManifestHash !== certifiedManifestHash ||
|
||||
certifiedManifest !== actualManifest;
|
||||
if (foreign.length > 0 || stale || changedSymlinks) {
|
||||
const reasons = [
|
||||
foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '',
|
||||
stale ? 'generated source fingerprint does not match web source/configuration' : '',
|
||||
changedSymlinks
|
||||
? 'generated symbolic-link manifest does not match the certified build'
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next is not trustworthy (${reasons.join('; ')}); run pnpm clean:generated, then rerun the gate`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { code: 0, message: 'checkout preflight passed' };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await runPreflight();
|
||||
const stream = result.code === 0 ? process.stdout : process.stderr;
|
||||
stream.write(`${result.message}\n`);
|
||||
process.exitCode = result.code;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { runPreflight, sourceFingerprint } from './preflight.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`);
|
||||
|
||||
const requiredBins = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, 'apps', 'web', 'src', 'app'), { recursive: true });
|
||||
await writeFile(path.join(root, 'apps', 'web', 'src', 'app', 'page.tsx'), 'export default 1;\n');
|
||||
return root;
|
||||
}
|
||||
|
||||
async function installRequiredBins(root) {
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await Promise.all(
|
||||
requiredBins.map(async (name) => {
|
||||
const target = path.join(binDir, name);
|
||||
await writeFile(target, '');
|
||||
await chmod(target, 0o755);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function certifyGeneratedState(root, links = []) {
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
await mkdir(nextDir, { recursive: true });
|
||||
const manifest = `${JSON.stringify({ version: 1, links })}\n`;
|
||||
const manifestHash = createHash('sha256').update(manifest).digest('hex');
|
||||
await writeFile(path.join(nextDir, '.mosaic-symlink-manifest'), manifest);
|
||||
await writeFile(
|
||||
path.join(nextDir, '.mosaic-source-hash'),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
sourceFingerprint: await sourceFingerprint(root),
|
||||
symlinkManifestHash: manifestHash,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('missing dependencies have a dedicated exit code and install remediation', async () => {
|
||||
const root = await fixture('missing-deps');
|
||||
const result = await runPreflight({ root });
|
||||
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_MISSING_DEPS/);
|
||||
assert.match(result.message, /run pnpm install/i);
|
||||
});
|
||||
|
||||
test('a partial dependency install keeps the dedicated missing-deps result', async () => {
|
||||
const root = await fixture('partial-deps');
|
||||
await mkdir(path.join(root, 'node_modules', '.bin'), { recursive: true });
|
||||
await writeFile(path.join(root, 'node_modules', '.bin', 'tsc'), '', { mode: 0o755 });
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /turbo/);
|
||||
});
|
||||
|
||||
test('a dangling required dependency shim keeps the dedicated missing-deps result', async () => {
|
||||
const root = await fixture('dangling-deps');
|
||||
await installRequiredBins(root);
|
||||
const turbo = path.join(root, 'node_modules', '.bin', 'turbo');
|
||||
await rm(turbo);
|
||||
await symlink(path.join(root, 'node_modules', 'missing-turbo'), turbo);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /turbo/);
|
||||
});
|
||||
|
||||
test('installed dependencies pass when generated state is absent', async () => {
|
||||
const root = await fixture('clean');
|
||||
await installRequiredBins(root);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
||||
});
|
||||
|
||||
test('foreign-owned generated Next state is identified separately from source errors', async () => {
|
||||
const root = await fixture('foreign-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'generated output');
|
||||
|
||||
const result = await runPreflight({ root, uid: (process.getuid?.() ?? 0) + 1 });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /foreign-owned/);
|
||||
});
|
||||
|
||||
test('a generated marker mismatch is identified separately from source errors', async () => {
|
||||
const root = await fixture('stale-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'stale generated output');
|
||||
await writeFile(path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'), 'old-source');
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /apps\/web\/\.next/);
|
||||
assert.match(result.message, /pnpm clean:generated/);
|
||||
});
|
||||
|
||||
test('generated-state symbolic links are accepted only when exactly build-certified', async (t) => {
|
||||
await t.test('apps/web/.next itself is rejected when it is a symbolic link', async () => {
|
||||
const root = await fixture('symbolic-next-root');
|
||||
await installRequiredBins(root);
|
||||
await writeFile(path.join(root, 'outside-generated'), 'not a Next build\n');
|
||||
await symlink(path.join(root, 'outside-generated'), path.join(root, 'apps', 'web', '.next'));
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /symbolic link/);
|
||||
});
|
||||
|
||||
await t.test('apps/web/.next is rejected when it is not a directory', async () => {
|
||||
const root = await fixture('non-directory-next-root');
|
||||
await installRequiredBins(root);
|
||||
await writeFile(path.join(root, 'apps', 'web', '.next'), 'not a Next build\n');
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /real directory/);
|
||||
});
|
||||
|
||||
await t.test('an added descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-added');
|
||||
await installRequiredBins(root);
|
||||
await certifyGeneratedState(root);
|
||||
await symlink('/etc/hosts', path.join(root, 'apps', 'web', '.next', 'reviewer-symlink'));
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a removed certified descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-removed');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../dependency-one', link);
|
||||
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||
await rm(link);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a retargeted certified descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-retargeted');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../dependency-one', link);
|
||||
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||
await rm(link);
|
||||
await symlink('../dependency-two', link);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a manifest edited to whitelist a rogue symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-tampered-manifest');
|
||||
await installRequiredBins(root);
|
||||
await certifyGeneratedState(root);
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
await symlink('/etc/hosts', path.join(nextDir, 'reviewer-symlink'));
|
||||
await writeFile(
|
||||
path.join(nextDir, '.mosaic-symlink-manifest'),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
links: [{ path: 'reviewer-symlink', target: '/etc/hosts' }],
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('unchanged canonical-style descendant symlinks are accepted', async () => {
|
||||
const root = await fixture('symbolic-next-certified');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(
|
||||
root,
|
||||
'apps',
|
||||
'web',
|
||||
'.next',
|
||||
'standalone',
|
||||
'node_modules',
|
||||
'dependency',
|
||||
);
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../.pnpm/dependency', link);
|
||||
await certifyGeneratedState(root, [
|
||||
{ path: 'standalone/node_modules/dependency', target: '../.pnpm/dependency' },
|
||||
]);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), {
|
||||
code: 0,
|
||||
message: 'checkout preflight passed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('the source fingerprint includes inherited TypeScript configuration', async () => {
|
||||
const root = await fixture('inherited-typescript-config');
|
||||
const config = path.join(root, 'tsconfig.base.json');
|
||||
await writeFile(config, '{"compilerOptions":{"strict":true}}\n');
|
||||
const first = await sourceFingerprint(root);
|
||||
await writeFile(config, '{"compilerOptions":{"strict":false}}\n');
|
||||
const second = await sourceFingerprint(root);
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test('the source fingerprint rejects symbolic-link build inputs', async () => {
|
||||
const root = await fixture('symbolic-source');
|
||||
await writeFile(path.join(root, 'outside.ts'), 'export default 1;\n');
|
||||
await symlink(path.join(root, 'outside.ts'), path.join(root, 'apps', 'web', 'src', 'linked.ts'));
|
||||
|
||||
await assert.rejects(sourceFingerprint(root), /must not be a symbolic link/);
|
||||
});
|
||||
|
||||
test('the source fingerprint includes expanded public web build environment', async () => {
|
||||
const root = await fixture('public-build-environment');
|
||||
const envFile = path.join(root, 'apps', 'web', '.env.production');
|
||||
await writeFile(
|
||||
envFile,
|
||||
'RM01_GATEWAY_URL=https://one.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
|
||||
);
|
||||
const first = await sourceFingerprint(root);
|
||||
await writeFile(
|
||||
envFile,
|
||||
'RM01_GATEWAY_URL=https://two.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
|
||||
);
|
||||
const second = await sourceFingerprint(root);
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test('a matching generation marker accepts incremental output with mixed mtimes', async () => {
|
||||
const root = await fixture('incremental-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'unchanged generated output');
|
||||
await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z'));
|
||||
const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts');
|
||||
await writeFile(fresh, 'fresh generated output');
|
||||
await certifyGeneratedState(root);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
||||
});
|
||||
Reference in New Issue
Block a user