Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dd04ecc22 | ||
|
|
3a1203b2f8 | ||
|
|
df4c591ab4 | ||
|
|
4fa2768962 | ||
|
|
aa0a7b5fa2 | ||
|
|
42ac19af48 | ||
|
|
f744f32214 | ||
|
|
8ff7aac0ca | ||
|
|
80a45b1e1c | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 | ||
|
|
5916aeefd6 | ||
|
|
58b971aba3 | ||
|
|
f4fd5967fc | ||
|
|
f65e9ea656 | ||
|
|
f58b3699a6 |
@@ -46,6 +46,10 @@ steps:
|
|||||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||||
# with everything it guards; this direct line keeps one instrument running.
|
# with everything it guards; this direct line keeps one instrument running.
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||||
|
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
|
||||||
|
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||||
|
# joins CI directly rather than the exclusions file.
|
||||||
|
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||||
|
|
||||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||||
|
|||||||
@@ -245,9 +245,21 @@ describe('EnrollmentService.createToken', () => {
|
|||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
|
|
||||||
const expiresMs = new Date(result.expiresAt).getTime();
|
const expiresMs = new Date(result.expiresAt).getTime();
|
||||||
// Should be at most 900s from now
|
|
||||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
// The property under test is CLAMPING: a 9999s request must come back as 900s.
|
||||||
|
// The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below
|
||||||
|
// only has to exceed CI scheduling jitter — it does not need to be tight to keep
|
||||||
|
// the assertion discriminating. A 5s allowance consumes 0.05% of that margin and
|
||||||
|
// an unclamped result still misses by three orders of magnitude.
|
||||||
|
//
|
||||||
|
// It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A
|
||||||
|
// wall-clock budget sized to a fast machine is a flake, not a tighter test.
|
||||||
|
const CI_JITTER_MS = 5_000;
|
||||||
|
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS);
|
||||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
||||||
|
// Explicitly pin the clamp itself, independent of any timing allowance:
|
||||||
|
// unclamped (9999s) would exceed this by ~9_099_000 ms.
|
||||||
|
expect(expiresMs - before).toBeLessThan(1_000_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+62
@@ -146,6 +146,68 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
|
|||||||
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
||||||
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||||
|
|
||||||
|
### Fleet git identity launch propagation (#1043)
|
||||||
|
|
||||||
|
#### Problem and objective
|
||||||
|
|
||||||
|
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
|
||||||
|
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
|
||||||
|
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
|
||||||
|
estates where that refusal is not active. The objective is to make Git identity a deterministic,
|
||||||
|
roster-derived part of the generated launch projection and prove it reaches the launched process.
|
||||||
|
|
||||||
|
#### Normative requirements
|
||||||
|
|
||||||
|
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
|
||||||
|
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
|
||||||
|
tmux launch.
|
||||||
|
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
|
||||||
|
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
|
||||||
|
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
|
||||||
|
against the launched process environment. Source-text/string-presence assertions are insufficient.
|
||||||
|
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
|
||||||
|
removes Git-identity pane propagation and makes the behavioral test fail.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
|
||||||
|
environment projection, including the roster-derived Git identity.
|
||||||
|
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
|
||||||
|
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
|
||||||
|
independent review, and the required RED/green/R7 evidence are recorded before push.
|
||||||
|
|
||||||
|
### Framework shell assertion portability (#1098)
|
||||||
|
|
||||||
|
#### Problem and objective
|
||||||
|
|
||||||
|
The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when
|
||||||
|
`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after
|
||||||
|
the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a
|
||||||
|
nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and
|
||||||
|
make failures carry the observed records needed for diagnosis.
|
||||||
|
|
||||||
|
#### Normative requirements
|
||||||
|
|
||||||
|
1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from
|
||||||
|
the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status
|
||||||
|
can override a successful match.
|
||||||
|
2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries
|
||||||
|
SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI
|
||||||
|
utility implementation.
|
||||||
|
3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records
|
||||||
|
before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only.
|
||||||
|
4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order
|
||||||
|
controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former
|
||||||
|
`grep -q` pipeline returns nonzero from an upstream SIGPIPE.
|
||||||
|
2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and
|
||||||
|
emit the indexed observed argv.
|
||||||
|
3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main
|
||||||
|
Woodpecker pipeline is terminal green before #1098 closes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Exact Cross-Harness Fleet Communications Contract (#766)
|
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Generated environment files are rebuildable projections, not an operator-editabl
|
|||||||
## Launch chain
|
## Launch chain
|
||||||
|
|
||||||
| Layer | Responsibility |
|
| Layer | Responsibility |
|
||||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. |
|
||||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||||
@@ -24,6 +24,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
|
MOSAIC_GIT_IDENTITY=<roster name>
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -33,8 +34,10 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
||||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
split runtime and repository identity authority. The generated launch contract supports `claude`,
|
||||||
|
`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or
|
||||||
|
modifies generated, local, or quarantine state.
|
||||||
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
||||||
it has no generated-launch adapter and cannot be added through this path.
|
it has no generated-launch adapter and cannot be added through this path.
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
The launcher consumes validated data, not shell configuration.
|
The launcher consumes validated data, not shell configuration.
|
||||||
|
|
||||||
1. Read and validate the canonical roster.
|
1. Read and validate the canonical roster.
|
||||||
2. Render deterministic <name>.env.generated data from that roster.
|
2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
|
||||||
3. Parse optional <name>.env.local through a strict allowlist.
|
3. Parse optional <name>.env.local through a strict allowlist.
|
||||||
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
||||||
5. Derive the runtime command from validated runtime/model/reasoning data.
|
5. Reject a Git identity that is unsafe or differs from the generated agent name.
|
||||||
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
|
||||||
|
7. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||||
|
|
||||||
## File precedence and ownership
|
## File precedence and ownership
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ values, credential material, or command text.
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
|
MOSAIC_GIT_IDENTITY=<roster name>
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -44,8 +45,9 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
|
||||||
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
|
||||||
|
`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||||
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
||||||
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
||||||
this projection path.
|
this projection path.
|
||||||
|
|||||||
@@ -13,3 +13,13 @@ Correct for the CI container (runs as root), fatal for EVERY non-root local chec
|
|||||||
### **D-2 / hygiene — husky `prepare` fails `EPERM` copying into root-owned `.husky/_/`.** Repo working
|
### **D-2 / hygiene — husky `prepare` fails `EPERM` copying into root-owned `.husky/_/`.** Repo working
|
||||||
|
|
||||||
tree has root-owned dirs (`.husky/`, repo root) under a non-root agent. Worked around with the intended `HUSKY=0` escape hatch (does NOT disable the existing pre-commit/pre-push hooks).
|
tree has root-owned dirs (`.husky/`, repo root) under a non-root agent. Worked around with the intended `HUSKY=0` escape hatch (does NOT disable the existing pre-commit/pre-push hooks).
|
||||||
|
|
||||||
|
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
||||||
|
|
||||||
|
### **D-5 / P-QUEUE-001 + P-CONFORMANCE-001 — KEYSTONE: an inert gate that erased its own evidence.**
|
||||||
|
|
||||||
|
Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ the CI format gate did not block. An unrelated later PR (#872) then reformatted that file via its own `lint-staged`, so `main` went green again and nobody learned the gate had failed to fire. Verified blob-level under the repo's own config. **Detection must be per-merge-commit against that commit's own tree** — a "is main green today" check reports all-clear on this exact defect. Binding on RM-02/RM-55. Full chain in `TASKS.md` §1a. NOT quiet-patched, by Mos's ruling: patching the symptom destroys the signal.
|
||||||
|
|
||||||
|
### **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.
|
||||||
|
|||||||
+15
-16
@@ -1,6 +1,6 @@
|
|||||||
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
||||||
|
|
||||||
**Phase:** EXECUTING — first PR merged; RM-01 in flight; all 3 decisions ruled.
|
**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`).
|
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
||||||
|
|
||||||
## Head
|
## Head
|
||||||
@@ -17,13 +17,12 @@
|
|||||||
## In-flight
|
## In-flight
|
||||||
|
|
||||||
| Task | Owner | State |
|
| Task | Owner | State |
|
||||||
| ------------------------------------------------- | --------------- | ------------------------------------------------------------------ |
|
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
|
||||||
| PR #1026 docs (mission record + backlog) | mos-remediation | OPEN, retargeted to main, rebased; diff verified docs-only |
|
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
|
||||||
| PR #1025 hygiene | — | **MERGED** 52414605; rev-974 APPROVE + CI #2158 8/8 terminal-green |
|
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
|
||||||
| DECISION-1 wire-in point (charter change) | Mos / Jason | ESCALATED — both planners reject the charter's target |
|
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
|
||||||
| DECISION-2 rollback artifact + availability trade | Jason | ESCALATED |
|
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
|
||||||
| DECISION-3 RM-03 vs parked PR #1023 ownership | Mos | ESCALATED |
|
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
|
||||||
| RM-01 reproducible checkout (unblocks everything) | unassigned | READY TO DISPATCH |
|
|
||||||
|
|
||||||
## Fleet seats
|
## Fleet seats
|
||||||
|
|
||||||
@@ -39,6 +38,14 @@
|
|||||||
- Freeze: LIFTED for this workstream only.
|
- 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: `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.
|
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`.
|
||||||
- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay**
|
- 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.
|
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
|
||||||
|
|
||||||
@@ -72,14 +79,6 @@ PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### **D-5 / P-QUEUE-001 + P-CONFORMANCE-001 — KEYSTONE: an inert gate that erased its own evidence.**
|
|
||||||
|
|
||||||
Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ the CI format gate did not block. An unrelated later PR (#872) then reformatted that file via its own `lint-staged`, so `main` went green again and nobody learned the gate had failed to fire. Verified blob-level under the repo's own config. **Detection must be per-merge-commit against that commit's own tree** — a "is main green today" check reports all-clear on this exact defect. Binding on RM-02/RM-55. Full chain in `TASKS.md` §1a. NOT quiet-patched, by Mos's ruling: patching the symptom destroys the signal.
|
|
||||||
|
|
||||||
### **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:END -->
|
<!-- BOARD-ROLL:END -->
|
||||||
|
|
||||||
## Decisions log
|
## Decisions log
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ mechanically until Build 3 (rotation) makes it automatic.
|
|||||||
|
|
||||||
## On resume (do in order, before any orchestration action)
|
## On resume (do in order, before any orchestration action)
|
||||||
|
|
||||||
1. `cd /src/mosaic-stack` and confirm you are on the remediation working branch.
|
1. `cd /src/mosaic-stack`, then **`git fetch origin remediation/state`**.
|
||||||
|
⚠ **The live board is on the rolling branch `remediation/state`, NOT on `main`.** `main` carries only
|
||||||
|
periodic snapshots, so reading the board from `main` will silently give you a STALE tick. Read the
|
||||||
|
live files at `origin/remediation/state` (e.g. `git show origin/remediation/state:docs/remediation/BOARD.md`),
|
||||||
|
or check that branch out. Every tick is pushed there immediately, so its HEAD is always the newest state.
|
||||||
2. Read `docs/remediation/MISSION.md` — the charter (goal, 4 builds, 15 decisions, sequencing, directives).
|
2. Read `docs/remediation/MISSION.md` — the charter (goal, 4 builds, 15 decisions, sequencing, directives).
|
||||||
3. Read `docs/remediation/BOARD.md` — the LIVE state: current phase, in-flight tasks, fleet seat assignments,
|
3. Read `docs/remediation/BOARD.md` **at `origin/remediation/state`** — the LIVE state: current phase,
|
||||||
gate status. This is your single source of in-flight truth (kept small).
|
in-flight tasks, fleet seat assignments, gate status. Single source of in-flight truth (kept < 8 KB;
|
||||||
|
older entries roll to `BOARD-LEDGER.md` via `board-roll.sh`).
|
||||||
4. Read the discussion checkpoint for full rationale if needed:
|
4. Read the discussion checkpoint for full rationale if needed:
|
||||||
`../jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md` (or the jarvis-brain repo path).
|
`../jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md` (or the jarvis-brain repo path).
|
||||||
5. **Residency attestation (fail-closed):** restate from the reloaded files — (a) the goal in one line, (b) the
|
5. **Residency attestation (fail-closed):** restate from the reloaded files — (a) the goal in one line, (b) the
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Mosaic Stack Remediation — Mission Charter
|
# Mosaic Stack Remediation — Mission Charter
|
||||||
|
|
||||||
**Owner:** project orchestrator `mos-remediation` (Claude, launched in `/src/mosaic-stack`).
|
**Owner:** project orchestrator `mos-remediation` (Claude, launched in `/src/mosaic-stack`).
|
||||||
**Origin:** 2026-07-16..31 fleet lifecycle postmortem. **Status:** PLANNING (task decomposition).
|
**Origin:** 2026-07-16..31 fleet lifecycle postmortem. **Status:** EXECUTING (planning complete; RM-01 in flight).
|
||||||
**HOLD lifted** for this workstream by Jason, 2026-07-31 — "begin full mosaic fleet operation on this."
|
**HOLD lifted** for this workstream by Jason, 2026-07-31 — "begin full mosaic fleet operation on this."
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
@@ -10,19 +10,100 @@ Convert the 15 accepted postmortem remediation proposals into a working, **dogfo
|
|||||||
**North star:** anything with a deterministic right answer moves OUT of the LLM into a deterministic
|
**North star:** anything with a deterministic right answer moves OUT of the LLM into a deterministic
|
||||||
gate/program; the LLM handles only genuine judgment.
|
gate/program; the LLM handles only genuine judgment.
|
||||||
|
|
||||||
|
### First-class principle — observe the property, not the exit code
|
||||||
|
|
||||||
|
> **No write is done until the requested PROPERTY is observed. A success exit code is not evidence.**
|
||||||
|
>
|
||||||
|
> **Success output is designed to be believed.** That is the whole reason the inert-gate class exists
|
||||||
|
> and why P-WRAPPER-001's tri-state (`verified` / `written-unverified` / `failed`) is not optional. The
|
||||||
|
> failure is not carelessness — a green is _engineered_ to be trusted, so trusting it is the default
|
||||||
|
> behaviour of a competent operator, not a lapse.
|
||||||
|
>
|
||||||
|
> Promoted to the charter by Mos (2026-07-31) after the orchestrator committed this exact error: a
|
||||||
|
> `--draft` flag was silently dropped by a wrapper fallback that still exited 0, and the PR was reported
|
||||||
|
> as a draft on the strength of the exit code rather than an observed `draft: true` (D-12). Twelve
|
||||||
|
> failure instances were banked in that session; **three of them were the orchestrator's own.** That
|
||||||
|
> ratio is the point — the mechanism must catch the mechanic too, or it is not a mechanism.
|
||||||
|
>
|
||||||
|
> Operationally: after any write, read back the property you required. Applies to gates, wrappers, PR
|
||||||
|
> flags, commit authorship, file installs, and message delivery alike.
|
||||||
|
|
||||||
|
### First-class principle — pre-registration prevents retrofitting, and nothing else
|
||||||
|
|
||||||
|
> **A pre-registered check set can fail in three distinct ways:**
|
||||||
|
>
|
||||||
|
> | mode | the set is… | found as |
|
||||||
|
> | --------------------------- | ------------------------------------------------- | -------- |
|
||||||
|
> | **WRONG** | a check does not test what it claims | D-8 |
|
||||||
|
> | **INCOMPLETE** | green while a criterion's requirement is untested | D-17 |
|
||||||
|
> | **INTERNALLY INCONSISTENT** | two criteria cannot both hold | D-18 |
|
||||||
|
>
|
||||||
|
> **Pre-registration protects against exactly one thing: retrofitting a check to fit the implementation
|
||||||
|
> it is supposed to judge.** It confers neither correctness, nor coverage, nor consistency. "We
|
||||||
|
> pre-registered the checks" has been treated as though it settled the question — it settles one of
|
||||||
|
> three.
|
||||||
|
>
|
||||||
|
> Promoted to the charter by Mos (2026-07-31). All three modes were found on this mission's own **first
|
||||||
|
> delivery**, by the machinery applied to its own work — not by inspection, and not by looking for them.
|
||||||
|
>
|
||||||
|
> **Enforceable form — RM-02's four clauses.** The registry must establish that: (1) each check is
|
||||||
|
> **right** — proven red for its own stated reason before its green counts; (2) the set **covers** —
|
||||||
|
> every criterion bound to a case that actually exercises it; (3) no two criteria **conflict** —
|
||||||
|
> mutual unsatisfiability is a registry defect discoverable by construction; (4) when a criterion's
|
||||||
|
> meaning changes, the registry **retains original text, restatement, and reason**, so evolution stays
|
||||||
|
> auditable. A criterion with no case that can fail for its own reason is unregistered in substance,
|
||||||
|
> however it reads in the manifest.
|
||||||
|
|
||||||
|
### Corollary — never ship an integrity claim dressed as a property
|
||||||
|
|
||||||
|
> A verification artifact that can be forged by whoever it is meant to catch verifies nothing. If a
|
||||||
|
> manifest, marker, ledger, or receipt is writable by the same actor whose behaviour it certifies, it
|
||||||
|
> **certifies the attack.** Such an artifact must sit inside the integrity envelope it belongs to,
|
||||||
|
> publish atomically, and carry a **tamper negative-control observed red** — otherwise its integrity is
|
||||||
|
> a _claim_, not a _property_.
|
||||||
|
>
|
||||||
|
> **If it cannot be made tamper-evident, say so and reconsider the approach.** Laundering foreign
|
||||||
|
> content as certified is the only unacceptable outcome; an honest "this cannot be verified" is always
|
||||||
|
> available and always preferable.
|
||||||
|
|
||||||
|
### First-class principle — when a property cannot exist at the layer it was specified
|
||||||
|
|
||||||
|
> Some required properties are **impossible at the layer that asked for them** — not hard, impossible.
|
||||||
|
> A local check cannot defend against an actor who can rewrite the check itself. When that happens,
|
||||||
|
> there are exactly three honest moves, and all three are mandatory:
|
||||||
|
>
|
||||||
|
> 1. **Implement what the layer _can_ guarantee.** Partial protection against the class it was actually
|
||||||
|
> born from is worth having.
|
||||||
|
> 2. **State the boundary precisely, in BOTH directions.** What it does _not_ defend, **and** beside it
|
||||||
|
> what it _does_. A reader who sees only the negative dismisses the check as worthless; one who sees
|
||||||
|
> only the positive over-trusts it. **Both together is the honest artifact** — either alone misleads.
|
||||||
|
> 3. **Record where the real guarantee will come from — as a TRACKED DEPENDENCY, not prose.** It must
|
||||||
|
> name a task that someone must close. _A documented gap with no owner becomes a permanent gap that
|
||||||
|
> reads as intentional._
|
||||||
|
>
|
||||||
|
> **A written-down gap is acceptable engineering. An implied-fixed gap is this mission's core failure in
|
||||||
|
> a new costume** — a verification artifact that verifies nothing, with a green to prove it.
|
||||||
|
>
|
||||||
|
> Promoted to the charter by Mos (2026-07-31) from D-19. Origin: the RM-01 symlink manifest could not be
|
||||||
|
> made tamper-evident against a same-UID actor (CWE-345), because the manifest and its marker share one
|
||||||
|
> writable tree. The implementing seat **escalated rather than relabelling self-authentication as
|
||||||
|
> tamper-resistance** — the corollary above firing on its first real adversarial test, on the cheapest
|
||||||
|
> 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.
|
||||||
|
|
||||||
## Decision record (authoritative, immutable)
|
## Decision record (authoritative, immutable)
|
||||||
|
|
||||||
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
||||||
- Site + `annotations.json`: `jarvis-brain/docs/postmortem-spec/site/` (committed, origin/main).
|
- Site + `annotations.json`: `jarvis-brain/docs/postmortem-spec/site/` (committed, origin/main).
|
||||||
- Discussion checkpoint (rich rationale per proposal): `jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`.
|
- Discussion checkpoint (rich rationale per proposal): `jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`.
|
||||||
- Postmortem report: mosaicstack/stack PR #107 (merged 88f4ee04).
|
- Postmortem report: mosaicstack/stack PR #107 (merged 88f4ee04).
|
||||||
- MACP wiring scout (verdict c=STRANDED): `/tmp/macp-wiring-investigation.md` (copy into this dir — see TODO).
|
- MACP wiring scout (verdict c=STRANDED): [`MACP-WIRING-SCOUT.md`](./MACP-WIRING-SCOUT.md) (copied into this dir; TODO discharged). Its findings are sound; its _recommended wire-in point_ is superseded by DECISION-1.
|
||||||
|
|
||||||
## The plan — 15 proposals collapse to 4 builds + hygiene
|
## The plan — 15 proposals collapse to 4 builds + hygiene
|
||||||
|
|
||||||
| Build | Absorbs | What it is |
|
| Build | Absorbs | What it is |
|
||||||
| ------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| **1. One choke-point service** (mechanical enforcer) | MISSION, STATE, AUDIT, WRAPPER, QUEUE | Deterministic program every task/data mutation flows through. **Wire the stranded `@mosaicstack/macp`** in at `mosaic_orchestrator.py::run_single_task` — typed tasks, gate-runner, event ledger, credential binding, tri-state write outcomes. |
|
| **1. One choke-point service** (mechanical enforcer) | MISSION, STATE, AUDIT, WRAPPER, QUEUE | Deterministic program every task/data mutation flows through. **Wire the stranded `@mosaicstack/macp`** — typed tasks, gate-runner, event ledger, credential binding, tri-state write outcomes. ⚠ **Target CORRECTED 2026-07-31 (DECISION-1, Mos):** a new production Node `TaskExecutor` on the **live** dispatch path (`packages/mosaic` launch + `packages/coord`), which Coord/Forge/live-dispatch submit through. **NOT** `mosaic_orchestrator.py::run_single_task` — that controller is `"enabled": false` and references a dispatcher absent from this checkout; wiring it would strand the executor, reproducing this mission's own disease. The Python rail is **deleted**, not ported. Both planners reached this independently. |
|
||||||
| **2. One durable spine + hot path** | (storage under everything) | **PG system-of-record + Redis hot queue** (transactional-outbox). Mission/tasks/state-claims/audit-ledger/comms-inbox all land here. |
|
| **2. One durable spine + hot path** | (storage under everything) | **PG system-of-record + Redis hot queue** (transactional-outbox). Mission/tasks/state-claims/audit-ledger/comms-inbox all land here. |
|
||||||
| **3. Rotation lifecycle** (finish the Mission Control Plane) | LIFECYCLE, CONTRACT, GUIDE, RECOVERY | Coordinator daemon: contract-hash binding, compaction-detected → rotate-not-compact, checkpoint→fresh-session→rehydrate, broker-independent recovery. Deterministic, not an LLM. Reuse `packages/coord`; existing PRD at `docs/mission-control/`. |
|
| **3. Rotation lifecycle** (finish the Mission Control Plane) | LIFECYCLE, CONTRACT, GUIDE, RECOVERY | Coordinator daemon: contract-hash binding, compaction-detected → rotate-not-compact, checkpoint→fresh-session→rehydrate, broker-independent recovery. Deterministic, not an LLM. Reuse `packages/coord`; existing PRD at `docs/mission-control/`. |
|
||||||
| **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. |
|
| **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. |
|
||||||
@@ -37,6 +118,7 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
|
|||||||
## Sequencing (skeleton — adversarial decomposition refines this)
|
## Sequencing (skeleton — adversarial decomposition refines this)
|
||||||
|
|
||||||
1. **Spine + choke-point service** (builds 1+2) — foundation; unlocks MISSION/STATE/AUDIT/WRAPPER/QUEUE at one integration point.
|
1. **Spine + choke-point service** (builds 1+2) — foundation; unlocks MISSION/STATE/AUDIT/WRAPPER/QUEUE at one integration point.
|
||||||
|
(Per DECISION-1, a P0 phase of provable-gate + activation work precedes this; see `TASKS.md` §3.)
|
||||||
2. **Rotation daemon** (build 3) on that spine — the drift fix proper.
|
2. **Rotation daemon** (build 3) on that spine — the drift fix proper.
|
||||||
3. **Comms service** (build 4) — envelope → service → PG/Redis → adapters; retire direct-tmux.
|
3. **Comms service** (build 4) — envelope → service → PG/Redis → adapters; retire direct-tmux.
|
||||||
4. **Hygiene + conformance** (build 5) — fleet convergence, allowlist sync, dogfood harness.
|
4. **Hygiene + conformance** (build 5) — fleet convergence, allowlist sync, dogfood harness.
|
||||||
@@ -50,6 +132,11 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
|
|||||||
(WRAPPER), auto-sync sweep (WORKFLOW), #1018 stale-consumed (INBOX). The fleet is its own test bed.
|
(WRAPPER), auto-sync sweep (WORKFLOW), #1018 stale-consumed (INBOX). The fleet is its own test bed.
|
||||||
- **Orchestration tracking → DB**, hard cutover ("rip off the bandaid"), NO flat-file interim. jarvis-brain
|
- **Orchestration tracking → DB**, hard cutover ("rip off the bandaid"), NO flat-file interim. jarvis-brain
|
||||||
PDA flat-files untouched. Current flat-file tracking runs as-is/unhardened until DB tracking is real, then one clean replace.
|
PDA flat-files untouched. Current flat-file tracking runs as-is/unhardened until DB tracking is real, then one clean replace.
|
||||||
|
- ⚠ **QUALIFIED 2026-07-31 (DECISION-2, Mos):** the DB spine **must NOT be a single-point hard-stop.**
|
||||||
|
A broker-independent / degraded mode **and** a rehearsed rollback artifact are **design requirements**
|
||||||
|
(P-RECOVERY-001), binding now on RM-12, RM-13, RM-23, RM-36 and RM-53. This **supersedes** the earlier
|
||||||
|
orchestrator recommendation to pre-commit "no DB ⇒ the fleet stops" — that answer is _not_ on record.
|
||||||
|
Only the specific availability _target_ remains open, queued for Jason; it does **not** block current work.
|
||||||
|
|
||||||
## The 15 decisions (one-line; full rationale in the checkpoint)
|
## The 15 decisions (one-line; full rationale in the checkpoint)
|
||||||
|
|
||||||
|
|||||||
+516
-1
@@ -93,6 +93,520 @@ and must not be cited as merge evidence. Rely on reviewer clearance + real CI.
|
|||||||
Three independent live instances in a single session — format gate, agent context reset, queue guard —
|
Three independent live instances in a single session — format gate, agent context reset, queue guard —
|
||||||
is the class confirmed, not anecdote.
|
is the class confirmed, not anecdote.
|
||||||
|
|
||||||
|
### D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically
|
||||||
|
|
||||||
|
`rev-974` blocked PR #1027 a second time. **The defect was not in the code — it was in this file**, at
|
||||||
|
D-18's entry, written by the orchestrator.
|
||||||
|
|
||||||
|
Two faults, both mine:
|
||||||
|
|
||||||
|
1. **D-18's AC2 restatement omitted the scope clause** that D-19 later established as mandatory
|
||||||
|
("within an accidental/independent-mutation threat model").
|
||||||
|
2. **D-18 asserted that the tampered-manifest control turns integrity "from a claim into a property."**
|
||||||
|
It does not, and _cannot_. That sentence was written **before** D-19 proved the property impossible
|
||||||
|
at this layer, and was never revised when D-19 landed.
|
||||||
|
|
||||||
|
**The reviewer did not merely read it — it disproved it.** It performed a **same-UID consistent
|
||||||
|
manifest + marker rewrite**, and **preflight passed**. My documented claim was falsified by experiment.
|
||||||
|
Code, README, scratchpad and PR body all stated both threat-model directions correctly; **this file was
|
||||||
|
the only place still overclaiming.**
|
||||||
|
|
||||||
|
**This is two banked findings firing on the orchestrator at once:**
|
||||||
|
|
||||||
|
- **The integrity-claim corollary** — I wrote an integrity _claim_ in the voice of an integrity
|
||||||
|
_property_, in the very document that defines the rule against doing so.
|
||||||
|
- **D-14 (propagation)** — D-19 superseded D-18's assertion. I propagated the consequence into the
|
||||||
|
charter and the delivery conditions, but **not back into D-18 itself.** A ruling that fails to
|
||||||
|
propagate _backwards_ into the finding it supersedes is the same defect as one that fails to
|
||||||
|
propagate forwards, and I did not audit for that direction.
|
||||||
|
|
||||||
|
**Corrected in place**, with the original wording quoted and the empirical disproof recorded, rather
|
||||||
|
than silently rewritten — the same standard demanded of any restated criterion.
|
||||||
|
|
||||||
|
**Requirement on RM-02 (fifth clause).** Documentation asserting a _security or integrity_ property is
|
||||||
|
itself a claim requiring a negative control. Where a document states "X is guaranteed", the registry
|
||||||
|
must hold a case that **fails if X is not guaranteed** — and that case must have been observed red.
|
||||||
|
**Prose is not exempt from the mission's own evidentiary standard**, and prose in the _governing_
|
||||||
|
document least of all: it is the artifact most likely to be quoted as authority long after the code has
|
||||||
|
moved on.
|
||||||
|
|
||||||
|
**Reviewer credit.** rev-974 was briefed that its highest-priority check was "confirm the PR claims no
|
||||||
|
more than it can deliver, and a softened or omitted boundary is a finding even though the code works."
|
||||||
|
It applied that instruction **to the orchestrator's own governing document** and produced an experiment
|
||||||
|
to settle it. That is the review standard this mission is trying to make ordinary.
|
||||||
|
|
||||||
|
### D-19 — an integrity property that cannot exist at the layer it was specified
|
||||||
|
|
||||||
|
Implementing D-18's manifest, the seat + a Codex security review reached **CWE-345**: the symlink
|
||||||
|
manifest and the source-hash marker both live in the **same same-UID writable generated tree**, so an
|
||||||
|
actor with that UID can plant a rogue link, regenerate _both_, retain the fingerprint, and pass. **No
|
||||||
|
local cryptographic construction fixes self-authentication** without a key outside that actor's
|
||||||
|
authority; relocating the marker changes the path, not the authority.
|
||||||
|
|
||||||
|
The seat **escalated rather than describing self-authentication as tamper-resistant** — the explicit
|
||||||
|
failure mode the charter corollary demands. That is the corollary working, on its first real test.
|
||||||
|
|
||||||
|
**Ruling — Option A: scope AC2 to accidental / independent / stale mutation; retain the design.**
|
||||||
|
Rationale, recorded so it can be challenged:
|
||||||
|
|
||||||
|
1. **The undefendable boundary is not the weak link.** An actor with same-UID write can already edit the
|
||||||
|
source, the tests, `scripts/preflight.mjs` itself, and `.husky/*`. If they have that, _nothing_ in the
|
||||||
|
local checkout is trustworthy — hardening the manifest buys no real security while **implying
|
||||||
|
protection that does not exist**, which is worse than the gap.
|
||||||
|
2. **What AC2 is actually for.** These checks exist because a five-month-stale `.next` produced 19
|
||||||
|
phantom `TS2307` errors indistinguishable from real ones (**D-5**). That is staleness, drift and
|
||||||
|
foreign residue — and against that class the design demonstrably works.
|
||||||
|
3. **A real trust anchor arrives later, from this mission's own architecture.** An anchor must live
|
||||||
|
outside the actor's authority; for a fleet running as one user that means a separate service —
|
||||||
|
precisely the **choke-point executor + PG spine** of Builds 1–2, which verify outside the worktree's
|
||||||
|
authority. Hand-rolling key distribution for a local preflight now would duplicate that work badly.
|
||||||
|
4. Option C (structural policy, no manifest) is strictly worse — it cannot detect a **removed** expected link.
|
||||||
|
|
||||||
|
**Option A is acceptable only with honest labelling**, or it becomes the disease it is meant to cure.
|
||||||
|
Conditions (last two added/sharpened by Mos):
|
||||||
|
|
||||||
|
- Threat model stated verbatim in the code **and** the PR; the words _tamper-proof / tamper-evident /
|
||||||
|
secure_ **barred** from that context; the scope carried in AC2's restatement; every control kept
|
||||||
|
RED-first including manifest-only tamper.
|
||||||
|
- **State the boundary in BOTH directions.** Not only what it does _not_ defend (same-UID write; no
|
||||||
|
local construction can) but, beside it, what it **does** defend: accidental / independent / stale /
|
||||||
|
foreign-residue mutation — the **D-5** class it was born from (the five-month `.next` and its 19
|
||||||
|
phantom `TS2307`s). _A reader who sees only the negative dismisses the check as worthless; one who
|
||||||
|
sees only the positive over-trusts it. Both together is the honest artifact._
|
||||||
|
- **The residual risk is a HARD TRACKED DEPENDENCY EDGE, not a comment.** It is **RM-59**, owned by the
|
||||||
|
choke-point executor + spine work (`depends_on: RM-12, RM-21, RM-25`), and the AC2 scope note must
|
||||||
|
cite that id. _"Record where the real guarantee comes from" only holds if the record is a live
|
||||||
|
dependency someone must close._ **A documented gap with no owner becomes a permanent gap that reads
|
||||||
|
as intentional.**
|
||||||
|
|
||||||
|
**The generalizable rule.** When a required property **cannot exist at the layer where it was
|
||||||
|
specified**, the honest moves are: implement what the layer _can_ guarantee, **state the boundary
|
||||||
|
precisely**, and record where the real guarantee will come from. **A known gap that is written down is
|
||||||
|
acceptable; a gap that is implied fixed is not.** Silence here would have shipped a verification
|
||||||
|
artifact that verifies nothing — with a green to prove it.
|
||||||
|
|
||||||
|
### D-18 — two pre-registered criteria were mutually unsatisfiable, discoverable only at implementation
|
||||||
|
|
||||||
|
Implementing D-17's fix surfaced a conflict **between** pre-registered criteria:
|
||||||
|
|
||||||
|
- **AC2** (as written) — reject symlinked generated state.
|
||||||
|
- **AC4** — the canonical `pnpm -w build` succeeds and leaves no residue.
|
||||||
|
|
||||||
|
Verified independently rather than taken on report: `apps/web/next.config.ts:4` sets
|
||||||
|
`output: 'standalone'`, and the built tree contains **42 legitimate pnpm dependency symlinks** under
|
||||||
|
`.next/standalone/node_modules`. A blanket descendant-symlink rejection makes the canonical build fail
|
||||||
|
its own preflight with exit 43. **AC2 read literally is unsatisfiable alongside AC4 under this
|
||||||
|
configuration**, and nothing short of building the tree would have revealed it.
|
||||||
|
|
||||||
|
**Third distinct failure mode of a pre-registered check set**, completing the chain:
|
||||||
|
|
||||||
|
| finding | a pre-registered check set can be… |
|
||||||
|
| ------- | ------------------------------------------------------------------ |
|
||||||
|
| D-8 | **wrong** — a check that does not test what it claims |
|
||||||
|
| D-17 | **incomplete** — green while a criterion's requirement is untested |
|
||||||
|
| D-18 | **internally inconsistent** — two criteria that cannot both hold |
|
||||||
|
|
||||||
|
The implementing seat escalated instead of silently picking a winner. That matters: **quietly resolving
|
||||||
|
a conflict between pre-registered criteria destroys the point of pre-registering them** — the registration
|
||||||
|
exists so that changes of meaning are auditable rather than absorbed.
|
||||||
|
|
||||||
|
**Resolution (orchestrator ruling).** Approved a **build-certified symlink manifest**: `.next` itself is
|
||||||
|
still rejected as a symlink; descendants are rejected unless _exactly_ certified by a manifest the build
|
||||||
|
publishes atomically. Strictly **stronger** than blanket rejection — it also catches a **retargeted**
|
||||||
|
symlink, which blanket rejection cannot distinguish from a legitimate one.
|
||||||
|
|
||||||
|
**AC2 restated (recorded, not absorbed).** _Generated state must reject `.next` itself being a symlink
|
||||||
|
or non-directory, and must reject any descendant symlink not exactly certified by the build manifest —
|
||||||
|
added, removed, retargeted, or manifest-only-tampered all fail with exit 43 — **within an accidental /
|
||||||
|
independent-mutation threat model.**_
|
||||||
|
|
||||||
|
> ⚠ **This entry is superseded in part by [D-19](#d-19). Do not read D-18 standalone.** The scope clause
|
||||||
|
> above is load-bearing: the design **cannot** defend against a same-UID actor, which can rewrite the
|
||||||
|
> manifest and the marker consistently (CWE-345). D-18 was written **before** that impossibility was
|
||||||
|
> established.
|
||||||
|
|
||||||
|
**Hardening required before this counts.** The manifest is itself generated state, so **a manifest
|
||||||
|
writable by whoever plants a rogue symlink certifies the attack** — that is the one way this design
|
||||||
|
fails. It must sit inside the same ownership/fingerprint envelope, published atomically via the existing
|
||||||
|
marker mechanism, with negative controls **observed red first** for: added, removed, retargeted,
|
||||||
|
**manifest-only-tampered**, plus a positive control that the canonical build passes.
|
||||||
|
|
||||||
|
> ⚠ **CORRECTED (D-20).** This paragraph originally ended: _"without it, integrity is a claim rather
|
||||||
|
> than a property."_ **That overclaimed**, by implying the control makes integrity a _property_. It does
|
||||||
|
> not, and cannot. The manifest-only-tamper control detects **independent** mutation of the manifest;
|
||||||
|
> it confers **no authenticity** against an actor who rewrites manifest _and_ marker together.
|
||||||
|
> `rev-974` disproved the original wording empirically — a same-UID consistent manifest+marker rewrite
|
||||||
|
> **passed preflight**. Integrity here remains a scoped **drift-detection** property, never an
|
||||||
|
> authenticity one. See D-19 and the charter principle on properties that cannot exist at their
|
||||||
|
> specified layer.
|
||||||
|
|
||||||
|
**Requirement on RM-02 (fourth clause).** The registry must detect **conflicts between registered
|
||||||
|
criteria**, not only wrongness and coverage. Two criteria that cannot simultaneously hold is a registry
|
||||||
|
defect discoverable by construction — and when a criterion is restated, the registry must retain the
|
||||||
|
original text, the restatement, and the reason, so the evolution stays auditable.
|
||||||
|
|
||||||
|
### D-17 — a pre-registered criterion passed a green suite without being satisfied
|
||||||
|
|
||||||
|
`rev-974` returned **CHANGES REQUESTED** on PR #1027 with one blocking finding, and it is the sharpest
|
||||||
|
instance of the session's theme because it occurred **inside our own verification machinery**.
|
||||||
|
|
||||||
|
**AC2** was pre-registered before any code was written, and explicitly required that **symlinked
|
||||||
|
generated state be rejected**. The implementation does not do it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ln -s /etc/hosts apps/web/.next/reviewer-symlink
|
||||||
|
pnpm preflight # → "checkout preflight passed", exit 0
|
||||||
|
# → required: generated-state exit 43
|
||||||
|
```
|
||||||
|
|
||||||
|
The acceptance suite was **21/21 green** throughout. Confirmed independently rather than relayed:
|
||||||
|
`scripts/preflight.mjs:82-92` rejects symlinks on the **source** path; `:28` merely _skips_ symlinked
|
||||||
|
directories rather than rejecting them; and the **generated-state** path at `:141-163` `lstat`s and
|
||||||
|
checks `uid` (ownership) but **never** calls `isSymbolicLink()`. The suite's only symlink cases
|
||||||
|
(`preflight.test.mjs:59`, `:115`) cover the turbo binary and a _source_ file. No generated-state case
|
||||||
|
exists anywhere.
|
||||||
|
|
||||||
|
**So: criterion pre-registered, suite green, requirement unmet.** Nobody was careless — the coverage gap
|
||||||
|
is _invisible from a green_, which is the entire problem.
|
||||||
|
|
||||||
|
**This sharpens D-8 rather than repeating it.** D-8 established that pre-registration does not confer
|
||||||
|
_correctness_ (a check can be wrong when written). D-17 establishes the adjacent failure:
|
||||||
|
**pre-registration does not confer _coverage_** — a suite can be green, and every registered criterion
|
||||||
|
can appear satisfied, while a criterion's actual requirement is untested. The two together mean a
|
||||||
|
registry of checks needs **two** properties, not one: each check must be _right_, and the set must
|
||||||
|
_actually exercise_ what it claims.
|
||||||
|
|
||||||
|
**Requirement on RM-02 (third clause).** The registry must bind each acceptance criterion to the
|
||||||
|
**specific case that exercises it**, and prove that case red before trusting its green. A criterion with
|
||||||
|
no case that can fail for _that criterion's stated reason_ is unregistered in substance however it
|
||||||
|
appears in the manifest. This is mutation testing pointed at the **criterion-to-case mapping**, not
|
||||||
|
merely at the gate.
|
||||||
|
|
||||||
|
**Credit where due:** the reviewer also declined to re-run AC8, stating plainly that the PR carried it
|
||||||
|
forward with no runnable command rather than silently substituting a different boundary test. That is
|
||||||
|
the D-8 clause working a second time, in the same review that produced D-17.
|
||||||
|
|
||||||
|
### D-16 — the local test gate and the CI test gate disagree by environment
|
||||||
|
|
||||||
|
Mos flagged a shape worth chasing: if `pnpm test` exits non-zero on a _pre-existing_ guard, then either
|
||||||
|
`main` is red and merges step around it (the #868 shape again), or CI does not run that path. **Both
|
||||||
|
branches turned out wrong, and the truth is a third thing.** Established by running it, not by asking:
|
||||||
|
|
||||||
|
CI runs **exactly** `pnpm test` (`.woodpecker/ci.yml`, `test` step) — the same command. So the path _is_
|
||||||
|
exercised. Yet:
|
||||||
|
|
||||||
|
| environment | result |
|
||||||
|
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| CI container | `test` step **green** (#2158, #2167) |
|
||||||
|
| this host, clean worktree | **exit 97** — `WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated on bash 5.2.15(1)-release … expected [3 4], probe reported [3 5] (#973)`, after `PASS=18 FAIL=0` |
|
||||||
|
| this host, main checkout | **exit 1** — a _different_, second defect (below) |
|
||||||
|
|
||||||
|
The guard is **environment-dependent**: it aborts on this host's bash and not in CI's container. `git
|
||||||
|
diff origin/main...` confirms PR #1027 touches **zero** files under `packages/mosaic`, so the guard is
|
||||||
|
genuinely pre-existing and unrelated — **f10-coder's report was accurate in every particular**, and
|
||||||
|
`main` is equally affected on this host.
|
||||||
|
|
||||||
|
**So it is not "merges step around a red" — it is worse in one specific way: the local gate and the CI
|
||||||
|
gate do not agree about what passing means.** No agent on this host can obtain a green `pnpm test` at
|
||||||
|
all, on any branch, including `main`. A gate an operator cannot run is a gate that only CI enforces,
|
||||||
|
and a gate only CI enforces cannot be a pre-push gate. This is the hermeticity/portability class
|
||||||
|
already live as #1007 (PR #1024).
|
||||||
|
|
||||||
|
**Second, independent defect found while establishing the above.** In the main checkout the same
|
||||||
|
package fails differently — exit 1 — because a test **scans the working tree** and asserts on files it
|
||||||
|
finds, picking up `apps/coordinator/venv/**` (third-party `site-packages`: `pi = math.pi` in `rich`,
|
||||||
|
`setuptools`, `mypy`). **A test whose result depends on untracked files present in the tree is not
|
||||||
|
hermetic.** This is the _same_ contamination source that made `pnpm format:check` unpassable (D-1/D-7
|
||||||
|
hygiene) — one untracked foreign tree silently breaking two independent gates.
|
||||||
|
|
||||||
|
**Requirements.** RM-01/RM-04: a gate must produce the same verdict on a developer host and in CI, or
|
||||||
|
declare loudly that it cannot run here — never diverge silently. RM-02 registers both as cases: the
|
||||||
|
environment-divergence guard, and a hermeticity control asserting a suite's verdict is unchanged by the
|
||||||
|
presence of untracked directories. Coordinate with #1007/#1024 rather than opening a third lane.
|
||||||
|
|
||||||
|
**Ownership (Mos, 2026-07-31).** The hermeticity fix **is** PR #1024, which sits in **Jason's parked
|
||||||
|
delivery stack** — so, like #1023, its disposition is Jason's. Marked `SUPERSEDED-PENDING-JASON`
|
||||||
|
alongside #1023. D-16 strengthens the urgency but does not transfer ownership: **we do not open a third
|
||||||
|
lane on a parked PR.** The one-line escalation for Jason: _two independent gates
|
||||||
|
(`format:check`, `pnpm test`) were broken by a single untracked directory, and a third
|
||||||
|
(`pnpm test`) disagrees between host and CI — non-hermetic gates make every green host-dependent._
|
||||||
|
|
||||||
|
**Sharpened statement of the class (Mos).** A pre-push gate an operator _cannot run locally_ is a gate
|
||||||
|
only CI enforces — so pointing `.husky/pre-push` at it **misrepresents where the gate lives**. Combined
|
||||||
|
with the shared root cause across two gates, the finding is: **non-hermetic gates make every green
|
||||||
|
host-dependent.** A gate that only appears to pass depending on which host runs it is this mission's
|
||||||
|
exact subject, one meta-level up.
|
||||||
|
|
||||||
|
**#1027 disposition (Mos):** proceeds on **CI-green**. CI is the authoritative gate; the local exit-97
|
||||||
|
is a known host-specific guard abort, irrelevant to the merge decision.
|
||||||
|
|
||||||
|
### D-15 — token scope is not repository permission (a THIRD capability layer)
|
||||||
|
|
||||||
|
`f10-coder` was provisioned with `gitea-mosaicstack-f10-coder.token`, scopes `write:repository` +
|
||||||
|
`write:issue`, and the mint was verified by "repo access returns 200". It then failed to push:
|
||||||
|
|
||||||
|
```
|
||||||
|
remote: error: User permission denied for writing.
|
||||||
|
remote: error: pre-receive hook declined
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified objectively rather than inferred (per the charter principle):
|
||||||
|
|
||||||
|
| probe | result |
|
||||||
|
| ------------------------------------------------------ | ------------------------------------------- |
|
||||||
|
| `GET /repos/mosaicstack/stack/collaborators/f10-coder` | **404** — not a collaborator |
|
||||||
|
| repo permissions as seen by **its own token** | `admin: false`, `push: false`, `pull: true` |
|
||||||
|
|
||||||
|
**Capability has at least three independent layers, and satisfying two proves nothing about the third:**
|
||||||
|
|
||||||
|
1. **Token file exists** → raw-API authentication works (D-11b).
|
||||||
|
2. **`tea` login exists** → tea-dependent wrapper paths work (D-13).
|
||||||
|
3. **Repository permission granted** (collaborator/team membership) → _writes_ are actually authorised.
|
||||||
|
|
||||||
|
A token can carry `write:repository` scope and still be refused, because **scope bounds what a token
|
||||||
|
may attempt; repository permission decides what the user may do.** They are different systems.
|
||||||
|
|
||||||
|
**This is the charter principle failing on the very check meant to confirm capability.** The mint was
|
||||||
|
validated by an HTTP 200 on a _read_. A 200 proves reachability; it does not prove the property that
|
||||||
|
was required, which was **write**. Both the provisioner and I accepted it — the same
|
||||||
|
`written-unverified` treated as `verified` as D-12, one layer up, on a check whose entire purpose was
|
||||||
|
verification.
|
||||||
|
|
||||||
|
**Requirements.** RM-50's pre-dispatch capability check must probe the **effective permission for the
|
||||||
|
operation intended** — for push authority, assert `permissions.push == true` as that seat, not token
|
||||||
|
existence and not a 200 on a read. RM-04's registry reconciliation covers all three layers, with a
|
||||||
|
must-fail control for each. A capability check that cannot fail on a seat lacking write permission is
|
||||||
|
itself an inert gate.
|
||||||
|
|
||||||
|
### D-14 — a ruled decision did not propagate to the authoritative record
|
||||||
|
|
||||||
|
DECISION-1 (the corrected choke-point wire-in target) was ruled by the coordinator and applied to
|
||||||
|
`TASKS.md`. **`MISSION.md` — the charter, the document a cold-starting seat reads first — kept the
|
||||||
|
superseded target for hours.** It was flagged `CONTESTED` in a board note, then the ruling landed and
|
||||||
|
nobody edited the charter. A seat resuming from the charter would have read the _rejected_ target as
|
||||||
|
authoritative and wired the choke point into a disabled rail — the precise failure the ruling existed
|
||||||
|
to prevent.
|
||||||
|
|
||||||
|
Caught by hand, during an unrelated edit. Nothing would have caught it otherwise.
|
||||||
|
|
||||||
|
**This is P-MISSION-001 turned on ourselves.** The mission's own thesis is that convention exists and
|
||||||
|
_enforcement_ is the gap: a decision that lives in a chat ruling and a board note, but not in the
|
||||||
|
source of truth, has not actually been made — it has been _agreed_. The two are different, and the
|
||||||
|
difference is exactly what this mission is about.
|
||||||
|
|
||||||
|
> ⚠ **AMENDED by D-20 — propagation is BIDIRECTIONAL.** As first written, this requirement was read by
|
||||||
|
> both the orchestrator and the coordinator as _forward_ propagation only: a ruling reaches the
|
||||||
|
> documents that state the new rule. **D-20 proved that insufficient.** When D-19 superseded part of
|
||||||
|
> D-18, the consequence propagated forward into the charter and the delivery conditions but **never
|
||||||
|
> backward into D-18 itself**, which went on asserting a withdrawn claim — and a reviewer disproved it
|
||||||
|
> by experiment. **A supersession must update BOTH the documents that render the new rule AND the
|
||||||
|
> finding it retires, with the retired wording quoted rather than deleted.** Backward propagation is
|
||||||
|
> the same defect as forward; neither of us audited that direction until it bit.
|
||||||
|
|
||||||
|
**Requirement (not merely a fix).** A ruled decision must propagate **mechanically** to the
|
||||||
|
authoritative record; it must not depend on someone remembering to edit a second file. Concretely, once
|
||||||
|
mission state is DB-backed (RM-53 / the P-MISSION cutover):
|
||||||
|
|
||||||
|
- a decision is a **record**, not prose duplicated across documents;
|
||||||
|
- documents _render_ decisions rather than restating them, so there is one place to be wrong;
|
||||||
|
- and where duplication is unavoidable, a check asserts the authoritative record and the derived
|
||||||
|
document agree — with a must-fail control proving divergence is detected.
|
||||||
|
|
||||||
|
Until then, the interim rule: **the same commit that records a ruling updates every document that
|
||||||
|
states it — and every finding it supersedes.** Interim rules are exactly what the DB cutover exists to replace.
|
||||||
|
|
||||||
|
**The rule found a second instance within minutes of being written.** Auditing the charter against all
|
||||||
|
rulings to date (rather than waiting to be bitten again) surfaced that **DECISION-2 had also not
|
||||||
|
propagated**: `MISSION.md`'s standing directives still stated the DB hard-cutover with no mention of
|
||||||
|
Mos's binding qualification that _the spine must not be a single-point hard-stop_ (degraded mode +
|
||||||
|
rollback artifact required). A seat reading the charter would have designed toward an availability
|
||||||
|
posture the coordinator had explicitly rejected — and would have found the superseded
|
||||||
|
"no DB ⇒ the fleet stops" recommendation nowhere contradicted. Now corrected in place.
|
||||||
|
|
||||||
|
**Two un-propagated rulings out of two rulings that touched charter text.** The propagation gap is not
|
||||||
|
an oversight that happened once; without a mechanism it is the _default outcome_. That is the argument
|
||||||
|
for making this a requirement rather than a discipline.
|
||||||
|
|
||||||
|
### D-13 — two credential registries that can disagree (why the `--draft` fallback fired at all)
|
||||||
|
|
||||||
|
Diagnosing D-12's root cause surfaced a distinct defect. There are **two parallel credential
|
||||||
|
registries**, and capability in one does not imply capability in the other:
|
||||||
|
|
||||||
|
| registry | contents for identity `mos-dt-0` on `mosaicstack` |
|
||||||
|
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
||||||
|
| token files — `~/.config/mosaic/secrets/gitea-tokens/` | `gitea-mosaicstack-mos-dt-0.token` **EXISTS** |
|
||||||
|
| `tea login list` | **NO** `mosaicstack` login for `mos-dt-0` (only `mosaicstack-mos` and `mosaicstack-rev-974`) |
|
||||||
|
|
||||||
|
So `get_gitea_token` succeeds and every raw-API path works, while every **tea-dependent** wrapper path
|
||||||
|
fails its login validation and silently degrades to the API fallback — which is exactly what dropped
|
||||||
|
`--draft`. **tea is not "stale"; the login simply does not exist for that identity.**
|
||||||
|
|
||||||
|
This matters beyond one flag: capability was declared authoritative by the token-file set (D-11b), but
|
||||||
|
that registry does not govern the tea path. A seat can be _fully provisioned_ by the authoritative
|
||||||
|
registry and still lose functionality with no error — only a warning, and only on the degraded path.
|
||||||
|
|
||||||
|
**Requirements.** RM-04 (activation coherence): the two registries must be reconciled — one source of
|
||||||
|
truth, or a startup check asserting they agree, with a must-fail control proving disagreement is
|
||||||
|
detected. RM-50: the pre-dispatch capability check must verify capability for the **path actually
|
||||||
|
used**, not merely token-file presence.
|
||||||
|
|
||||||
|
**Confirmed working despite the gap** (so this is degradation, not outage): pushes, `pr-merge.sh`,
|
||||||
|
PR/issue creation via API fallback, comment posting, and all reads. Impact is confined to
|
||||||
|
tea-only features — `--draft`, `--labels`, `--milestone`.
|
||||||
|
|
||||||
|
**Reconciliation run by Mos (the manual form of RM-04's assert-agreement, done once by hand).** For
|
||||||
|
`git.mosaicstack.dev`, the token-file registry holds **six** seats; `tea` holds logins for **two**:
|
||||||
|
|
||||||
|
| state | seats |
|
||||||
|
| ------------------------------------------------ | -------------------------------------------------------- |
|
||||||
|
| token file present, **no** mosaicstack tea login | `f10-coder`, `jarvis`, `mos-admin`, `mos-dt-0`, `pepper` |
|
||||||
|
| token file present **and** tea login present | `rev-974` (only) |
|
||||||
|
|
||||||
|
**Five of six provisioned seats are silently degraded on tea-only features.** This is _systemic_, not
|
||||||
|
a one-off — which is why the fix is registry reconciliation (RM-04) and not a per-seat mint. Minting
|
||||||
|
one seat would clear a symptom and leave the class live.
|
||||||
|
|
||||||
|
Mos deliberately deferred the mint: it is not on RM-01's critical path, and additively editing shared
|
||||||
|
`tea` config underneath running work is a change he declined to make without cause. Full remediation —
|
||||||
|
mint the five missing logins **and** wire the startup must-fail assertion that _detects_ disagreement —
|
||||||
|
lands as RM-04 at a non-critical seam, or immediately if any seat needs a tea-only feature to progress.
|
||||||
|
|
||||||
|
**Correction of record:** this supersedes D-11(b)'s claim that the token-file set is _the_ authoritative
|
||||||
|
capability registry. It is **necessary but not sufficient**. Capability is **per-path**: the token file
|
||||||
|
governs the raw-API path, the tea login governs the tea path, and the two can disagree silently.
|
||||||
|
|
||||||
|
### D-12 — a requested SAFETY flag was silently degraded, and I did not check
|
||||||
|
|
||||||
|
I created PR #1027 with `pr-create.sh ... -d` (draft) because it carries **partial, unproven work**.
|
||||||
|
`tea` authentication was stale, so the wrapper fell back to its raw-API path — which cannot set draft —
|
||||||
|
and emitted:
|
||||||
|
|
||||||
|
```
|
||||||
|
Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup.
|
||||||
|
```
|
||||||
|
|
||||||
|
The PR was created **not-draft**. I read the success output, saw the PR number, and moved on. I then
|
||||||
|
reported to the coordinator that the PR was "opened as draft". **It was open, mergeable, and marked
|
||||||
|
ready for ~25 minutes**, protected only by the words "DRAFT" and "do not merge" in its title and body —
|
||||||
|
i.e. by prose a human might read, not by the platform control I asked for. Detected only because a
|
||||||
|
watcher polled `draft:` and the value disagreed with my belief. Corrected by setting the `WIP:` title
|
||||||
|
prefix (Gitea's draft mechanism); `draft: True` verified after.
|
||||||
|
|
||||||
|
**Three distinct failures, and the third is mine:**
|
||||||
|
|
||||||
|
1. **Silent degradation of a safety flag.** The fallback path dropped `--draft` and still exited 0. A
|
||||||
|
fallback that cannot honour a _safety_ argument must fail, not proceed — degrading `--labels` is a
|
||||||
|
nuisance; degrading `--draft` publishes unproven work as ready to merge.
|
||||||
|
2. **The warning went to stderr and nothing consumed it.** It was correct, specific, and ignored — a
|
||||||
|
warning nobody acts on is indistinguishable from no warning.
|
||||||
|
3. **I did not verify the flag took effect.** I checked that the PR existed, not that it had the
|
||||||
|
property I required. This is the mission's own thesis turned on me: **I trusted a success exit code
|
||||||
|
over an observed state**, on exactly the class of tool this mission exists to distrust.
|
||||||
|
|
||||||
|
**Requirements.** RM-02: a wrapper that cannot honour a safety-relevant argument must exit non-zero —
|
||||||
|
registered with a must-fail control asserting `--draft` on a degraded path fails rather than proceeds.
|
||||||
|
RM-24 (tri-state write outcomes): this is precisely `written-unverified` being treated as `verified` —
|
||||||
|
the PR write succeeded, the _requested property_ was never confirmed, and no one looked.
|
||||||
|
|
||||||
|
### D-11 — seat identity did not survive into git, and seat capability is invisible at dispatch
|
||||||
|
|
||||||
|
Two defects, one dispatch (RM-01 → `f10-coder`):
|
||||||
|
|
||||||
|
**(a) Identity drift — P-WRAPPER-001, reproduced on our own delivery.** The seat's commits are
|
||||||
|
authored `mosaic-coder <[email protected]>` — the generic fallback. **You cannot tell from
|
||||||
|
git history which seat did this work.** Recorded, not rewritten: the drift is the evidence.
|
||||||
|
|
||||||
|
> **Mechanism, corrected (Mos).** My original framing here was wrong, and the error was in the brief
|
||||||
|
> before it was in the finding. `MOSAIC_GIT_IDENTITY` resolves the **token** (which per-slot credential
|
||||||
|
> the wrappers act with). The **commit author** comes from `git config user.name` / `user.email`, which
|
||||||
|
> is a **separate setting** — it fell back to the generic value because nothing set it. Exporting the
|
||||||
|
> identity could never have fixed authorship. **My worker brief instructed only the export, so the
|
||||||
|
> seat did exactly what it was told and the commits were still mis-attributed.**
|
||||||
|
>
|
||||||
|
> **The requirement is coherence: token and authorship must agree.** A seat acting with
|
||||||
|
> `gitea-mosaicstack-f10-coder` must also commit as `f10-coder <[email protected]>`.
|
||||||
|
> Either half alone is identity drift — one produces the right credential with the wrong author, the
|
||||||
|
> other the reverse. That coherence _is_ P-WRAPPER-001, and it belongs in seat setup, not in prose
|
||||||
|
> instructions a seat may follow correctly and still end up wrong.
|
||||||
|
|
||||||
|
**(b) Capability opacity.** Nothing at dispatch time revealed that `f10-coder` had no credential for
|
||||||
|
the target provider. Per-slot tokens live at `~/.config/mosaic/secrets/gitea-tokens/`; the seat holds
|
||||||
|
`gitea-usc-f10-coder` but not `gitea-mosaicstack-f10-coder`. This surfaced only when the seat failed
|
||||||
|
**mid-task, after ~$9 and 69% of its context.** The orchestrator (me) selected a seat without any way
|
||||||
|
to check it could act on the target repo — and there was no way to check.
|
||||||
|
|
||||||
|
`get_gitea_token` behaved **correctly**: it refused to fall through and borrow another slot's token,
|
||||||
|
failing loud precisely to protect gate-16 attribution. The tooling was right; the _dispatch-time
|
||||||
|
information_ did not exist.
|
||||||
|
|
||||||
|
**This is P-RECOVERY-001's "honest capability labeling" applied to seats rather than services.** A seat
|
||||||
|
should declare what it can actually do — which providers, which repos, which credentials — and that
|
||||||
|
declaration must be **checkable before dispatch**, not discovered by failure after the budget is spent.
|
||||||
|
|
||||||
|
**Requirements.**
|
||||||
|
|
||||||
|
- **RM-04 (activation coherence)** gains the identity-binding half: seat setup must set **both** the
|
||||||
|
token identity **and** `git config user.name`/`user.email`, coherently. Verified by an
|
||||||
|
exit-asserting test that makes a commit and asserts its author — never assumed from an instruction
|
||||||
|
in a brief.
|
||||||
|
- **RM-50 (roster ownership)** gains per-seat capability declaration plus a **pre-dispatch capability
|
||||||
|
check**. Mos (who owns provisioning) confirms the check is mechanically trivial: **capability is
|
||||||
|
token-file existence.** Before dispatching seat `X` to provider `Y`, test that
|
||||||
|
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists; if absent, provision it or pick a
|
||||||
|
provisioned seat. **The token-file set is the authoritative capability registry.** A one-second check
|
||||||
|
would have replaced a mid-task failure that cost ~$9 and 69% of a seat's context.
|
||||||
|
|
||||||
|
### D-10 — the queue guard's failure modes are exactly backwards
|
||||||
|
|
||||||
|
`ci-queue-wait.sh` — a **required** pre-push/pre-merge gate — was observed this session doing both of
|
||||||
|
these:
|
||||||
|
|
||||||
|
- **Fails OPEN on an unknown result.** `state=unknown ⇒ exit 0`, five times, during real pushes and
|
||||||
|
real merges. It also evaluates `branch=main` rather than the branch being acted on.
|
||||||
|
- **Fails CLOSED on credential resolution.** In a worker seat it aborted with
|
||||||
|
`Gitea token not found`, hard-blocking a legitimate push of completed, tested work. The worker
|
||||||
|
correctly stopped (Constitution gate 8). The identical command run from that worker's _own worktree_
|
||||||
|
in another shell succeeded, so the checkout and remote were fine — the difference was the worker's
|
||||||
|
process environment.
|
||||||
|
|
||||||
|
**A gate that waves through work it never checked, and blocks work that is ready, has its failure
|
||||||
|
modes inverted.** Availability failures (cannot reach the provider, cannot resolve a credential)
|
||||||
|
should degrade to a loud, auditable _inability to assert_ — never to a hard stop on delivery, and
|
||||||
|
never to a silent pass. Correctness failures (unknown, malformed, terminal-failure) are what must
|
||||||
|
block.
|
||||||
|
|
||||||
|
This is also the **Pi-brick shape** (P-RECOVERY-001): a gate whose own unavailability prevents the
|
||||||
|
work needed to recover from it.
|
||||||
|
|
||||||
|
**Requirement on RM-03, extending its existing two defects:** the guard must distinguish
|
||||||
|
`CANNOT_ASSERT` (credential/transport/provider unavailable — loud, audited, does not silently pass and
|
||||||
|
does not permanently block) from `ASSERTED_NOT_READY` (a real non-green CI state — blocks). Both are
|
||||||
|
registered R-002 cases with must-fail controls; neither may exit 0 silently.
|
||||||
|
|
||||||
|
### D-9 — the comms path shell-interprets message bodies (injection-shaped, found by accident)
|
||||||
|
|
||||||
|
Sending a status message with `agent-send.sh -m "...`backticks`..."` caused bash to **execute** the
|
||||||
|
backticked text as command substitution. The recipient received a mangled body plus a
|
||||||
|
`No such file or directory` error; the intended sentence never arrived. The message was reported as
|
||||||
|
delivered.
|
||||||
|
|
||||||
|
This is the **same class** as the already-noted `pr-create.sh` backtick-quoting bug (M2 scratchpad):
|
||||||
|
**two tools in the comms path treat a message body as shell input.** A body that can execute on the
|
||||||
|
sender is a _correctness_ bug before it is ever a security one — and note the failure mode: the
|
||||||
|
send reported success while silently transmitting something other than what was written. Silent
|
||||||
|
corruption with a success receipt is precisely the pattern this mission exists to eliminate.
|
||||||
|
|
||||||
|
**Requirement on RM-40 / RM-42 (comms/v1), hardened by Mos.** The envelope must carry its payload
|
||||||
|
**verbatim** and must not be subject to shell interpretation at **any** hop — sender, transport, or
|
||||||
|
adapter. Concretely: **file/stdin transport, never argv interpolation.**
|
||||||
|
|
||||||
|
**Standing interim rule, effective now (Mos).** Until the envelope lands, use `agent-send.sh -f
|
||||||
|
<file>` for any message body containing special characters — **never `-m`**. Passing a file sidesteps
|
||||||
|
argv interpolation entirely. **This rule is mandatory in every worker brief this mission issues**,
|
||||||
|
alongside the D-8 "if a check is unrunnable, say so" clause. Round-trip fidelity (send a body containing backticks, `$(…)`, quotes, and newlines; assert
|
||||||
|
byte-identical receipt) is a required registered test case under RM-02, including a must-fail control
|
||||||
|
proving the assertion can detect corruption.
|
||||||
|
|
||||||
### D-8 — a PRE-REGISTERED acceptance check that was not runnable as written
|
### D-8 — a PRE-REGISTERED acceptance check that was not runnable as written
|
||||||
|
|
||||||
On PR #1025 the author (me) pre-registered AC2 with the fixture snippet `mkdir -p apps/*/venv/lib`.
|
On PR #1025 the author (me) pre-registered AC2 with the fixture snippet `mkdir -p apps/*/venv/lib`.
|
||||||
@@ -298,7 +812,7 @@ spread is itself information, and X1 says we calibrate on real merged PRs.
|
|||||||
### P5 — Retirements, hygiene, conformance
|
### P5 — Retirements, hygiene, conformance
|
||||||
|
|
||||||
| id | task | src | depends_on | est (S/O) | tier |
|
| id | task | src | depends_on | est (S/O) | tier |
|
||||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -------------------------- | ---------------- | ------ |
|
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------- | ---------------- | ------ |
|
||||||
| RM-50 | One roster-owned socket/host; quarantine unmanaged; **deterministic reaper for stale sessions AND dead-session disk scratch** (D-7) | O+S+live | RM-04 | 14K / 150K | sonnet |
|
| RM-50 | One roster-owned socket/host; quarantine unmanaged; **deterministic reaper for stale sessions AND dead-session disk scratch** (D-7) | O+S+live | RM-04 | 14K / 150K | sonnet |
|
||||||
| RM-51 | Auto-sync **allowlist** (never auto-stage unknown paths) + worktree/lease isolation | O+S | RM-02 | 8K / 110K | sonnet |
|
| RM-51 | Auto-sync **allowlist** (never auto-stage unknown paths) + worktree/lease isolation | O+S | RM-02 | 8K / 110K | sonnet |
|
||||||
| RM-52 | Retire the Python controller + duplicate MACP islands (3 → 1) | O+S | RM-26, RM-27, RM-25, RM-28 | 14K / 110K | codex |
|
| RM-52 | Retire the Python controller + duplicate MACP islands (3 → 1) | O+S | RM-26, RM-27, RM-25, RM-28 | 14K / 110K | codex |
|
||||||
@@ -307,6 +821,7 @@ spread is itself information, and X1 says we calibrate on real merged PRs.
|
|||||||
| RM-55 | **Conformance harness:** fault-inject the live failure classes on real artifacts | O+S | RM-35, RM-41, RM-53 | 18K / 260K | opus |
|
| RM-55 | **Conformance harness:** fault-inject the live failure classes on real artifacts | O+S | RM-35, RM-41, RM-53 | 18K / 260K | opus |
|
||||||
| RM-56 | Retirement proof: CI asserts all three retirements are complete **and stay complete** | O | RM-52, RM-45, RM-53 | — / 90K | codex |
|
| RM-56 | Retirement proof: CI asserts all three retirements are complete **and stay complete** | O | RM-52, RM-45, RM-53 | — / 90K | codex |
|
||||||
| RM-57 | Operator cutover docs + activation proof; map all 15 decisions to evidence | S | RM-04, RM-36, RM-45, RM-55 | 6K / — | codex |
|
| RM-57 | Operator cutover docs + activation proof; map all 15 decisions to evidence | S | RM-04, RM-36, RM-45, RM-55 | 6K / — | codex |
|
||||||
|
| RM-59 | **Close the D-19 residual risk** — generated-state verification anchored **outside** the worktree's authority (executor/spine-side attestation), retiring the same-UID self-authentication gap | mos-remediation (D-19) | RM-12, RM-21, RM-25 | 20K | opus |
|
||||||
| RM-58 | **Mechanical pre-dispatch context reset** — the orchestrator resets a seat out-of-band and verifies it, rather than asking the agent to reset itself | mos-remediation (D-4) | RM-31, RM-50 | 8K | sonnet |
|
| RM-58 | **Mechanical pre-dispatch context reset** — the orchestrator resets a seat out-of-band and verifies it, rather than asking the agent to reset itself | mos-remediation (D-4) | RM-31, RM-50 | 8K | sonnet |
|
||||||
|
|
||||||
**Critical path:** `RM-01 → RM-02 → RM-10 → RM-11 → RM-12 → RM-21 → RM-23 → RM-31 → RM-33 → RM-34 → RM-53 → RM-55`.
|
**Critical path:** `RM-01 → RM-02 → RM-10 → RM-11 → RM-12 → RM-21 → RM-23 → RM-31 → RM-33 → RM-34 → RM-53 → RM-55`.
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# #1099 pipefail + early-exit sweep
|
||||||
|
|
||||||
|
Baseline: `df4c591ab42aa1ae62c12935fdc0e772684864a0`
|
||||||
|
|
||||||
|
This is a site inventory, not a risk count. `FIXED` means the early-exiting consumer no longer has a piped upstream process whose SIGPIPE can become the result under `pipefail`. `NOT-LOAD-BEARING` means the pipeline status is explicitly discarded. `UNREACHABLE-AND-WHY` describes designed input, not a payload-size safety claim.
|
||||||
|
|
||||||
|
## Tranche 1 — runtime and general scripts
|
||||||
|
|
||||||
|
| Baseline site | Verdict | Construction / reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tools/matrix-presence-harness/run.sh:38` | FIXED | nullglob array selects the first path; no pipeline |
|
||||||
|
| `tools/e2e-install-test.sh:139` | FIXED | capture help completely, then grep via redirection |
|
||||||
|
| `tools/install.sh:312` | FIXED | NUL `mapfile` reads all roots; count != 1 reaches the named malformed-archive diagnostic |
|
||||||
|
| `scripts/analysis/reflect-board-history.sh:76` | FIXED | capture Git history completely, then grep via redirection |
|
||||||
|
| `scripts/analysis/reflect-git-history.sh:67` | FIXED | grep reads from a here-string |
|
||||||
|
| `scripts/analysis/reflect-git-history.sh:69` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/authentik/user-create.sh:72` | FIXED | jq `first(...)` reads the response directly |
|
||||||
|
| `packages/mosaic/framework/tools/git/mutate-push-guard.sh:87` | FIXED | grep `-m1` reads the file directly; downstream `cut` consumes its complete scalar output |
|
||||||
|
| `packages/mosaic/framework/tools/orchestrator/session-resume.sh:94` | FIXED | `mapfile` plus bounded indexed loop replaces `head` pipeline |
|
||||||
|
| `packages/mosaic/framework/tools/prdy/prdy-status.sh:69` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:172` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:173` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:174` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:175` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:176` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:177` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:178` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:16` | FIXED | Bash regex extracts the first field without a pipeline |
|
||||||
|
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:56` | FIXED | grep and bounded sed each read from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/tmux/send-message.sh:113` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/tmux/send-message.sh:124` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/wake/detector.sh:126` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
||||||
|
| `packages/mosaic/framework/tools/wake/detector.sh:270` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/wake/detector.sh:278` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/wake/digest.sh:647` | FIXED | capture complete locator output, then select first line by parameter expansion |
|
||||||
|
| `packages/mosaic/framework/tools/wake/reconcile.sh:149` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
||||||
|
|
||||||
|
## Explicit withdrawn / non-load-bearing sites
|
||||||
|
|
||||||
|
| Baseline site | Verdict | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tools/install.sh:182` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
|
||||||
|
| `tools/install.sh:356` | UNREACHABLE-AND-WHY | `pnpm pack` writes one matching CLI tarball into a fresh directory immediately before lookup; citation withdrawn in #1099 |
|
||||||
|
| `tools/install.sh:357` | UNREACHABLE-AND-WHY | same fresh-directory invariant for gateway tarball; citation withdrawn in #1099 |
|
||||||
|
| `tools/install.sh:627` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
|
||||||
|
| `scripts/agent/session-start.sh:70` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
|
||||||
|
| `packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh:58` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
|
||||||
|
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:25` | UNREACHABLE-AND-WHY | withdrawn in #1099 after designed-input reachability measurement; preserved without re-litigation |
|
||||||
|
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:27` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||||
|
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:30` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||||
|
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:32` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||||
|
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:34` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||||
|
|
||||||
|
## Tranche 2 — non-wake test harnesses
|
||||||
|
|
||||||
|
All 22 baseline sites below are `FIXED`; the checked-in tranche fixture is passed through the same scanner and asserts all 22 occurrences and 21 normalized identities (the same response-split line occurs twice).
|
||||||
|
|
||||||
|
| Baseline site(s) | Verdict | Construction |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `systemd/user/test-fleet-units.sh:148` | FIXED | capture tmux output, then grep via redirection |
|
||||||
|
| `git/test-issue-comment-readback.sh:283,302` | FIXED | parameter expansion splits status/body without `head` |
|
||||||
|
| `git/test-pr-review-gitea-comment.sh:228` | FIXED | parameter expansion splits status/body |
|
||||||
|
| `git/test-lane-brief-pr-linkage.sh:72` | FIXED | grep reads from a here-string |
|
||||||
|
| `git/test-pr-review-repo-host-override.sh:225-226` | FIXED | grep reads from a here-string |
|
||||||
|
| `orchestrator/smoke-test.sh:67,72` | FIXED | parameter expansion selects first line |
|
||||||
|
| `orchestrator/test-board-roll.sh:99-100` | FIXED | grep reads from a here-string |
|
||||||
|
| `quality/scripts/test-upgrade-durable-snapshot.sh:180` | FIXED | complete sorted output is read with `mapfile`, then indexed |
|
||||||
|
| `quality/scripts/test-upgrade-rollback.sh:339,356` | FIXED | direct `grep -m1` file reads; cleanup captures before testing |
|
||||||
|
| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions |
|
||||||
|
| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string |
|
||||||
|
|
||||||
|
Remaining wake-validation sites are intentionally deferred to the final review-sized tranche and are not yet assigned a safety verdict here.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# #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`.
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# #1043 — Fleet pane git-identity propagation
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison.
|
||||||
|
|
||||||
|
## Tracking
|
||||||
|
|
||||||
|
- External issue: `mosaicstack/stack#1043`
|
||||||
|
- Branch: `fix/1043-pane-git-identity`
|
||||||
|
- Coordinator: `tl-mosaic`
|
||||||
|
- `docs/TASKS.md`: read-only by project worker contract; not modified.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- RED-first bug reproducer is mandatory.
|
||||||
|
- R7 delete-the-subject mutation must turn the behavioral test red.
|
||||||
|
- Assert launched-process environment, not source text.
|
||||||
|
- One push only; do not poll CI after push.
|
||||||
|
- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence.
|
||||||
|
- Do not modify a live host launcher or obtain/copy another credential.
|
||||||
|
- Self-post the PR, verify provider attribution, then stop.
|
||||||
|
- Final status wording: `believed-fixed, pending jarvis validation`.
|
||||||
|
|
||||||
|
## Scope inventory
|
||||||
|
|
||||||
|
Re-derived against `origin/main` at `85d2108e`:
|
||||||
|
|
||||||
|
- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
||||||
|
- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||||
|
- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts`
|
||||||
|
- Roster projection producers:
|
||||||
|
- `packages/mosaic/src/commands/fleet.ts`
|
||||||
|
- `packages/mosaic/src/fleet/fleet-reconciler.ts`
|
||||||
|
- `packages/mosaic/src/fleet/fleet-agent-crud.ts`
|
||||||
|
- `packages/mosaic/src/fleet/v1-v2-migration.ts`
|
||||||
|
- Contract and producer tests discovered by repository search.
|
||||||
|
- Generated-environment operator/developer docs and their executable documentation contract test.
|
||||||
|
|
||||||
|
Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add the process-environment set-comparison regression first and record RED.
|
||||||
|
2. Add roster-derived `MOSAIC_GIT_IDENTITY=<agent name>` to the complete generated projection contract.
|
||||||
|
3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment.
|
||||||
|
4. Update affected projection tests and generated-environment docs.
|
||||||
|
5. Run focused and baseline gates.
|
||||||
|
6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN.
|
||||||
|
7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor.
|
||||||
|
|
||||||
|
## Evidence log
|
||||||
|
|
||||||
|
### TDD and mutation evidence
|
||||||
|
|
||||||
|
- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity.
|
||||||
|
- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`.
|
||||||
|
- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||||
|
- R7 restoration: restored that launch entry; the same test returned green.
|
||||||
|
- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy.
|
||||||
|
|
||||||
|
### Situational and focused tests
|
||||||
|
|
||||||
|
- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity.
|
||||||
|
- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green.
|
||||||
|
- Focused Mosaic Vitest set: 6 files, 311 tests — green.
|
||||||
|
- `bash -n` on changed shell files — green.
|
||||||
|
- `git diff --check` — green.
|
||||||
|
|
||||||
|
### Baseline gates
|
||||||
|
|
||||||
|
- `pnpm typecheck` — 45/45 tasks green.
|
||||||
|
- `pnpm lint` — 25/25 tasks green.
|
||||||
|
- `pnpm format:check` — green.
|
||||||
|
- `pnpm test:checkout` — green.
|
||||||
|
- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase.
|
||||||
|
- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates:
|
||||||
|
1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr;
|
||||||
|
2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`).
|
||||||
|
No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter.
|
||||||
|
|
||||||
|
### Independent review
|
||||||
|
|
||||||
|
- Codex code review first pass: request changes for missing shell rejection-path coverage.
|
||||||
|
- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call.
|
||||||
|
- Codex code re-review: **approve**, no findings, confidence 0.88.
|
||||||
|
- Codex security review: risk `none`, no findings, confidence 0.97.
|
||||||
|
|
||||||
|
### Acceptance criteria mapping
|
||||||
|
|
||||||
|
| Acceptance criterion | Evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above |
|
||||||
|
| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests |
|
||||||
|
| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly |
|
||||||
|
|
||||||
|
### Documentation checklist
|
||||||
|
|
||||||
|
- PRD updated with #1043 requirements and acceptance criteria.
|
||||||
|
- Fleet launch runbook, generated-env concept, and generated-env reference updated.
|
||||||
|
- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies.
|
||||||
|
- `docs/TASKS.md` remains unmodified per its single-writer project contract.
|
||||||
|
|
||||||
|
## Round 2 — PR #1073 review 97 remediation
|
||||||
|
|
||||||
|
### Review blocker
|
||||||
|
|
||||||
|
The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression.
|
||||||
|
|
||||||
|
### RED-first and canonical wiring
|
||||||
|
|
||||||
|
1. Removed the suite's signed exclusion before adding a CI execution path.
|
||||||
|
2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18.
|
||||||
|
3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain.
|
||||||
|
4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition.
|
||||||
|
|
||||||
|
### Workflow-level R7
|
||||||
|
|
||||||
|
- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`.
|
||||||
|
- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain.
|
||||||
|
- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||||
|
- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort.
|
||||||
|
- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical.
|
||||||
|
|
||||||
|
### Workflow population
|
||||||
|
|
||||||
|
- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||||
|
- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses.
|
||||||
|
- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change.
|
||||||
|
|
||||||
|
### Independent remediation review
|
||||||
|
|
||||||
|
- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install.
|
||||||
|
- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged.
|
||||||
|
- Codex re-review: **APPROVE**, confidence 0.93, no findings.
|
||||||
|
|
||||||
|
### Vitest denominator reconciliation
|
||||||
|
|
||||||
|
The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees:
|
||||||
|
|
||||||
|
- generated environment boundary: 24
|
||||||
|
- fleet documentation: 23
|
||||||
|
- Tess service profile: 6
|
||||||
|
- fleet regen command: 27
|
||||||
|
- fleet agent CRUD command: 22
|
||||||
|
- fleet command: 209
|
||||||
|
- total: **311**
|
||||||
|
|
||||||
|
Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic.
|
||||||
|
|
||||||
|
## Round 3 — Alpine stale-marker portability
|
||||||
|
|
||||||
|
### Objective and plan
|
||||||
|
|
||||||
|
- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion.
|
||||||
|
- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review.
|
||||||
|
- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling.
|
||||||
|
- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes.
|
||||||
|
|
||||||
|
### RED-first evidence
|
||||||
|
|
||||||
|
Before the fix, the canonical CI image command
|
||||||
|
`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||||
|
exited 1 at the stale-marker setup with exact BusyBox output
|
||||||
|
`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location.
|
||||||
|
|
||||||
|
### Root cause and fix
|
||||||
|
|
||||||
|
The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata.
|
||||||
|
|
||||||
|
### Structural pattern
|
||||||
|
|
||||||
|
This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening.
|
||||||
|
|
||||||
|
### GREEN and quality evidence
|
||||||
|
|
||||||
|
- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`.
|
||||||
|
- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces.
|
||||||
|
- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed.
|
||||||
|
- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN.
|
||||||
|
- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings.
|
||||||
|
- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings.
|
||||||
|
|
||||||
|
### Re-derived inventory and denominators
|
||||||
|
|
||||||
|
- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization.
|
||||||
|
- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path.
|
||||||
|
- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||||
|
- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets.
|
||||||
|
|
||||||
|
## Round 4 — bound stale-marker observation
|
||||||
|
|
||||||
|
### Objective and plan
|
||||||
|
|
||||||
|
- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window.
|
||||||
|
- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale.
|
||||||
|
- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image.
|
||||||
|
- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling.
|
||||||
|
- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged.
|
||||||
|
|
||||||
|
### Root cause and bounded observation
|
||||||
|
|
||||||
|
The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion.
|
||||||
|
|
||||||
|
The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed.
|
||||||
|
|
||||||
|
### Two-mutant RED / restored GREEN
|
||||||
|
|
||||||
|
All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`:
|
||||||
|
|
||||||
|
1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`.
|
||||||
|
2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls.
|
||||||
|
3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`.
|
||||||
|
|
||||||
|
### Re-derived inventory
|
||||||
|
|
||||||
|
- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty.
|
||||||
|
- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path.
|
||||||
|
- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds.
|
||||||
|
- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation.
|
||||||
|
|
||||||
|
## Residual risk
|
||||||
|
|
||||||
|
- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events.
|
||||||
|
- Canonical CI result is pending and will not be polled by this seat.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# #1098 — Framework shell portability / red main
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Restore terminal-green `main` by making the `test-start-agent-session.sh` clean-environment assertion semantic and portable without removing either newly enumerated framework-shell suite.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Tracking issue: `mosaicstack/stack#1098`
|
||||||
|
- Branch: `fix/framework-shell-portability`
|
||||||
|
- Base: `origin/main` at `4fa2768962702d53e16e8b67ee6ad52ebcb0910e`
|
||||||
|
- Primary file: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||||
|
- Requirements source: `docs/PRD.md` § Framework shell assertion portability (#1098)
|
||||||
|
- Out of scope: deployed files under `~/.config/mosaic`, pnpm-store cleanup, checkout deletion, and changes to the launcher’s `/usr/bin/env -i` behavior.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. The test inspects the captured NUL-delimited tmux argv semantically and accepts an adjacent `/usr/bin/env`, `-i` pair regardless of trailing payload size or pipe scheduling.
|
||||||
|
2. Missing `/usr/bin/env`, missing `-i`, and non-adjacent `-i` remain failures.
|
||||||
|
3. Failure output includes the observed argv records with stable indexes and shell escaping; it exposes no credentials because this fixture supplies only generated non-secret launch data.
|
||||||
|
4. The focused suite passes on the dev host and in the repository CI image; the blocking PR/main pipeline returns terminal green.
|
||||||
|
5. Independent review passes; PR is squash-merged and #1098 is closed only after merged-main CI is terminal green.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- ASSUMPTION: 30K-token working budget; rationale: one shell-test defect plus full PR/CI lifecycle.
|
||||||
|
- Auto-reduction: focused shell and package gates first; rely on canonical Woodpecker for the full monorepo suite rather than duplicating a dependency install under constrained `/home`.
|
||||||
|
- Disk baseline before clone/build: `/home` 7.1G free (99% used), `/tmp` 2.4G free (92% used).
|
||||||
|
|
||||||
|
## Investigation
|
||||||
|
|
||||||
|
### First-hand CI evidence
|
||||||
|
|
||||||
|
- Public log: `GET https://ci.mosaicstack.dev/api/repos/47/logs/2269/53041`
|
||||||
|
- Decoded 1,436 entries (11 null `data` entries treated as empty log rows), 190,756 bytes.
|
||||||
|
- Failure: `FAIL: pane command did not clear its environment` immediately after the expected pane-PID warning.
|
||||||
|
- BusyBox primitives, complete assertion pipeline, real CI image, stale/current image digests, Turbo cache masking, gateway failure, and heartbeat-sidecar concurrent writing were independently excluded.
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
|
||||||
|
The assertion ends in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i'
|
||||||
|
```
|
||||||
|
|
||||||
|
The script has `set -o pipefail`. `grep -q` exits as soon as it finds the valid `-i` record. Upstream `tail`/`printf` can then receive SIGPIPE, making the aggregate pipeline nonzero even though grep returned 0 and the semantic property is true. This depends on payload size, pipe capacity, and scheduling, explaining a local/image pass with a CI failure.
|
||||||
|
|
||||||
|
Discriminating stress control with `/usr/bin/env` followed immediately by `-i`:
|
||||||
|
|
||||||
|
- 8,192-byte trailing payload: `printf=0 tail=0 grep=0`, aggregate 0.
|
||||||
|
- 16,384-byte trailing payload: `printf=0 tail=141 grep=0`, aggregate 141.
|
||||||
|
- 32,768+ bytes: `printf=141 tail=141 grep=0`, aggregate 141.
|
||||||
|
- A full-reading `grep -xF` control remained 0 for every payload.
|
||||||
|
|
||||||
|
This is a third branch omitted by the earlier present-vs-corrupted split: the pair can be present and intact while `pipefail` reports an upstream SIGPIPE.
|
||||||
|
|
||||||
|
## TDD plan
|
||||||
|
|
||||||
|
1. RED: preserve the one-off stress reproducer above and add an automated large-argv semantic regression that fails under the current pipeline implementation.
|
||||||
|
2. GREEN: parse the authoritative NUL-delimited capture into a Bash array and search for an adjacent `/usr/bin/env`, `-i` pair without a short-circuit pipeline.
|
||||||
|
3. Add negative controls for missing, detached, and reversed tokens.
|
||||||
|
4. On failure, print indexed `%q` argv records before returning nonzero.
|
||||||
|
5. Run focused suite, mutation controls, shell syntax/format checks, then repository baseline gates feasible without dependency installation.
|
||||||
|
6. Independent review, queue guard, push, PR, CI, coordinator merge authorization, squash merge, merged-main CI, issue close.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Checkout created and based on `origin/main` `4fa27689`.
|
||||||
|
- [x] CI log decoded directly.
|
||||||
|
- [x] Root-cause stress control reproduced semantic match + aggregate pipeline failure.
|
||||||
|
- [x] RED evidence: intact `/usr/bin/env`, `-i` fixture produced component statuses `0/141/0` and aggregate 141 under the former `grep -q` pipeline; full-reading semantic control stayed 0.
|
||||||
|
- [x] GREEN implementation: direct NUL-argv adjacency parser, indexed diagnostics, and full-reading scalar predicates replace all load-bearing early-exit pipelines in this test.
|
||||||
|
- [x] Baseline/situational tests:
|
||||||
|
- focused launcher suite: PASS on GNU host and cached Alpine CI image;
|
||||||
|
- paired `test-fleet-units.sh`: PASS;
|
||||||
|
- enumeration guard: PASS (`population=53`, `enumerated=36`, `excluded=18`), 14/14 mutation needles;
|
||||||
|
- `bash -n`, ShellCheck, `git diff --check`: PASS;
|
||||||
|
- static denominator after change: zero load-bearing `grep -q`/`head`/`-m1` pipeline candidates in `test-start-agent-session.sh`;
|
||||||
|
- delete-the-subject mutation removing production `-i`: RED with 78 indexed argv records, byte count, and explicit boundary failure.
|
||||||
|
- [x] Independent review:
|
||||||
|
- first Codex review: request changes — negative fixtures did not each assert diagnostics;
|
||||||
|
- remediation: centralized predicate + diagnostic wrapper and exercised all four negative fixtures;
|
||||||
|
- second Codex review: APPROVE, 0 blockers/should-fix/suggestions;
|
||||||
|
- Codex security review: risk none, 0 findings.
|
||||||
|
- [ ] PR CI, formal fleet review, merge, merged-main CI, issue closure.
|
||||||
|
|
||||||
|
## Documentation disposition
|
||||||
|
|
||||||
|
- Updated canonical `docs/PRD.md` with FSP requirements and acceptance criteria.
|
||||||
|
- This is an internal test/reliability change with no API, user workflow, deployment, navigation, or publishing-surface change; no user/admin/API/sitemap update is required.
|
||||||
|
- `docs/TASKS.md` remains unchanged because the project contract makes it orchestrator-only.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- The CI failure did not print its captured argv, so the exact CI payload is unavailable. The stress control proves the assertion is non-portable and can emit the exact false verdict; branch CI is the canonical confirmation that replacing it resolves pipeline 2269’s failure class.
|
||||||
|
- Printing fixture argv is safe only while this test’s projection remains non-secret. The diagnostic must stay scoped to the test capture and shell-escaped.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# #1099 — pipefail + early-exit sweep
|
||||||
|
|
||||||
|
## Scope and decisions
|
||||||
|
|
||||||
|
- Baseline `df4c591ab42aa1ae62c12935fdc0e772684864a0`, after #1100 removed its 35 sites.
|
||||||
|
- Split into review-sized non-closing tranches: runtime/general; tmux/git/quality tests; wake validation/tests.
|
||||||
|
- Do not equate class membership with demonstrated risk. Do not use payload size or pipeline stage count as a safety proxy.
|
||||||
|
- Preserve the issue's withdrawn findings for `qa-hook-stdin.sh` and the two fresh-directory `pnpm pack` lookups. Fix `install.sh:312` because malformed multi-root input must reach its named handler.
|
||||||
|
|
||||||
|
## Tranche 1 TDD
|
||||||
|
|
||||||
|
RED-first control: `node --test scripts/pipefail-early-exit.test.mjs` reported exactly 26 non-accepted runtime/general sites, including `install.sh:312`, and exited 1. A checked-in fixture generated from immutable baseline `df4c591a` records all 26 normalized sites; the control passes every fixture entry through the same scanner, asserts exact identity/count/uniqueness, and separately requires zero findings in the current tree. It also inventories accepted sites rather than silently excluding whole files.
|
||||||
|
|
||||||
|
Construction choices:
|
||||||
|
|
||||||
|
- here-string/file redirection for scalar grep assertions;
|
||||||
|
- full capture then parameter expansion for first-line selection;
|
||||||
|
- arrays/`mapfile` for complete populations;
|
||||||
|
- direct jq/awk/grep selection where one tool can express the property;
|
||||||
|
- no `|| true` added to a load-bearing assertion.
|
||||||
|
|
||||||
|
Site-by-site verdicts: `docs/reports/quality/1099-pipefail-sweep.md`.
|
||||||
|
|
||||||
|
## Tranche 2 TDD
|
||||||
|
|
||||||
|
Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2.
|
||||||
|
|
||||||
|
## Verification so far
|
||||||
|
|
||||||
|
- `bash -n` on every changed shell script: pass.
|
||||||
|
- structural Node control: pass.
|
||||||
|
- `test-mutate-push-guard.sh`: 8/8 pass.
|
||||||
|
- `test-send-message-verdict.sh`: 3/3 pass.
|
||||||
|
- `test-send-message-socket.sh`: pass.
|
||||||
|
- Independent review 143 found two semantic regressions: a help-probe `|| true` changed the failure truth table, and an unguarded Git capture changed non-Git data-dir behavior from rc 0 + JSON to silent rc 128. Both received RED-first regressions before correction; help status is now separate and required, and Git status remains condition-guarded.
|
||||||
|
- Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
|
||||||
|
- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# PR merge squash message field
|
||||||
|
|
||||||
|
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
|
||||||
|
- **Owner:** `be-coder-08`
|
||||||
|
- **Branch:** `fix/pr-merge-message-field`
|
||||||
|
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
|
||||||
|
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
|
||||||
|
|
||||||
|
## Binding requirements
|
||||||
|
|
||||||
|
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
|
||||||
|
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
|
||||||
|
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
|
||||||
|
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
|
||||||
|
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
|
||||||
|
|
||||||
|
## Derived interface decisions
|
||||||
|
|
||||||
|
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
|
||||||
|
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
|
||||||
|
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
|
||||||
|
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
|
||||||
|
|
||||||
|
## Canonical delivery plan
|
||||||
|
|
||||||
|
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
|
||||||
|
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
|
||||||
|
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
|
||||||
|
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
|
||||||
|
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
|
||||||
|
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
|
||||||
|
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
|
||||||
|
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
|
||||||
|
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
|
||||||
|
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
|
||||||
|
|
||||||
|
## Remediation and current review state
|
||||||
|
|
||||||
|
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
|
||||||
|
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
|
||||||
|
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
|
||||||
|
4. Codex review rounds 3–5 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
|
||||||
|
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
|
||||||
|
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
|
||||||
|
|
||||||
|
## Disposable provider fixture acceptance
|
||||||
|
|
||||||
|
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
|
||||||
|
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
|
||||||
|
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
|
||||||
|
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
|
||||||
|
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
|
||||||
|
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
|
||||||
|
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
|
||||||
|
|
||||||
|
## Fixture preflight
|
||||||
|
|
||||||
|
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
|
||||||
|
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
|
||||||
|
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
|
||||||
|
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
|
||||||
|
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
|
||||||
|
|
||||||
|
## Fixture result
|
||||||
|
|
||||||
|
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
|
||||||
|
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
|
||||||
|
- Trailer-shaped lines, verbatim and in order:
|
||||||
|
1. `Co-authored-by: be-coder-07 <[email protected]>`
|
||||||
|
2. `Co-authored-by: be-coder-08 <[email protected]>`
|
||||||
|
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
|
||||||
|
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
|
||||||
|
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
|
||||||
|
|
||||||
|
## Current hold point
|
||||||
|
|
||||||
|
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
|
||||||
|
|
||||||
|
## Security review 96 remediation
|
||||||
|
|
||||||
|
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
|
||||||
|
|
||||||
|
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
|
||||||
|
|
||||||
|
Security remediation:
|
||||||
|
|
||||||
|
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
|
||||||
|
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
|
||||||
|
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
|
||||||
|
|
||||||
|
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 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.
|
||||||
@@ -13,7 +13,14 @@ 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
|
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
|
**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
|
authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green
|
||||||
CI before merging). These two scripts are the _only_ sanctioned merge path.
|
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.
|
||||||
3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw
|
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`
|
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
|
||||||
and `pr-ci-wait.sh`.
|
and `pr-ci-wait.sh`.
|
||||||
|
|||||||
@@ -868,6 +868,38 @@ steps:
|
|||||||
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
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
|
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)
|
## Post-Merge CI Monitoring (Hard Rule)
|
||||||
|
|
||||||
For source-code delivery, completion is not allowed at "PR opened" stage.
|
For source-code delivery, completion is not allowed at "PR opened" stage.
|
||||||
@@ -893,14 +925,16 @@ Woodpecker note:
|
|||||||
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- If pipeline state is running/queued/pending, wait until queue clears.
|
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
||||||
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
- 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.
|
||||||
|
|
||||||
## Gitea as Unified Platform
|
## Gitea as Unified Platform
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
|
|||||||
- PR target for delivery is `main`.
|
- PR target for delivery is `main`.
|
||||||
- Direct pushes to `main` are prohibited.
|
- Direct pushes to `main` are prohibited.
|
||||||
- Merge to `main` MUST be squash-only.
|
- Merge to `main` MUST be squash-only.
|
||||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
|
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
||||||
|
|
||||||
## Review Checklist
|
## 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`.
|
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.
|
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.
|
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 to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
|
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>`.
|
||||||
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).
|
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).
|
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).
|
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
|
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||||
> without asking.
|
> without asking.
|
||||||
|
|
||||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
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`
|
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
||||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
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)
|
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.
|
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.
|
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
||||||
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
||||||
- Before merging, run queue guard:
|
- Before merging, run queue guard:
|
||||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||||
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
||||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||||
- Merge via wrapper:
|
- Merge via wrapper:
|
||||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||||
- Wait for terminal CI status:
|
- Wait for terminal CI status:
|
||||||
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||||
- Close linked issue after merge + green CI:
|
- 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
|
**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.
|
(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 main` 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 {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||||
|
|
||||||
## Git Scripts
|
## Git Scripts
|
||||||
|
|
||||||
@@ -638,8 +638,9 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
|||||||
|
|
||||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
||||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
||||||
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
- 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/pr-ci-wait.sh -n {PR_NUMBER}`
|
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||||
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
|
|||||||
# Milestones
|
# Milestones
|
||||||
~/.config/mosaic/tools/git/milestone-create.sh
|
~/.config/mosaic/tools/git/milestone-create.sh
|
||||||
|
|
||||||
# CI queue guard (required before push/merge)
|
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose 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)
|
### Code Review (Codex)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ EOF
|
|||||||
chmod 700 "$AGENT_HOME/fleet/agents"
|
chmod 700 "$AGENT_HOME/fleet/agents"
|
||||||
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$AGENT_NAME
|
MOSAIC_AGENT_NAME=$AGENT_NAME
|
||||||
|
MOSAIC_GIT_IDENTITY=$AGENT_NAME
|
||||||
MOSAIC_AGENT_CLASS=code
|
MOSAIC_AGENT_CLASS=code
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=
|
MOSAIC_AGENT_MODEL=
|
||||||
@@ -144,7 +145,8 @@ EOF
|
|||||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
||||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
||||||
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
||||||
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then
|
ld_preload_env="$(tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null)" || true
|
||||||
|
if grep -q '^LD_PRELOAD=' <<<"$ld_preload_env"; then
|
||||||
fail "fresh holder retained LD_PRELOAD"
|
fail "fresh holder retained LD_PRELOAD"
|
||||||
fi
|
fi
|
||||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
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.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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.
|
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.
|
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.
|
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 -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `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 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>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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`).
|
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.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ if [[ -n "$GROUP" ]]; then
|
|||||||
group_response=$(curl -sk \
|
group_response=$(curl -sk \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
"${AUTHENTIK_URL}/api/v3/core/groups/?search=${GROUP}")
|
"${AUTHENTIK_URL}/api/v3/core/groups/?search=${GROUP}")
|
||||||
group_pk=$(echo "$group_response" | jq -r ".results[] | select(.name == \"$GROUP\") | .pk" | head -1)
|
group_pk=$(jq -r "first(.results[] | select(.name == \"$GROUP\") | .pk) // empty" <<<"$group_response")
|
||||||
if [[ -n "$group_pk" ]]; then
|
if [[ -n "$group_pk" ]]; then
|
||||||
payload=$(echo "$payload" | jq --arg gk "$group_pk" '. + {groups: [$gk]}')
|
payload=$(echo "$payload" | jq --arg gk "$group_pk" '. + {groups: [$gk]}')
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ is_sensitive_key() {
|
|||||||
|
|
||||||
is_generated_key() {
|
is_generated_key() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
MOSAIC_AGENT_NAME|MOSAIC_GIT_IDENTITY|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,7 @@ validate_generated_value() {
|
|||||||
local value="$2"
|
local value="$2"
|
||||||
case "$key" in
|
case "$key" in
|
||||||
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
||||||
|
MOSAIC_GIT_IDENTITY) safe_agent_name "$value" || fail_env unsafe-git-identity "$key" "$value" ;;
|
||||||
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
||||||
MOSAIC_AGENT_RUNTIME)
|
MOSAIC_AGENT_RUNTIME)
|
||||||
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
||||||
@@ -175,7 +176,7 @@ load_environment_file() {
|
|||||||
|
|
||||||
load_environment_file "$GENERATED_ENV" generated
|
load_environment_file "$GENERATED_ENV" generated
|
||||||
for required_key in \
|
for required_key in \
|
||||||
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
MOSAIC_AGENT_NAME MOSAIC_GIT_IDENTITY MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||||
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
||||||
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
||||||
done
|
done
|
||||||
@@ -183,12 +184,15 @@ load_environment_file "$LOCAL_ENV" local
|
|||||||
|
|
||||||
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
||||||
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
||||||
|
[ "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}" = "$AGENT_NAME" ] || \
|
||||||
|
fail_env git-identity-mismatch MOSAIC_GIT_IDENTITY "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}"
|
||||||
|
|
||||||
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
||||||
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
||||||
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
||||||
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
||||||
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
||||||
|
MOSAIC_GIT_IDENTITY=${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}
|
||||||
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
||||||
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
||||||
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
||||||
@@ -343,6 +347,7 @@ LAUNCH_ENV=(
|
|||||||
"PATH=$PANE_PATH"
|
"PATH=$PANE_PATH"
|
||||||
"MOSAIC_HOME=$MOSAIC_HOME"
|
"MOSAIC_HOME=$MOSAIC_HOME"
|
||||||
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
||||||
|
"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"
|
||||||
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
||||||
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
||||||
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
||||||
|
|||||||
@@ -14,6 +14,82 @@ fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pane_command_clears_environment() {
|
||||||
|
local calls_file="$1"
|
||||||
|
local -a argv=()
|
||||||
|
local index
|
||||||
|
mapfile -d '' -t argv < "$calls_file"
|
||||||
|
for ((index = 0; index + 1 < ${#argv[@]}; index++)); do
|
||||||
|
if [ "${argv[$index]}" = /usr/bin/env ] && [ "${argv[$((index + 1))]}" = -i ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
print_pane_argv() {
|
||||||
|
local calls_file="$1"
|
||||||
|
local -a argv=()
|
||||||
|
local bytes index
|
||||||
|
mapfile -d '' -t argv < "$calls_file"
|
||||||
|
bytes=$(wc -c < "$calls_file")
|
||||||
|
printf 'observed pane argv: records=%s bytes=%s\n' "${#argv[@]}" "$bytes" >&2
|
||||||
|
for ((index = 0; index < ${#argv[@]}; index++)); do
|
||||||
|
printf ' [%03d] %q\n' "$index" "${argv[$index]}" >&2
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
check_pane_environment_boundary() {
|
||||||
|
local calls_file="$1"
|
||||||
|
if pane_command_clears_environment "$calls_file"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
print_pane_argv "$calls_file"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
contains_literal() {
|
||||||
|
grep -F -- "$2" <<< "$1" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
contains_line() {
|
||||||
|
grep -xF -- "$2" <<< "$1" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Portability regression: inspect the authoritative NUL-delimited argv instead
|
||||||
|
# of piping a newline reconstruction through `grep -q` under pipefail. The old
|
||||||
|
# pipeline could report failure after a successful match when an upstream
|
||||||
|
# producer received SIGPIPE. A large trailing argument keeps that failure class
|
||||||
|
# covered without making stream size part of the semantic contract.
|
||||||
|
PORTABILITY_CALLS="$ROOT/portability-calls"
|
||||||
|
printf -v PORTABILITY_PADDING '%*s' 32768 ''
|
||||||
|
PORTABILITY_PADDING=${PORTABILITY_PADDING// /x}
|
||||||
|
printf '%s\0' /usr/bin/env -i "$PORTABILITY_PADDING" > "$PORTABILITY_CALLS"
|
||||||
|
pane_command_clears_environment "$PORTABILITY_CALLS" || \
|
||||||
|
fail "valid large pane argv was rejected by the environment-boundary assertion"
|
||||||
|
|
||||||
|
assert_pane_boundary_rejected() {
|
||||||
|
local case_name="$1"
|
||||||
|
local expected_records="$2"
|
||||||
|
local diagnostic
|
||||||
|
if diagnostic=$(check_pane_environment_boundary "$PORTABILITY_CALLS" 2>&1); then
|
||||||
|
fail "pane boundary accepted invalid $case_name fixture"
|
||||||
|
fi
|
||||||
|
contains_literal "$diagnostic" "records=$expected_records bytes=" || \
|
||||||
|
fail "pane argv diagnostic omitted counts for $case_name fixture"
|
||||||
|
contains_literal "$diagnostic" '[000]' || \
|
||||||
|
fail "pane argv diagnostic omitted indexed arguments for $case_name fixture"
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '%s\0' tmux -i > "$PORTABILITY_CALLS"
|
||||||
|
assert_pane_boundary_rejected missing-env 2
|
||||||
|
printf '%s\0' /usr/bin/env HOME=/untrusted > "$PORTABILITY_CALLS"
|
||||||
|
assert_pane_boundary_rejected missing-i 2
|
||||||
|
printf '%s\0' /usr/bin/env HOME=/untrusted -i > "$PORTABILITY_CALLS"
|
||||||
|
assert_pane_boundary_rejected non-adjacent-i 3
|
||||||
|
printf '%s\0' -i /usr/bin/env > "$PORTABILITY_CALLS"
|
||||||
|
assert_pane_boundary_rejected reversed-boundary 2
|
||||||
|
|
||||||
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -62,6 +138,19 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
|||||||
SHIM
|
SHIM
|
||||||
chmod +x "$FAKE_BIN/mosaic"
|
chmod +x "$FAKE_BIN/mosaic"
|
||||||
|
|
||||||
|
# Freeze numeric epoch reads only when a test arm supplies an observation bound.
|
||||||
|
# Formatting reads still use the real BusyBox/POSIX date implementation.
|
||||||
|
cat > "$FAKE_BIN/date" <<'SHIM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -n "${MOSAIC_TEST_FIXED_EPOCH:-}" ] && [ "${1:-}" = '+%s' ]; then
|
||||||
|
printf '%s\n' "$MOSAIC_TEST_FIXED_EPOCH"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /bin/date "$@"
|
||||||
|
SHIM
|
||||||
|
chmod +x "$FAKE_BIN/date"
|
||||||
|
|
||||||
write_generated() {
|
write_generated() {
|
||||||
local home="$1"
|
local home="$1"
|
||||||
local agent="$2"
|
local agent="$2"
|
||||||
@@ -71,6 +160,7 @@ write_generated() {
|
|||||||
chmod 600 "$home/fleet/run/holder-owner"
|
chmod 600 "$home/fleet/run/holder-owner"
|
||||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$agent
|
MOSAIC_AGENT_NAME=$agent
|
||||||
|
MOSAIC_GIT_IDENTITY=$agent
|
||||||
MOSAIC_AGENT_CLASS=code
|
MOSAIC_AGENT_CLASS=code
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
||||||
@@ -88,6 +178,7 @@ run_start() {
|
|||||||
local agent="$2"
|
local agent="$2"
|
||||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||||
|
MOSAIC_TEST_FIXED_EPOCH="${MOSAIC_TEST_FIXED_EPOCH:-}" \
|
||||||
MOSAIC_TEST_HOME="$home" \
|
MOSAIC_TEST_HOME="$home" \
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
MOSAIC_HOME="$home" "$START" "$agent"
|
MOSAIC_HOME="$home" "$START" "$agent"
|
||||||
@@ -100,19 +191,55 @@ AGENT_VALID="coder0"
|
|||||||
write_generated "$HOME_VALID" "$AGENT_VALID"
|
write_generated "$HOME_VALID" "$AGENT_VALID"
|
||||||
run_start "$HOME_VALID" "$AGENT_VALID"
|
run_start "$HOME_VALID" "$AGENT_VALID"
|
||||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
contains_literal "$valid_args" new-session || fail "valid generated projection did not reach tmux"
|
||||||
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
contains_literal "$valid_args" mosaic || fail "fixed mosaic launcher command missing"
|
||||||
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
|
contains_literal "$valid_args" yolo || fail "fixed yolo launcher command missing"
|
||||||
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
|
contains_literal "$valid_args" pi || fail "roster runtime missing"
|
||||||
if echo "$valid_args" | grep -qF 'bash -c'; then
|
if contains_literal "$valid_args" 'bash -c'; then
|
||||||
fail "launcher constructed a shell command payload"
|
fail "launcher constructed a shell command payload"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The pane must start through an absolute clean-environment boundary. Its
|
# The pane must start through an absolute clean-environment boundary. Its
|
||||||
# runtime command remains an argv vector, but no holder/session environment
|
# runtime command remains an argv vector, but no holder/session environment
|
||||||
# control variable can pass through the pane command.
|
# control variable can pass through the pane command.
|
||||||
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||||
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
||||||
|
|
||||||
|
# Git identity is generated authority, not an optional or independently mutable
|
||||||
|
# local value. Each invalid form must fail before fake tmux receives a call.
|
||||||
|
assert_git_identity_rejected() {
|
||||||
|
local case_name="$1"
|
||||||
|
local expected_code="$2"
|
||||||
|
local home="$ROOT/git-identity-$case_name"
|
||||||
|
local agent="coder-git-identity-$case_name"
|
||||||
|
local generated="$home/fleet/agents/$agent.env.generated"
|
||||||
|
write_generated "$home" "$agent"
|
||||||
|
|
||||||
|
case "$case_name" in
|
||||||
|
missing) grep -v '^MOSAIC_GIT_IDENTITY=' "$generated" > "$generated.next" && mv "$generated.next" "$generated" ;;
|
||||||
|
unsafe) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=bad/identity|' "$generated" ;;
|
||||||
|
mismatch) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=other-agent|' "$generated" ;;
|
||||||
|
local-shadow)
|
||||||
|
printf 'MOSAIC_GIT_IDENTITY=%s\n' "$agent" > "$home/fleet/agents/$agent.env.local"
|
||||||
|
chmod 600 "$home/fleet/agents/$agent.env.local"
|
||||||
|
;;
|
||||||
|
*) fail "unknown Git identity rejection case: $case_name" ;;
|
||||||
|
esac
|
||||||
|
chmod 600 "$generated"
|
||||||
|
|
||||||
|
: > "$TMUX_CALLS"
|
||||||
|
if output=$(run_start "$home" "$agent" 2>&1); then
|
||||||
|
fail "Git identity case $case_name was accepted"
|
||||||
|
fi
|
||||||
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before Git identity $case_name rejection"
|
||||||
|
contains_literal "$output" "code=$expected_code" || \
|
||||||
|
fail "Git identity $case_name diagnostic omitted code $expected_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_git_identity_rejected missing missing-key
|
||||||
|
assert_git_identity_rejected unsafe unsafe-git-identity
|
||||||
|
assert_git_identity_rejected mismatch git-identity-mismatch
|
||||||
|
assert_git_identity_rejected local-shadow generated-key-shadow
|
||||||
|
|
||||||
# The generated-file parent is a security boundary too: even a private regular
|
# The generated-file parent is a security boundary too: even a private regular
|
||||||
# file is untrusted if its parent can be replaced or written by another user.
|
# file is untrusted if its parent can be replaced or written by another user.
|
||||||
@@ -125,7 +252,7 @@ if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
|
|||||||
fail "generated file under a world-writable parent was accepted"
|
fail "generated file under a world-writable parent was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
||||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
contains_literal "$output" 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||||
|
|
||||||
: > "$TMUX_CALLS"
|
: > "$TMUX_CALLS"
|
||||||
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
||||||
@@ -136,7 +263,7 @@ if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
|
|||||||
fail "generated file under a symlinked parent was accepted"
|
fail "generated file under a symlinked parent was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
||||||
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
contains_literal "$output" 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||||
|
|
||||||
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
||||||
# symlink or group/world-writable ancestor must fail before environment parsing,
|
# symlink or group/world-writable ancestor must fail before environment parsing,
|
||||||
@@ -174,8 +301,8 @@ assert_managed_ancestor_rejected() {
|
|||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
||||||
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
|
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
|
||||||
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
|
contains_literal "$output" 'code=unsafe-' || fail "managed ancestor diagnostic missing"
|
||||||
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
|
if contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND'; then
|
||||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
fail "environment parsing ran before $hazard $ancestor rejection"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -196,9 +323,9 @@ if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
|
|||||||
fail "generated-key shadow was accepted"
|
fail "generated-key shadow was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
||||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
contains_literal "$output" 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||||
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
|
contains_literal "$output" 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||||
if echo "$output" | grep -qF 'codex'; then
|
if contains_literal "$output" codex; then
|
||||||
fail "shadow diagnostic leaked value"
|
fail "shadow diagnostic leaked value"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -214,9 +341,9 @@ if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
|
|||||||
fail "arbitrary command override was accepted"
|
fail "arbitrary command override was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
||||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||||
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
|
contains_literal "$output" 'sha256=' || fail "command diagnostic omitted hash"
|
||||||
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
|
if contains_literal "$output" "$COMMAND_VALUE"; then
|
||||||
fail "command diagnostic leaked command value"
|
fail "command diagnostic leaked command value"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -230,7 +357,7 @@ if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
|
|||||||
fail "world-readable local input was accepted"
|
fail "world-readable local input was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
||||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
contains_literal "$output" 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||||
|
|
||||||
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
|
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
|
||||||
# computed PATH only. The pane command itself must not carry loader, shell
|
# computed PATH only. The pane command itself must not carry loader, shell
|
||||||
@@ -260,25 +387,35 @@ PATH="$PANE_STALE_PATH" \
|
|||||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||||
"$START" coder-pane-boundary
|
"$START" coder-pane-boundary
|
||||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
contains_line "$pane_args" "HOME=$PANE_TRUSTED_HOME" || \
|
||||||
fail "pane did not restore trusted HOME"
|
fail "pane did not restore trusted HOME"
|
||||||
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
|
contains_literal "$pane_args" "HOME=$PANE_STALE_HOME" && \
|
||||||
fail "pane inherited stale HOME"
|
fail "pane inherited stale HOME"
|
||||||
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
contains_literal "$pane_args" "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||||
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
|
contains_literal "$pane_args" "$blocked" && fail "pane inherited $blocked"
|
||||||
done
|
done
|
||||||
|
|
||||||
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
|
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||||
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
|
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
||||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
|
||||||
fail "pane command did not clear its environment"
|
|
||||||
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
||||||
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
# Exercise the repository launcher at $START, not the independently installed
|
||||||
|
# host copy. Set-compare every declared generated projection entry with the
|
||||||
|
# launched process environment so a newly declared identity cannot be omitted
|
||||||
|
# by a hand-maintained per-variable assertion.
|
||||||
|
declared_generated_environment=$(sort "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.generated")
|
||||||
|
missing_or_changed_generated_environment=$(comm -23 \
|
||||||
|
<(printf '%s\n' "$declared_generated_environment") \
|
||||||
|
<(printf '%s\n' "$pane_environment" | sort))
|
||||||
|
if [ -n "$missing_or_changed_generated_environment" ]; then
|
||||||
|
missing_or_changed_keys=$(printf '%s\n' "$missing_or_changed_generated_environment" | cut -d= -f1 | paste -sd, -)
|
||||||
|
fail "runtime pane omitted or changed generated environment keys: $missing_or_changed_keys"
|
||||||
|
fi
|
||||||
|
contains_line "$pane_environment" "HOME=$PANE_TRUSTED_HOME" || \
|
||||||
fail "runtime pane did not receive trusted HOME"
|
fail "runtime pane did not receive trusted HOME"
|
||||||
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
contains_literal "$pane_environment" "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||||
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
|
||||||
done
|
done
|
||||||
|
|
||||||
write_interaction_generated() {
|
write_interaction_generated() {
|
||||||
@@ -290,6 +427,7 @@ write_interaction_generated() {
|
|||||||
chmod 600 "$home/fleet/run/holder-owner"
|
chmod 600 "$home/fleet/run/holder-owner"
|
||||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$agent
|
MOSAIC_AGENT_NAME=$agent
|
||||||
|
MOSAIC_GIT_IDENTITY=$agent
|
||||||
MOSAIC_AGENT_CLASS=operator-interaction
|
MOSAIC_AGENT_CLASS=operator-interaction
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
||||||
@@ -352,8 +490,12 @@ write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
|
|||||||
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||||
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
||||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
||||||
touch -d '10 seconds ago' "$STALE_HB.native"
|
touch -t 200001010000.00 "$STALE_HB.native"
|
||||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
# Hold the sidecar's observation epoch constant: assertion runtime must not age
|
||||||
|
# a fresh-marker mutant into the stale state that this fixture must distinguish.
|
||||||
|
STALE_OBSERVATION_EPOCH=$(date +%s)
|
||||||
|
MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \
|
||||||
|
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||||
wait_for_sidecar_status "$STALE_HB"
|
wait_for_sidecar_status "$STALE_HB"
|
||||||
|
|
||||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
||||||
@@ -374,22 +516,22 @@ if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed
|
|||||||
fail "interaction wrapper accepted malformed generated data"
|
fail "interaction wrapper accepted malformed generated data"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
||||||
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
contains_literal "$output" 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||||
|
|
||||||
# A syntactically valid but policy-incompatible projection reaches the pinned
|
# A syntactically valid but policy-incompatible projection reaches the pinned
|
||||||
# interaction policy check only after strict parsing and never starts tmux.
|
# interaction policy check only after strict parsing and never starts tmux.
|
||||||
: > "$TMUX_CALLS"
|
: > "$TMUX_CALLS"
|
||||||
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
||||||
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
||||||
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \
|
sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \
|
||||||
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
||||||
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
||||||
fail "interaction wrapper accepted a policy-incompatible projection"
|
fail "interaction wrapper accepted a policy-incompatible projection"
|
||||||
fi
|
fi
|
||||||
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
contains_literal "$interaction_policy_args" new-session && \
|
||||||
fail "interaction pinned-policy rejection created a tmux session"
|
fail "interaction pinned-policy rejection created a tmux session"
|
||||||
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
contains_literal "$output" 'operator interaction service requires runtime pi' || \
|
||||||
fail "interaction pinned-policy check did not follow strict parsing"
|
fail "interaction pinned-policy check did not follow strict parsing"
|
||||||
|
|
||||||
# Exact stop derives the socket exclusively from the validated generated
|
# Exact stop derives the socket exclusively from the validated generated
|
||||||
@@ -402,10 +544,10 @@ HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
|||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
||||||
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
|
contains_line "$stop_args" mosaic-test || fail "exact stop did not use the validated generated socket"
|
||||||
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
|
contains_line "$stop_args" kill-session || fail "exact stop did not request session termination"
|
||||||
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
contains_line "$stop_args" '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||||
if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
if contains_literal "$stop_args" ambient-socket; then
|
||||||
fail "exact stop trusted an ambient socket"
|
fail "exact stop trusted an ambient socket"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
BRANCH="main"
|
BRANCH=""
|
||||||
|
TARGET_REPO=""
|
||||||
|
HEAD_SHA=""
|
||||||
TIMEOUT_SEC=900
|
TIMEOUT_SEC=900
|
||||||
INTERVAL_SEC=15
|
INTERVAL_SEC=15
|
||||||
PURPOSE="merge"
|
PURPOSE="merge"
|
||||||
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-B, --branch BRANCH Branch head to inspect (default: main)
|
-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
|
||||||
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
||||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||||
--purpose VALUE Log context: push|merge (default: merge)
|
--purpose VALUE Log context: push|merge (default: merge)
|
||||||
@@ -27,63 +31,65 @@ Options:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0")
|
$(basename "$0")
|
||||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
$(basename "$0") --purpose push -t 600 -i 10
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||||
|
|
||||||
get_state_from_status_json() {
|
get_state_from_status_json() {
|
||||||
python3 - <<'PY'
|
# 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 '
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.load(sys.stdin)
|
payload = json.load(sys.stdin)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("status payload is not an object")
|
||||||
except Exception:
|
except Exception:
|
||||||
print("unknown")
|
print("malformed")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
statuses = payload.get("statuses") or []
|
raw_statuses = payload.get("statuses", [])
|
||||||
state = (payload.get("state") or "").lower()
|
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()
|
||||||
|
|
||||||
pending_values = {"pending", "queued", "running", "waiting"}
|
pending_values = {"pending", "queued", "running", "waiting"}
|
||||||
failure_values = {"failure", "error", "failed"}
|
failure_values = {"failure", "error", "failed"}
|
||||||
success_values = {"success"}
|
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 = []
|
values = []
|
||||||
for item in statuses:
|
for item in statuses:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
print("malformed")
|
||||||
value = (item.get("status") or item.get("state") or "").lower()
|
raise SystemExit(0)
|
||||||
if value:
|
raw_value = item.get("status") or item.get("state")
|
||||||
values.append(value)
|
if not isinstance(raw_value, str) or not raw_value:
|
||||||
|
print("malformed")
|
||||||
|
raise SystemExit(0)
|
||||||
|
values.append(raw_value.lower())
|
||||||
|
|
||||||
if not values and not state:
|
if any(value in pending_values for value in values) or state in pending_values:
|
||||||
print("no-status")
|
|
||||||
elif any(v in pending_values for v in values):
|
|
||||||
print("pending")
|
print("pending")
|
||||||
elif any(v in failure_values for v in values):
|
elif any(value in failure_values for value in values) or state in failure_values:
|
||||||
print("terminal-failure")
|
print("terminal-failure")
|
||||||
elif values and all(v in success_values for v in values):
|
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
||||||
print("terminal-success")
|
print("terminal-success")
|
||||||
|
elif not values:
|
||||||
|
print("no-status")
|
||||||
else:
|
else:
|
||||||
print("unknown")
|
print("unknown")
|
||||||
PY
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
print_pending_contexts() {
|
print_pending_contexts() {
|
||||||
python3 - <<'PY'
|
python3 -c '
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -104,17 +110,61 @@ for item in statuses:
|
|||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
name = item.get("context") or item.get("name") or "unknown-context"
|
name = item.get("context") or item.get("name") or "unknown-context"
|
||||||
value = (item.get("status") or item.get("state") or "unknown").lower()
|
value = str(item.get("status") or item.get("state") or "unknown").lower()
|
||||||
target = item.get("target_url") or item.get("url") or ""
|
target = item.get("target_url") or item.get("url") or ""
|
||||||
if value in pending_values:
|
if value in pending_values:
|
||||||
found = True
|
found = True
|
||||||
if target:
|
suffix = f" ({target})" if target else ""
|
||||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||||
else:
|
|
||||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
|
||||||
if not found:
|
if not found:
|
||||||
print("[ci-queue-wait] no pending contexts")
|
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
|
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() {
|
github_get_branch_head_sha() {
|
||||||
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
|
|||||||
local owner="$1"
|
local owner="$1"
|
||||||
local repo="$2"
|
local repo="$2"
|
||||||
local sha="$3"
|
local sha="$3"
|
||||||
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
gitea_get_branch_head_sha() {
|
gitea_get_branch_head_sha() {
|
||||||
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
BRANCH="$2"
|
BRANCH="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-R|--repo)
|
||||||
|
TARGET_REPO="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--sha)
|
||||||
|
HEAD_SHA="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-t|--timeout)
|
-t|--timeout)
|
||||||
TIMEOUT_SEC="$2"
|
TIMEOUT_SEC="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
|
|||||||
echo "Error: timeout and interval must be integer seconds." >&2
|
echo "Error: timeout and interval must be integer seconds." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
||||||
|
|
||||||
OWNER=$(get_repo_owner)
|
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||||
REPO=$(get_repo_name)
|
echo "Error: --purpose must be push or merge." >&2
|
||||||
detect_platform > /dev/null
|
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
|
||||||
PLATFORM="${PLATFORM:-unknown}"
|
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 [[ "$PLATFORM" == "github" ]]; then
|
||||||
if ! command -v gh >/dev/null 2>&1; then
|
if ! command -v gh >/dev/null 2>&1; then
|
||||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
record_cannot_assert "github-cli-unavailable"
|
||||||
exit 1
|
exit $?
|
||||||
fi
|
fi
|
||||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||||
exit 1
|
record_cannot_assert "branch-head-unavailable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
HOST=$(get_remote_host) || {
|
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
||||||
echo "Error: Could not determine remote host." >&2
|
record_cannot_assert "remote-host-unresolvable"
|
||||||
exit 1
|
exit $?
|
||||||
}
|
fi
|
||||||
TOKEN=$(get_gitea_token "$HOST") || {
|
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
||||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
record_cannot_assert "credential-unresolvable"
|
||||||
exit 1
|
exit $?
|
||||||
}
|
fi
|
||||||
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
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
|
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
record_cannot_assert "branch-head-unavailable"
|
||||||
exit 1
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||||
else
|
else
|
||||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
record_cannot_assert "unsupported-platform"
|
||||||
exit 1
|
exit $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
START_TS=$(date +%s)
|
START_TS=$(date +%s)
|
||||||
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
|||||||
while true; do
|
while true; do
|
||||||
NOW_TS=$(date +%s)
|
NOW_TS=$(date +%s)
|
||||||
if (( NOW_TS > DEADLINE_TS )); then
|
if (( NOW_TS > DEADLINE_TS )); then
|
||||||
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||||
exit 124
|
exit 124
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
||||||
|
record_cannot_assert "status-provider-unreachable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
||||||
|
record_cannot_assert "status-provider-unreachable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||||
@@ -271,21 +459,24 @@ while true; do
|
|||||||
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
||||||
sleep "$INTERVAL_SEC"
|
sleep "$INTERVAL_SEC"
|
||||||
;;
|
;;
|
||||||
|
terminal-success)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
no-status)
|
no-status)
|
||||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||||
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||||
exit 1
|
else
|
||||||
|
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
exit 3
|
||||||
exit 0
|
|
||||||
;;
|
;;
|
||||||
terminal-success|terminal-failure|unknown)
|
terminal-failure|malformed|unknown)
|
||||||
# Queue guard only blocks on pending/running/queued states.
|
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 0
|
exit 3
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 0
|
exit 3
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
|
|
||||||
detect_platform() {
|
detect_platform() {
|
||||||
local remote_url
|
local remote_url
|
||||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
||||||
|
# kills the CALLER before the -z check below can run, so the error message that is
|
||||||
|
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
||||||
|
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
||||||
|
|
||||||
if [[ -z "$remote_url" ]]; then
|
if [[ -z "$remote_url" ]]; then
|
||||||
echo "error: not a git repository or no origin remote" >&2
|
echo "error: not a git repository or no origin remote" >&2
|
||||||
@@ -39,7 +42,10 @@ detect_platform() {
|
|||||||
|
|
||||||
get_repo_info() {
|
get_repo_info() {
|
||||||
local remote_url
|
local remote_url
|
||||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
||||||
|
# kills the CALLER before the -z check below can run, so the error message that is
|
||||||
|
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
||||||
|
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
||||||
|
|
||||||
if [[ -z "$remote_url" ]]; then
|
if [[ -z "$remote_url" ]]; then
|
||||||
echo "error: not a git repository or no origin remote" >&2
|
echo "error: not a git repository or no origin remote" >&2
|
||||||
@@ -240,6 +246,21 @@ PY
|
|||||||
} >&2
|
} >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Explain tea's most misleading failure. `user does not exist [uid: 0, name: ]` reads
|
||||||
|
# as a missing account; it almost always means a REVOKED OR STALE TOKEN. `tea login`
|
||||||
|
# keeps its OWN COPY of the token, so rotating the credential store does not update it.
|
||||||
|
# Diagnostic only -- stderr, no control flow, no exit.
|
||||||
|
explain_tea_user_does_not_exist() {
|
||||||
|
cat >&2 <<'MSG'
|
||||||
|
NOTE: `user does not exist [uid: 0, name: ]` from tea usually means a REVOKED OR STALE TOKEN,
|
||||||
|
not a missing account. A `tea login` stores its OWN COPY of the token; rotating the
|
||||||
|
credential store does NOT update it.
|
||||||
|
CHECK: the login's cached copy (`tea login list` -- read the FULL table, never `| head`),
|
||||||
|
then re-register that login against the current token.
|
||||||
|
DO NOT probe capability with a mutating request; a POST is the action, not a check.
|
||||||
|
MSG
|
||||||
|
}
|
||||||
|
|
||||||
get_gitea_login_for_host() {
|
get_gitea_login_for_host() {
|
||||||
local host="${1:-}"
|
local host="${1:-}"
|
||||||
local login
|
local login
|
||||||
|
|||||||
@@ -91,13 +91,32 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||||
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
# `tea issue comment` is NOT a subcommand -- tea 0.11.x lists only
|
||||||
|
# list/create/edit/reopen/close under `tea issue`. Comments are the
|
||||||
|
# TOP-LEVEL `tea comment`, which takes the same --repo/--login flags.
|
||||||
|
# The old call therefore always failed, was unchecked, and the script
|
||||||
|
# closed the issue anyway, losing the record of WHY.
|
||||||
|
#
|
||||||
|
# Use `tea comment` rather than the API helper so the comment and the
|
||||||
|
# close are made by the SAME principal ($GITEA_LOGIN_NAME). Routing the
|
||||||
|
# comment through the token-authenticated helper here would attribute the
|
||||||
|
# comment to the token holder and the close to the tea login -- two
|
||||||
|
# principals for one operation.
|
||||||
|
tea comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || {
|
||||||
|
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||||
else
|
else
|
||||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
gitea_issue_comment_api
|
# Fail closed here too: an unchecked comment lets the issue close without its
|
||||||
|
# audit trail, which is the same defect as the tea path above.
|
||||||
|
gitea_issue_comment_api || {
|
||||||
|
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
gitea_issue_close_api
|
gitea_issue_close_api
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ case "$PLATFORM" in
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
||||||
|
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||||
fi
|
fi
|
||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||||
|
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||||
fi
|
fi
|
||||||
gitea_issue_view_api
|
gitea_issue_view_api
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ cp "$TARGET" "$BAK"
|
|||||||
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
||||||
|
|
||||||
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
||||||
PROSE_LO="$(grep -n '^usage() {' "$BAK" | head -1 | cut -d: -f1)"
|
PROSE_LO="$(grep -n -m1 '^usage() {' "$BAK" | cut -d: -f1)"
|
||||||
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
||||||
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
||||||
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ case "$PLATFORM" in
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
||||||
|
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -12,8 +12,10 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
MERGE_METHOD="squash"
|
MERGE_METHOD="squash"
|
||||||
DELETE_BRANCH=false
|
DELETE_BRANCH=false
|
||||||
SKIP_QUEUE_GUARD=false
|
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
|
EXPECT_HEAD=""
|
||||||
|
CO_AUTHOR_TRAILERS=false
|
||||||
|
ESCALATE_TO=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -25,15 +27,18 @@ Options:
|
|||||||
-n, --number NUMBER PR number to merge (required)
|
-n, --number NUMBER PR number to merge (required)
|
||||||
-m, --method METHOD Merge method: squash only (default: squash)
|
-m, --method METHOD Merge method: squash only (default: squash)
|
||||||
-d, --delete-branch Delete the head branch after merge
|
-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
|
--dry-run Run metadata/login preflight without merging
|
||||||
|
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||||
|
--co-author-trailers Build verified trailers from linked PR commit authors
|
||||||
|
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") -n 42 # Merge PR #42
|
$(basename "$0") -n 42 # Merge PR #42
|
||||||
$(basename "$0") -n 42 -m squash # Squash merge
|
$(basename "$0") -n 42 -m squash # Squash merge
|
||||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
|
||||||
|
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -53,15 +58,30 @@ while [[ $# -gt 0 ]]; do
|
|||||||
DELETE_BRANCH=true
|
DELETE_BRANCH=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--skip-queue-guard)
|
|
||||||
SKIP_QUEUE_GUARD=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--dry-run)
|
--dry-run)
|
||||||
DRY_RUN=true
|
DRY_RUN=true
|
||||||
SKIP_QUEUE_GUARD=true
|
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--expect-head)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: --expect-head requires one full commit SHA." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
EXPECT_HEAD="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--co-author-trailers)
|
||||||
|
CO_AUTHOR_TRAILERS=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--escalate-to)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: --escalate-to requires one principal name." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ESCALATE_TO="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
@@ -86,18 +106,49 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
|||||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
||||||
|
if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then
|
||||||
|
echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then
|
||||||
|
echo "Error: --escalate-to must be one exact principal name." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then
|
||||||
|
echo "Error: --escalate-to is valid only with --co-author-trailers." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
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())')"
|
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())')"
|
||||||
|
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
|
||||||
|
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
|
||||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
if [[ "$DRY_RUN" != true ]]; then
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||||
--purpose merge \
|
--purpose merge \
|
||||||
-B "$BASE_BRANCH" \
|
-B "$HEAD_BRANCH" \
|
||||||
|
-R "$HEAD_REPO" \
|
||||||
|
--sha "$HEAD_SHA" \
|
||||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||||
fi
|
fi
|
||||||
@@ -106,79 +157,442 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
is_known_tea_empty_identity_failure() {
|
write_curl_auth_config() {
|
||||||
local error_file="$1"
|
local mode="$1" credential="$2"
|
||||||
|
printf '%s' "$credential" | python3 -c '
|
||||||
|
import sys
|
||||||
|
mode = sys.argv[1]
|
||||||
|
credential = sys.stdin.read()
|
||||||
|
if not credential or any(char in credential for char in "\r\n"):
|
||||||
|
raise SystemExit(1)
|
||||||
|
escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
|
if mode == "token":
|
||||||
|
print(f"header = \"Authorization: token {escaped}\"")
|
||||||
|
elif mode == "basic":
|
||||||
|
print(f"user = \"{escaped}\"")
|
||||||
|
else:
|
||||||
|
raise SystemExit(1)
|
||||||
|
' "$mode"
|
||||||
|
}
|
||||||
|
|
||||||
python3 - "$error_file" <<'PY'
|
LAST_GITEA_HTTP_CODE="000"
|
||||||
import re
|
LAST_GITEA_ERROR=""
|
||||||
|
MERGE_TEMP_DIRS=()
|
||||||
|
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
|
||||||
|
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
|
||||||
|
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
|
||||||
|
for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do
|
||||||
|
if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
GITEA_CURL_BOUNDS=(
|
||||||
|
--max-filesize "$GITEA_CURL_MAX_BYTES"
|
||||||
|
--max-time "$GITEA_CURL_MAX_TIME"
|
||||||
|
--connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT"
|
||||||
|
)
|
||||||
|
|
||||||
|
format_gitea_error_response() {
|
||||||
|
local response_file="$1"
|
||||||
|
python3 - "$response_file" <<'PY'
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
with open(sys.argv[1], "rb") as handle:
|
||||||
error = handle.read()
|
raw = handle.read(65536)
|
||||||
|
try:
|
||||||
known_empty_identity = re.search(
|
response = json.loads(raw.decode("utf-8", errors="replace"))
|
||||||
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
error,
|
message = "non-JSON response omitted"
|
||||||
flags=re.IGNORECASE | re.DOTALL,
|
else:
|
||||||
)
|
if isinstance(response, dict):
|
||||||
raise SystemExit(0 if known_empty_identity else 1)
|
message = response.get("message") or response.get("error")
|
||||||
|
if not message and response.get("errors") is not None:
|
||||||
|
message = json.dumps(response["errors"], separators=(",", ":"))
|
||||||
|
else:
|
||||||
|
message = None
|
||||||
|
if not message:
|
||||||
|
message = "JSON response contained no error message"
|
||||||
|
message = str(message)
|
||||||
|
if len(message) > 500:
|
||||||
|
message = message[:500] + "..."
|
||||||
|
print(ascii(message))
|
||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
merge_gitea_with_api() {
|
cleanup_merge_temp_dirs() {
|
||||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
local path
|
||||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
||||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
[[ -n "$path" ]] && rm -rf -- "$path"
|
||||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
done
|
||||||
payload='{"Do":"squash"}'
|
}
|
||||||
|
trap cleanup_merge_temp_dirs EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
token=$(get_gitea_token "$host" || true)
|
fetch_gitea_pr_head() {
|
||||||
if [[ -n "$token" ]]; then
|
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
local response_file raw_code api_url auth_config curl_rc
|
||||||
-X POST \
|
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
|
||||||
-H "User-Agent: curl/8" \
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
||||||
-H "Authorization: token $token" \
|
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||||
-H 'Content-Type: application/json' \
|
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||||
-d "$payload" \
|
rm -f "$response_file"
|
||||||
"$api_url" || true)
|
return 1
|
||||||
if [[ "$raw_code" =~ ^2 ]]; then
|
|
||||||
rm -f "$body_file"
|
|
||||||
return 0
|
|
||||||
fi
|
fi
|
||||||
|
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \
|
||||||
|
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
|
||||||
|
curl_rc=$?
|
||||||
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||||
|
if [[ "$curl_rc" -ne 0 ]]; then
|
||||||
|
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||||
|
rm -f "$response_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||||
|
LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file")
|
||||||
|
rm -f "$response_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! python3 - "$response_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
pull = json.load(handle)
|
||||||
|
head = pull.get("head") if isinstance(pull, dict) else None
|
||||||
|
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
|
||||||
|
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(sha)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2
|
||||||
|
rm -f "$response_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$response_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_gitea_pr_commits() {
|
||||||
|
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||||
|
local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc
|
||||||
|
mkdir -p "$work_root"
|
||||||
|
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||||
|
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX")
|
||||||
|
printf '[]' > "$combined_file"
|
||||||
|
|
||||||
|
page=1
|
||||||
|
while true; do
|
||||||
|
page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX")
|
||||||
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}"
|
||||||
|
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \
|
||||||
|
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
|
||||||
|
curl_rc=$?
|
||||||
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||||
|
if [[ "$curl_rc" -ne 0 ]]; then
|
||||||
|
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||||
|
rm -f "$page_file" "$combined_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||||
|
LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file")
|
||||||
|
rm -f "$page_file" "$combined_file"
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
if ! page_count=$(python3 - "$page_file" <<'PY'
|
||||||
if [[ -n "$basic_auth" ]]; then
|
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
|
||||||
-X POST \
|
|
||||||
-u "$basic_auth" \
|
|
||||||
-H "User-Agent: curl/8" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$payload" \
|
|
||||||
"$api_url" || true)
|
|
||||||
if [[ "$raw_code" =~ ^2 ]]; then
|
|
||||||
rm -f "$body_file"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
code, path = sys.argv[1], sys.argv[2]
|
|
||||||
try:
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
page = json.load(handle)
|
||||||
raw = handle.read(500)
|
if not isinstance(page, list):
|
||||||
data = json.loads(raw) if raw else {}
|
raise SystemExit(1)
|
||||||
message = data.get("message") or data.get("error") or raw or "empty response"
|
print(len(page))
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
|
|
||||||
except Exception:
|
|
||||||
message = "unreadable response"
|
|
||||||
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
|
|
||||||
PY
|
PY
|
||||||
rm -f "$body_file"
|
); then
|
||||||
|
echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2
|
||||||
|
rm -f "$page_file" "$combined_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX")
|
||||||
|
if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
combined = json.load(handle)
|
||||||
|
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||||
|
page = json.load(handle)
|
||||||
|
json.dump(combined + page, sys.stdout, separators=(",", ":"))
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2
|
||||||
|
rm -f "$page_file" "$combined_file" "$merged_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
mv "$merged_file" "$combined_file"
|
||||||
|
rm -f "$page_file"
|
||||||
|
|
||||||
|
if [[ "$page_count" -lt 50 ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
page=$((page + 1))
|
||||||
|
if [[ "$page" -gt 1000 ]]; then
|
||||||
|
echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2
|
||||||
|
rm -f "$combined_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cat "$combined_file"
|
||||||
|
rm -f "$combined_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# LIMITATION: author.login resolution proves the commit address maps to a registered account.
|
||||||
|
# It does NOT prove the named principal authored the commit — git author metadata is self-asserted.
|
||||||
|
# This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted.
|
||||||
|
build_coauthor_message_fields() {
|
||||||
|
local commits_file="$1" context_file="$2" head_file="$3"
|
||||||
|
python3 - "$commits_file" "$context_file" "$head_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
commits_path, context_path, head_path = sys.argv[1:]
|
||||||
|
with open(commits_path, encoding="utf-8") as handle:
|
||||||
|
commits = json.load(handle)
|
||||||
|
head_sha = open(head_path, encoding="utf-8").read().strip()
|
||||||
|
context_parts = open(context_path, "rb").read().split(b"\0")
|
||||||
|
if len(context_parts) != 4 or context_parts[-1] != b"":
|
||||||
|
raise SystemExit(1)
|
||||||
|
poster, title, principal = (part.decode("utf-8") for part in context_parts[:3])
|
||||||
|
|
||||||
|
if not isinstance(commits, list) or not commits:
|
||||||
|
print(
|
||||||
|
f"BLOCK: provider returned no PR commits; author identity is unmeasurable. "
|
||||||
|
f"Refusing merge; escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if not poster:
|
||||||
|
print(
|
||||||
|
f"BLOCK: PR poster login is empty; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
|
||||||
|
if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha):
|
||||||
|
print(
|
||||||
|
f"BLOCK: inspected PR head SHA is invalid; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
trailers = []
|
||||||
|
head_seen = False
|
||||||
|
for item in commits:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr)
|
||||||
|
raise SystemExit(75)
|
||||||
|
sha = str(item.get("sha") or "<unknown>")
|
||||||
|
if sha == head_sha:
|
||||||
|
head_seen = True
|
||||||
|
commit = item.get("commit") if isinstance(item.get("commit"), dict) else {}
|
||||||
|
commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
|
||||||
|
email = str(commit_author.get("email") or "").strip()
|
||||||
|
provider_author = item.get("author") if isinstance(item.get("author"), dict) else {}
|
||||||
|
login = str(provider_author.get("login") or "").strip()
|
||||||
|
|
||||||
|
if not login:
|
||||||
|
diagnostic_email = email or "<missing>"
|
||||||
|
print(
|
||||||
|
f"BLOCK: commit {sha!r} has author.login=NULL while "
|
||||||
|
f"commit.author.email={diagnostic_email!r}; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if (
|
||||||
|
not email.isascii()
|
||||||
|
or not email.isprintable()
|
||||||
|
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
|
||||||
|
or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email)
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
f"BLOCK: commit {sha!r} has unusable linked identity "
|
||||||
|
f"author.login={login!r}, commit.author.email={email!r}; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if login == poster or login in seen:
|
||||||
|
continue
|
||||||
|
seen.add(login)
|
||||||
|
trailers.append(f"Co-authored-by: {login} <{email}>")
|
||||||
|
|
||||||
|
if not head_seen:
|
||||||
|
print(
|
||||||
|
f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if not trailers:
|
||||||
|
print("{}")
|
||||||
|
raise SystemExit(0)
|
||||||
|
if not title:
|
||||||
|
print(
|
||||||
|
f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title):
|
||||||
|
print(
|
||||||
|
f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"MergeTitleField": title,
|
||||||
|
"MergeMessageField": "\n".join(trailers),
|
||||||
|
}, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_gitea_api_attempt() {
|
||||||
|
local host="$1" auth_mode="$2" credential="$3"
|
||||||
|
local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc
|
||||||
|
LAST_GITEA_HTTP_CODE="000"
|
||||||
|
LAST_GITEA_ERROR=""
|
||||||
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||||
|
work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||||
|
mkdir -p "$work_root"
|
||||||
|
attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX")
|
||||||
|
chmod 0700 "$attempt_dir"
|
||||||
|
MERGE_TEMP_DIRS+=("$attempt_dir")
|
||||||
|
body_file=$(mktemp "$attempt_dir/api-response.XXXXXX")
|
||||||
|
fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX")
|
||||||
|
payload_file=$(mktemp "$attempt_dir/payload.XXXXXX")
|
||||||
|
printf '{}' > "$fields_file"
|
||||||
|
|
||||||
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
|
commits_file=$(mktemp "$attempt_dir/pr-merge-commits-input.XXXXXX")
|
||||||
|
context_file=$(mktemp "$attempt_dir/pr-merge-message-context.XXXXXX")
|
||||||
|
head_file=$(mktemp "$attempt_dir/pr-merge-head-input.XXXXXX")
|
||||||
|
printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file"
|
||||||
|
if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
attempt_rc=$?
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return "$attempt_rc"
|
||||||
|
fi
|
||||||
|
if [[ "$(<"$head_file")" != "$HEAD_SHA" ]]; then
|
||||||
|
echo "BLOCK: authenticated PR head moved from reviewed $HEAD_SHA to $(<"$head_file"); refusing merge; escalate to named principal '$ESCALATE_TO'." >&2
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return 75
|
||||||
|
fi
|
||||||
|
if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
attempt_rc=$?
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return "$attempt_rc"
|
||||||
|
fi
|
||||||
|
if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
attempt_rc=$?
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return "$attempt_rc"
|
||||||
|
fi
|
||||||
|
rm -f "$commits_file" "$context_file" "$head_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python3 - "$fields_file" "$HEAD_SHA" "$DELETE_BRANCH" > "$payload_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
fields = json.load(handle)
|
||||||
|
head_sha, delete_branch = sys.argv[2:]
|
||||||
|
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||||
|
if delete_branch == "true":
|
||||||
|
payload["delete_branch_after_merge"] = True
|
||||||
|
payload.update(fields)
|
||||||
|
allowed = {"Do", "head_commit_id", "delete_branch_after_merge", "MergeTitleField", "MergeMessageField"}
|
||||||
|
if payload.get("Do") != "squash" or set(payload) - allowed:
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(json.dumps(payload, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$fields_file"
|
||||||
|
|
||||||
|
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||||
|
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||||
|
rm -f "$body_file" "$payload_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \
|
||||||
|
-X POST -H "User-Agent: curl/8" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-binary "@$payload_file" "$api_url" <<<"$auth_config")
|
||||||
|
curl_rc=$?
|
||||||
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||||
|
if [[ "$curl_rc" -ne 0 ]]; then
|
||||||
|
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||||
|
rm -f "$body_file" "$payload_file"
|
||||||
|
rm -rf -- "$attempt_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||||
|
LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file")
|
||||||
|
fi
|
||||||
|
rm -f "$body_file" "$payload_file"
|
||||||
|
rm -rf -- "$attempt_dir"
|
||||||
|
[[ "$raw_code" =~ ^2 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_gitea_with_api() {
|
||||||
|
local host="$1" token attempt_rc
|
||||||
|
|
||||||
|
if ! token=$(get_gitea_token "$host"); then
|
||||||
|
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$token" ]]; then
|
||||||
|
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if merge_gitea_api_attempt "$host" token "$token"; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
attempt_rc=$?
|
||||||
|
fi
|
||||||
|
if [[ "$attempt_rc" -eq 75 ]]; then
|
||||||
|
return 75
|
||||||
|
fi
|
||||||
|
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
|
||||||
|
echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,11 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
if [[ -n "$TEA_LOGIN" ]]; then
|
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||||
@@ -202,7 +615,11 @@ fi
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
|
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||||
"${cmd[@]}"
|
"${cmd[@]}"
|
||||||
;;
|
;;
|
||||||
@@ -211,32 +628,9 @@ case "$PLATFORM" in
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||||
|
# tea cannot express it, so every Gitea merge uses the authenticated API path.
|
||||||
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"
|
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
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ PY
|
|||||||
detect_platform > /dev/null
|
detect_platform > /dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||||
write_metadata "$METADATA"
|
write_metadata "$METADATA"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
|||||||
data.get('head_ref'),
|
data.get('head_ref'),
|
||||||
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(
|
base_ref = first_non_empty(
|
||||||
nested(data, 'base', 'ref'),
|
nested(data, 'base', 'ref'),
|
||||||
nested(data, 'base', 'name'),
|
nested(data, 'base', 'name'),
|
||||||
@@ -207,6 +226,8 @@ normalized = {
|
|||||||
'state': data.get('state'),
|
'state': data.get('state'),
|
||||||
'author': nested(data, 'user', 'login') or '',
|
'author': nested(data, 'user', 'login') or '',
|
||||||
'headRefName': head_ref,
|
'headRefName': head_ref,
|
||||||
|
'headRefOid': head_sha,
|
||||||
|
'headRepository': head_repo,
|
||||||
'baseRefName': base_ref,
|
'baseRefName': base_ref,
|
||||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
'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)],
|
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
# Covers:
|
# Covers:
|
||||||
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
||||||
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
||||||
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ case "$mode" in
|
|||||||
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
||||||
500) code=500; body='{"message":"internal server error"}' ;;
|
500) code=500; body='{"message":"internal server error"}' ;;
|
||||||
no-status) code=200; body='{}' ;;
|
no-status) code=200; body='{}' ;;
|
||||||
terminal-success) code=200; body='{"state":"success"}' ;;
|
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||||
*)
|
*)
|
||||||
echo "curl stub: unknown mode=$mode" >&2
|
echo "curl stub: unknown mode=$mode" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
|
|||||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
export GITEA_TOKEN="stub-token"
|
export GITEA_TOKEN="stub-token"
|
||||||
export GITEA_URL="https://git.example.test"
|
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
|
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
|||||||
fail=1
|
fail=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
|
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
||||||
set +e
|
set +e
|
||||||
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
||||||
status_c=$?
|
status_c=$?
|
||||||
set -e
|
set -e
|
||||||
if [[ "$status_c" -eq 0 ]]; then
|
if [[ "$status_c" -ne 0 ]]; then
|
||||||
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
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
|
||||||
echo "$out_c" >&2
|
echo "$out_c" >&2
|
||||||
fail=1
|
fail=1
|
||||||
elif [[ "$out_c" == *"queue clear"* ]]; then
|
elif [[ "$out_c" == *"queue clear"* ]]; then
|
||||||
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
||||||
echo "$out_c" >&2
|
echo "$out_c" >&2
|
||||||
fail=1
|
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
|
fi
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/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)"
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
#!/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)"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Regression: detect_platform / get_repo_info must FAIL LOUDLY outside a git repo,
|
||||||
|
# not kill the caller silently.
|
||||||
|
#
|
||||||
|
# Both functions already contained the right error path:
|
||||||
|
# if [[ -z "$remote_url" ]]; then echo "error: not a git repository..." >&2; return 1; fi
|
||||||
|
# but under `set -e` -- which every wrapper in this directory uses -- the preceding
|
||||||
|
# assignment `remote_url=$(git remote get-url origin 2>/dev/null)` returns git's 128
|
||||||
|
# outside a repo and terminates the CALLER first. The message was unreachable.
|
||||||
|
#
|
||||||
|
# Observed cost: pr-review.sh invoked from a non-repo cwd exits 128 with NO stdout and
|
||||||
|
# NO stderr, even when -r/--repo and -H/--host are supplied -- the flags documented as
|
||||||
|
# "skips git-remote inference". Two reviewer seats hit this and correctly reported
|
||||||
|
# `blocked` with no diagnostic to report.
|
||||||
|
#
|
||||||
|
# The control that matters is the LOUD one: asserting "rc != 0" passes on the broken
|
||||||
|
# build too, because 128 is also non-zero. The test must assert the MESSAGE.
|
||||||
|
set -uo pipefail
|
||||||
|
fail=0
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
run_outside() { # $1=function name -> "rc:sawmessage"
|
||||||
|
local fn="$1" out rc
|
||||||
|
out=$( cd "$TMP" && bash -c "set -e; source '$HERE/detect-platform.sh'; $fn" 2>&1 ); rc=$?
|
||||||
|
printf '%s:%s' "$rc" "$(grep -qi 'not a git repository' <<<"$out" && echo yes || echo no)"
|
||||||
|
}
|
||||||
|
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||||
|
|
||||||
|
# $TMP must not be inside a git repo. Do not SKIP on failure: be-coder-07 showed the
|
||||||
|
# original SKIP exited 0, so pointing TMPDIR beneath a git worktree made this test PASS
|
||||||
|
# against unchanged main. A skip that exits 0 is indistinguishable from a pass.
|
||||||
|
# GIT_CEILING_DIRECTORIES stops git walking above $TMP, making the condition hold
|
||||||
|
# regardless of where TMPDIR lives, rather than merely detecting when it does not.
|
||||||
|
# GIT_CEILING_DIRECTORIES is matched against the PHYSICAL path -- a symlinked TMPDIR
|
||||||
|
# (/tmp is commonly one) makes the logical path never match, and the ceiling silently
|
||||||
|
# does nothing. Resolve it before exporting.
|
||||||
|
TMP="$(cd "$TMP" && pwd -P)"
|
||||||
|
export GIT_CEILING_DIRECTORIES="$TMP"
|
||||||
|
if ( cd "$TMP" && git rev-parse --git-dir >/dev/null 2>&1 ); then
|
||||||
|
echo " FAIL scratch dir is inside a git repo even with GIT_CEILING_DIRECTORIES set;"
|
||||||
|
echo " the outside-a-repo precondition cannot be established -- refusing to report a result"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== outside a git repo: rc=1 AND the diagnostic is emitted =="
|
||||||
|
check "detect_platform" "$(run_outside detect_platform)" "1:yes"
|
||||||
|
check "get_repo_info" "$(run_outside get_repo_info)" "1:yes"
|
||||||
|
|
||||||
|
echo "== inside a git repo the functions still work =="
|
||||||
|
git init -q "$TMP/repo" 2>/dev/null
|
||||||
|
git -C "$TMP/repo" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git 2>/dev/null
|
||||||
|
out=$( cd "$TMP/repo" && bash -c "set -e; source '$HERE/detect-platform.sh'; detect_platform" 2>&1 ); rc=$?
|
||||||
|
if [ "$rc" -eq 0 ] && grep -qi 'gitea' <<<"$out"; then echo " PASS detect_platform in-repo (rc=0, $out)"
|
||||||
|
else echo " FAIL detect_platform in-repo: rc=$rc out=$out"; fail=1; fi
|
||||||
|
|
||||||
|
[ "$fail" -eq 0 ] && echo "OK detect-platform fails loudly outside a repo" || echo "FAILED"
|
||||||
|
exit "$fail"
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Regression: the tea-failure diagnostic must be STATUS-NEUTRAL.
|
||||||
|
#
|
||||||
|
# Found by be-coder-08 reviewing PR #1086. At all three call sites the diagnostic is emitted
|
||||||
|
# immediately BEFORE the Gitea API fallback. Written as the last command of an && list:
|
||||||
|
# declare -F explain_... >/dev/null && explain_...
|
||||||
|
# under `set -e` a FAILING diagnostic exits and the fallback never runs -- a diagnostic that
|
||||||
|
# suppresses the recovery path it exists to explain. It misbehaves ONLY when the helper is
|
||||||
|
# PRESENT, so the helper-absent path (pre-#1086 behaviour) keeps working and reads as a
|
||||||
|
# passing control.
|
||||||
|
#
|
||||||
|
# TWO DEFECTS IN THE FIRST VERSION OF THIS TEST, both found by be-coder-08:
|
||||||
|
# 1. `out=$( ... ) 2>"$errto"` applies the redirection to the ASSIGNMENT, not to the
|
||||||
|
# command substitution, so the probe's stderr was never actually pointed at /dev/full
|
||||||
|
# and the /dev/full rows proved nothing. Verified: `out=$(echo x >&2) 2>/dev/full`
|
||||||
|
# leaks to the terminal and returns 0; the redirect must be INSIDE the substitution.
|
||||||
|
# 2. `eval "$CONSTRUCT"` changes `set -e` semantics for a bare && list, so the probe did
|
||||||
|
# not exercise the construct as the shipped file executes it. It now writes the line
|
||||||
|
# into a real script and runs it -- same parse, same set -e rules, no eval.
|
||||||
|
# The construct is still LIFTED FROM THE SHIPPED FILE: retyping the fixed form makes the
|
||||||
|
# probe pass on a build whose real call sites still carry the bare && form.
|
||||||
|
set -uo pipefail
|
||||||
|
fail=0
|
||||||
|
GIT_DIR_UNDER_TEST="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
probe() { # $1=present|absent $2=stderr target $3=source file -> "rc:fallback"
|
||||||
|
local helper="$1" errto="$2" src="$3" construct script out rc
|
||||||
|
construct=$(grep -m1 'explain_tea_user_does_not_exist' "$GIT_DIR_UNDER_TEST/$src" | sed 's/^[[:space:]]*//')
|
||||||
|
[ -n "$construct" ] || { printf 'no-construct:no'; return; }
|
||||||
|
script="$TMP/probe.sh"
|
||||||
|
{
|
||||||
|
echo '#!/bin/bash'
|
||||||
|
echo 'set -e'
|
||||||
|
echo 'explain_tea_user_does_not_exist() { echo "diagnostic" >&2; }'
|
||||||
|
[ "$helper" = absent ] && echo 'unset -f explain_tea_user_does_not_exist'
|
||||||
|
echo "$construct" # the shipped line, parsed by a real shell
|
||||||
|
echo 'echo FALLBACK_REACHED'
|
||||||
|
} > "$script"
|
||||||
|
# redirect INSIDE the substitution so the subshell's stderr really is $errto
|
||||||
|
out=$( bash "$script" 2>"$errto" ); rc=$?
|
||||||
|
printf '%s:%s' "$rc" "$(grep -q FALLBACK_REACHED <<<"$out" && echo yes || echo no)"
|
||||||
|
}
|
||||||
|
|
||||||
|
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||||
|
|
||||||
|
echo "== diagnostic must not alter exit status or skip the fallback =="
|
||||||
|
# /dev/full makes every stderr write fail -- the real-world shape is a closed or full fd.
|
||||||
|
for src in pr-create.sh issue-view.sh issue-create.sh; do
|
||||||
|
check "$src stderr OK / helper present" "$(probe present /dev/null "$src")" "0:yes"
|
||||||
|
check "$src stderr OK / helper absent " "$(probe absent /dev/null "$src")" "0:yes"
|
||||||
|
check "$src stderr FAILING / helper present" "$(probe present /dev/full "$src")" "0:yes"
|
||||||
|
check "$src stderr FAILING / helper absent " "$(probe absent /dev/full "$src")" "0:yes"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== all three call sites use the status-neutral form =="
|
||||||
|
for f in pr-create.sh issue-view.sh issue-create.sh; do
|
||||||
|
p="$GIT_DIR_UNDER_TEST/$f"
|
||||||
|
grep -q '{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true' "$p" \
|
||||||
|
&& echo " PASS $f guarded" || { echo " FAIL $f: diagnostic is not status-neutral"; fail=1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$fail" -eq 0 ] && echo "OK diagnostic is status-neutral" || echo "FAILED"
|
||||||
|
exit "$fail"
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression: issue-close.sh must NOT close an issue when the closing comment could not
|
||||||
|
# be posted, and comment+close must be made by ONE principal.
|
||||||
|
#
|
||||||
|
# Guards two defects fixed together (see #1081):
|
||||||
|
# 1. `tea issue comment` is not a subcommand -- tea exposes comments as the TOP-LEVEL
|
||||||
|
# `tea comment`. The old call always failed, was unchecked, and the issue closed
|
||||||
|
# anyway, losing the record of WHY it was closed.
|
||||||
|
# 2. Routing the comment through the token-authenticated API helper while the close
|
||||||
|
# used --login would attribute one operation to two principals.
|
||||||
|
#
|
||||||
|
# SAFETY (rev-974, #1085 review 130): this test previously ran under `set -uo pipefail`
|
||||||
|
# with unchecked mkdir/redirect/cd, then prepended a possibly-nonexistent $MOCK_BIN to
|
||||||
|
# PATH -- while `git remote add origin` names the REAL repository. Forcing setup failure
|
||||||
|
# with an unwritable AGENT_WORK_ROOT made it `git init` in its CALLER's directory and
|
||||||
|
# invoke the real, provider-mutating issue-close.sh. Setup now fails closed, and both
|
||||||
|
# `tea` and `curl` are asserted to resolve INSIDE $MOCK_BIN before any target run.
|
||||||
|
set -euo pipefail
|
||||||
|
# NOTE: with `set -e`, `grep -q X && fail "..."` is a trap -- the ABSENT case (grep rc=1,
|
||||||
|
# which is the PASSING case for a must-not-appear assertion) is the last command of an &&
|
||||||
|
# list and silently terminates the script with no message. Every must-not-appear check
|
||||||
|
# below is therefore an if-block. This is the same set -e + &&-list defect be-coder-08
|
||||||
|
# found in #1086, reintroduced here by adding `set -e` for the sandbox-safety fix.
|
||||||
|
|
||||||
|
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
|
||||||
|
SANDBOX="$WORK_ROOT/issue-close-fail-closed-test-$$"
|
||||||
|
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
|
||||||
|
cleanup() { rm -rf "$SANDBOX"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TARGET="$SCRIPT_DIR/issue-close.sh"
|
||||||
|
[ -f "$TARGET" ] || { echo "FAIL: issue-close.sh not found beside this test"; exit 1; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
|
||||||
|
# Every setup step is checked. Under `set -e` these abort; the explicit || fail keeps the
|
||||||
|
# reason legible instead of a bare non-zero exit.
|
||||||
|
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
|
||||||
|
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
|
||||||
|
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
|
||||||
|
git init -q || fail "setup: git init failed"
|
||||||
|
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
|
||||||
|
export PATH="$MOCK_BIN:$PATH" CALLS
|
||||||
|
export GITEA_URL="https://git.mosaicstack.dev"
|
||||||
|
export GITEA_TOKEN="redacted-test-token"
|
||||||
|
|
||||||
|
cat > "$MOCK_BIN/curl" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
method=GET; url=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-X) method="$2"; shift 2 ;;
|
||||||
|
http*|https*) url="$1"; shift ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf 'curl %s %s\n' "$method" "$url" >> "$CALLS"
|
||||||
|
[ "${MOCK_CURL_FAIL:-}" = "1" ] && [ "$method" = "POST" ] && exit 22
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/curl"
|
||||||
|
|
||||||
|
mk_tea() { # $1 = exit code for a comment attempt; $2 = login list (empty => no login)
|
||||||
|
local rc="$1" login="${2-}"
|
||||||
|
cat > "$MOCK_BIN/tea" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'tea %s\n' "\$*" >> "$CALLS"
|
||||||
|
if [[ "\$*" == *"login list"* ]]; then
|
||||||
|
printf '%s\n' '${login}'; exit 0
|
||||||
|
fi
|
||||||
|
# Fail ANY comment attempt -- both the correct top-level \`tea comment\` and the broken
|
||||||
|
# \`tea issue comment\` -- so an unfixed script exercises the DEFECT rather than tripping
|
||||||
|
# a setup assertion.
|
||||||
|
if [[ "\$1" == "comment" || ( "\$1" == "issue" && "\$2" == "comment" ) ]]; then exit $rc; fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/tea"
|
||||||
|
}
|
||||||
|
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||||
|
|
||||||
|
# The mocks must be the ones that run. Without this, a failed setup silently falls through
|
||||||
|
# to the real tea/curl and the "test" mutates the real provider.
|
||||||
|
assert_mocked() {
|
||||||
|
local w
|
||||||
|
for w in tea curl; do
|
||||||
|
p=$(command -v "$w" || true)
|
||||||
|
[ -n "$p" ] || fail "SAFETY: $w does not resolve at all"
|
||||||
|
case "$p" in
|
||||||
|
"$MOCK_BIN"/*) : ;;
|
||||||
|
*) fail "SAFETY: $w resolves to $p, OUTSIDE the sandbox -- refusing to invoke the target" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
run_target() { # never let a target failure abort the test; we assert on rc
|
||||||
|
# Call sites MUST use `rc=0; run_target ... || rc=$?` -- a bare `run_target ...; rc=$?`
|
||||||
|
# lets the non-zero RETURN trip set -e in the CALLER before rc is ever read.
|
||||||
|
set +e; bash "$TARGET" "$@" >/dev/null 2>&1; local rc=$?; set -e; return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── tea path ────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. NEGATIVE (the regression): comment fails => must NOT close, must exit non-zero
|
||||||
|
mk_tea 1 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||||
|
grep -qE 'tea (issue )?comment' "$CALLS" || fail "no comment attempt -- setup did not reach the tea branch"
|
||||||
|
if grep -q 'tea issue close' "$CALLS"; then fail "ISSUE CLOSED AFTER THE COMMENT FAILED -- the regression"; fi
|
||||||
|
[ "$rc" -ne 0 ] || fail "comment failed but issue-close exited 0 -- FAIL-OPEN"
|
||||||
|
|
||||||
|
# 2. POSITIVE: comment succeeds => close proceeds, exit 0
|
||||||
|
mk_tea 0 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||||
|
[ "$rc" -eq 0 ] || fail "comment succeeded but issue-close exited $rc"
|
||||||
|
grep -q 'tea issue close' "$CALLS" || fail "issue not closed even though the comment succeeded"
|
||||||
|
|
||||||
|
# 3. must use top-level `tea comment`, never `tea issue comment`
|
||||||
|
if grep -q 'tea issue comment' "$CALLS"; then fail "used 'tea issue comment' -- not a valid subcommand"; fi
|
||||||
|
|
||||||
|
# 4. ONE PRINCIPAL: comment and close must carry the SAME --login
|
||||||
|
c=$(grep -m1 '^tea comment' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
||||||
|
k=$(grep -m1 '^tea issue close' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
||||||
|
[ -n "$c" ] || fail "comment carried no --login"
|
||||||
|
[ "$c" = "$k" ] || fail "MIXED PRINCIPALS: comment=$c close=$k"
|
||||||
|
|
||||||
|
# ── no-login / API fallback path ────────────────────────────────────────────────────
|
||||||
|
# rev-974: the delta also adds fail-closed behaviour to this branch, and the suite never
|
||||||
|
# reached it -- replacing the whole fallback contract with an unconditional close still
|
||||||
|
# passed. These assert the POSTCONDITION (which HTTP calls happened, in what order),
|
||||||
|
# not merely that a command ran.
|
||||||
|
# 5. no login + comment FAILS => POST attempted, NO PATCH, non-zero
|
||||||
|
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; MOCK_CURL_FAIL=1 run_target -i 42 -c "closing note" || rc=$?
|
||||||
|
grep -q 'curl POST' "$CALLS" || fail "API path: no comment POST attempted"
|
||||||
|
if grep -q 'curl PATCH' "$CALLS"; then fail "API path: ISSUE CLOSED (PATCH) AFTER THE COMMENT POST FAILED"; fi
|
||||||
|
[ "$rc" -ne 0 ] || fail "API path: comment failed but exited 0 -- FAIL-OPEN"
|
||||||
|
|
||||||
|
# 6. no login + comment SUCCEEDS => POST strictly BEFORE PATCH, exit 0
|
||||||
|
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||||
|
[ "$rc" -eq 0 ] || fail "API path: comment succeeded but exited $rc"
|
||||||
|
order=$(grep -oE 'curl (POST|PATCH)' "$CALLS" | awk '{print $2}' | paste -sd, -)
|
||||||
|
[ "$order" = "POST,PATCH" ] || fail "API path: expected POST,PATCH -- got '${order:-<none>}'"
|
||||||
|
|
||||||
|
# 7. no login + NO comment => PATCH only, never a POST
|
||||||
|
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; run_target -i 42 || rc=$?
|
||||||
|
[ "$rc" -eq 0 ] || fail "API path: no-comment close exited $rc"
|
||||||
|
if grep -q 'curl POST' "$CALLS"; then fail "API path: posted a comment when none was requested"; fi
|
||||||
|
grep -q 'curl PATCH' "$CALLS" || fail "API path: issue not closed when no comment was requested"
|
||||||
|
|
||||||
|
echo "issue-close.sh fail-closed + single-principal regression passed"
|
||||||
@@ -280,7 +280,10 @@ print("201")
|
|||||||
print(json.dumps(record))
|
print(json.dumps(record))
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
response_status="${result%%$'\n'*}"
|
||||||
|
response_body=""
|
||||||
|
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
||||||
|
write_response "$response_status" "$response_body"
|
||||||
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
|
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
|
||||||
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
|
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
@@ -299,7 +302,10 @@ else:
|
|||||||
print(json.dumps(match))
|
print(json.dumps(match))
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
response_status="${result%%$'\n'*}"
|
||||||
|
response_body=""
|
||||||
|
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
||||||
|
write_response "$response_status" "$response_body"
|
||||||
else
|
else
|
||||||
echo "Unexpected curl request: $method $url" >&2
|
echo "Unexpected curl request: $method $url" >&2
|
||||||
exit 97
|
exit 97
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ section_nums() { # $1 = output $2 = header-prefix
|
|||||||
}
|
}
|
||||||
|
|
||||||
fail() { echo "FAIL: $1" >&2; exit 1; }
|
fail() { echo "FAIL: $1" >&2; exit 1; }
|
||||||
contains() { printf '%s\n' "$1" | grep -qx "$2"; }
|
contains() { grep -qx "$2" <<<"$1"; }
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixed (current) script behavior
|
# Fixed (current) script behavior
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -51,22 +51,23 @@ for arg in "$@"; do
|
|||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$prev" == "-d" ]]; then
|
if [[ "$prev" == "data" ]]; then
|
||||||
post_data="$arg"
|
post_data="$arg"
|
||||||
|
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
|
||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$arg" == "-o" ]]; then
|
if [[ "$prev" == "config" ]]; then
|
||||||
prev="-o"
|
[[ "$arg" == "-" ]] && cat >/dev/null
|
||||||
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$arg" == "-d" ]]; then
|
case "$arg" in
|
||||||
prev="-d"
|
-o) prev="-o" ;;
|
||||||
continue
|
-d|--data|--data-binary) prev="data" ;;
|
||||||
fi
|
-K|--config) prev="config" ;;
|
||||||
if [[ "$arg" == "-w" ]]; then
|
-w) write_code=true ;;
|
||||||
write_code=true
|
esac
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
emit_response() {
|
emit_response() {
|
||||||
local body="$1"
|
local body="$1"
|
||||||
@@ -79,15 +80,24 @@ emit_response() {
|
|||||||
printf '200'
|
printf '200'
|
||||||
fi
|
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
|
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"},"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","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
POST_DATA="$post_data" python3 - <<'PY'
|
||||||
echo "unexpected merge payload: $post_data" >&2
|
import json
|
||||||
exit 96
|
import os
|
||||||
fi
|
payload = json.loads(os.environ["POST_DATA"])
|
||||||
|
assert payload == {
|
||||||
|
"Do": "squash",
|
||||||
|
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
}, payload
|
||||||
|
PY
|
||||||
emit_response '{"merged":true,"message":"mock merge complete"}'
|
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -107,8 +117,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
|
|||||||
export GITEA_TOKEN="redacted-test-token"
|
export GITEA_TOKEN="redacted-test-token"
|
||||||
|
|
||||||
OUTPUT="$SANDBOX/output.log"
|
OUTPUT="$SANDBOX/output.log"
|
||||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
||||||
echo "--- output ---" >&2
|
echo "--- output ---" >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
echo "--- mock log ---" >&2
|
echo "--- mock log ---" >&2
|
||||||
@@ -127,38 +137,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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'
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -177,8 +155,8 @@ EOF
|
|||||||
chmod +x "$MOCK_BIN/tea"
|
chmod +x "$MOCK_BIN/tea"
|
||||||
unset GITEA_LOGIN
|
unset GITEA_LOGIN
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -215,7 +193,7 @@ cd "$REPO_DIR"
|
|||||||
git remote set-url origin https://github.com/mosaicstack/stack.git
|
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
rm -f "$SENTINEL"
|
rm -f "$SENTINEL"
|
||||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -240,7 +218,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
|||||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
rm -f "$SENTINEL"
|
rm -f "$SENTINEL"
|
||||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -260,4 +238,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "pr-merge.sh Gitea fallback regression passed"
|
echo "pr-merge.sh Gitea exact-head API regression passed"
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/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=""
|
||||||
|
out_file=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-d|--data|--data-binary)
|
||||||
|
payload="$2"
|
||||||
|
[[ "$payload" == @* ]] && payload=$(<"${payload#@}")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
out_file="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-K|--config)
|
||||||
|
[[ "$2" == "-" ]] && cat >/dev/null
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-w|-X|-H)
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||||
|
[[ -n "$out_file" ]] && printf '{}' > "$out_file"
|
||||||
|
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)"
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for the optional, identity-checked Gitea squash message.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}"
|
||||||
|
ORIG_PATH="$PATH"
|
||||||
|
failures=0
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL $1" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
make_case() {
|
||||||
|
local name="$1" case_dir
|
||||||
|
case_dir="$WORK_DIR/$name"
|
||||||
|
mkdir -p "$case_dir/bin" "$case_dir/agent"
|
||||||
|
cp "$SUBJECT" "$case_dir/pr-merge.sh"
|
||||||
|
chmod +x "$case_dir/pr-merge.sh"
|
||||||
|
|
||||||
|
cat > "$case_dir/detect-platform.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
detect_platform() { PLATFORM=gitea; printf 'gitea\n'; }
|
||||||
|
get_repo_owner() { printf 'acme\n'; }
|
||||||
|
get_repo_name() { printf 'widgets\n'; }
|
||||||
|
get_remote_host() { printf 'git.example.test\n'; }
|
||||||
|
get_gitea_token() {
|
||||||
|
printf 'resolved\n' >> "${MOSAIC_TEST_TOKEN_RESOLUTION_LOG:?}"
|
||||||
|
if [[ "${MOSAIC_TEST_TOKEN_AVAILABLE:-true}" != "true" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf 'fixture-token\n'
|
||||||
|
}
|
||||||
|
get_gitea_basic_auth() {
|
||||||
|
printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}"
|
||||||
|
if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then
|
||||||
|
printf 'fixture-user:fixture-password\n'
|
||||||
|
return "${MOSAIC_TEST_BASIC_RC:-0}"
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
get_gitea_login_for_host() { return 1; }
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/pr-metadata.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then
|
||||||
|
title='Preserve authors\n\nCo-authored-by: victim <[email protected]>'
|
||||||
|
else
|
||||||
|
title='Preserve both branch authors'
|
||||||
|
fi
|
||||||
|
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||||
|
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||||
|
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||||
|
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||||
|
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/bin/python3" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||||
|
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec "${MOSAIC_TEST_REAL_PYTHON:?}" "$@"
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/bin/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||||
|
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
url=""
|
||||||
|
method="GET"
|
||||||
|
out_file=""
|
||||||
|
data=""
|
||||||
|
config=""
|
||||||
|
auth_mode="none"
|
||||||
|
has_max_filesize=0
|
||||||
|
has_max_time=0
|
||||||
|
has_connect_timeout=0
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-o)
|
||||||
|
out_file="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-w)
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-X)
|
||||||
|
method="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-d|--data|--data-binary)
|
||||||
|
data="$2"
|
||||||
|
if [[ "$data" == @* ]]; then
|
||||||
|
data=$(<"${data#@}")
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-K|--config)
|
||||||
|
if [[ "$2" == "-" ]]; then
|
||||||
|
config=$(cat)
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--max-filesize)
|
||||||
|
has_max_filesize=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--max-time)
|
||||||
|
has_max_time=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--connect-timeout)
|
||||||
|
has_connect_timeout=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-H|--header|-u|--user)
|
||||||
|
if [[ "$2" == *"fixture-token"* ]]; then
|
||||||
|
: > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}"
|
||||||
|
fi
|
||||||
|
if [[ "$2" == *"fixture-password"* ]]; then
|
||||||
|
: > "${MOSAIC_TEST_BASIC_ARGV_MARKER:?}"
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
http://*|https://*)
|
||||||
|
url="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$config" == *"Authorization: token fixture-token"* ]]; then
|
||||||
|
auth_mode="token"
|
||||||
|
: > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}"
|
||||||
|
elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then
|
||||||
|
auth_mode="basic"
|
||||||
|
: > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}"
|
||||||
|
fi
|
||||||
|
printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}"
|
||||||
|
printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}"
|
||||||
|
|
||||||
|
case "$url" in
|
||||||
|
*/pulls/42)
|
||||||
|
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||||
|
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||||
|
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||||
|
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||||
|
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
if [[ "${MOSAIC_TEST_HEAD_MODE:-stable}" == "moved" ]]; then
|
||||||
|
head_sha=4444444444444444444444444444444444444444
|
||||||
|
fi
|
||||||
|
body="{\"head\":{\"sha\":\"$head_sha\"}}"
|
||||||
|
code=200
|
||||||
|
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||||
|
body='{"message":"token rejected"}'
|
||||||
|
code=401
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/pulls/42/commits*)
|
||||||
|
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||||
|
verified)
|
||||||
|
if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then
|
||||||
|
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
else
|
||||||
|
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
null-login)
|
||||||
|
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"[email protected]\n\u001b[31m"}},"author":null}]'
|
||||||
|
;;
|
||||||
|
unsafe-identity)
|
||||||
|
body='[{"sha":"unsafe\n\u001b[31m","commit":{"author":{"name":"Unsafe","email":"not-an-email"}},"author":{"login":"unsafe"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
;;
|
||||||
|
single)
|
||||||
|
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unknown commits mode" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
code=200
|
||||||
|
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||||
|
body='{"message":"token rejected"}'
|
||||||
|
code=401
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/pulls/42/merge)
|
||||||
|
body='{}'
|
||||||
|
code=200
|
||||||
|
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "merge" && "$auth_mode" == "token" ]]; then
|
||||||
|
body='{"message":"token rejected"}'
|
||||||
|
code=401
|
||||||
|
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "provider-error" ]]; then
|
||||||
|
body='{"message":"branch policy rejected\n\u001b[31m"}'
|
||||||
|
code=409
|
||||||
|
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "forbidden" ]]; then
|
||||||
|
body='{"message":"permission denied"}'
|
||||||
|
code=403
|
||||||
|
else
|
||||||
|
printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/users/*)
|
||||||
|
body='{"message":"not found"}'
|
||||||
|
code=404
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
body='{"message":"unexpected URL"}'
|
||||||
|
code=500
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ -n "$out_file" ]]; then
|
||||||
|
printf '%s' "$body" > "$out_file"
|
||||||
|
else
|
||||||
|
printf '%s' "$body"
|
||||||
|
fi
|
||||||
|
printf '%s' "$code"
|
||||||
|
case "${MOSAIC_TEST_CURL_FAILURE:-none}" in
|
||||||
|
oversize) exit 63 ;;
|
||||||
|
stalled) exit 28 ;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
|
||||||
|
chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \
|
||||||
|
"$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" "$case_dir/bin/python3"
|
||||||
|
printf '%s\n' "$case_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_case() {
|
||||||
|
local case_dir="$1" mode="$2"
|
||||||
|
shift 2
|
||||||
|
MOSAIC_TEST_COMMITS_MODE="$mode" \
|
||||||
|
MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \
|
||||||
|
MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \
|
||||||
|
MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \
|
||||||
|
MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \
|
||||||
|
MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \
|
||||||
|
MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \
|
||||||
|
MOSAIC_TEST_BASIC_CONFIG_MARKER="$case_dir/basic-via-config" \
|
||||||
|
MOSAIC_TEST_TOKEN_RESOLUTION_LOG="$case_dir/token-resolution.log" \
|
||||||
|
MOSAIC_TEST_BASIC_RESOLUTION_LOG="$case_dir/basic-resolution.log" \
|
||||||
|
MOSAIC_TEST_METADATA_ARGV_MARKER="$case_dir/metadata-in-argv" \
|
||||||
|
MOSAIC_TEST_REAL_PYTHON="$(command -v python3)" \
|
||||||
|
AGENT_WORK_ROOT="$case_dir/agent" \
|
||||||
|
PATH="$case_dir/bin:$ORIG_PATH" \
|
||||||
|
"$case_dir/pr-merge.sh" -n 42 "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verified multi-author path: the non-poster trailer is built from one commit's
|
||||||
|
# linked author.login and that same commit's author email. No /users lookup.
|
||||||
|
verified_dir=$(make_case verified)
|
||||||
|
set +e
|
||||||
|
verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
verified_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$verified_rc" -ne 0 ]]; then
|
||||||
|
fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output"
|
||||||
|
elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then
|
||||||
|
fail "verified multi-author merge did not reach the API payload"
|
||||||
|
else
|
||||||
|
python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields"
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert payload == {
|
||||||
|
"Do": "squash",
|
||||||
|
"head_commit_id": "2222222222222222222222222222222222222222",
|
||||||
|
"MergeTitleField": "Preserve both branch authors",
|
||||||
|
"MergeMessageField": "Co-authored-by: alice <[email protected]>",
|
||||||
|
}, payload
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config"
|
||||||
|
[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv"
|
||||||
|
[[ ! -e "$verified_dir/metadata-in-argv" ]] || fail "verified path placed PR title or contributor email in child argv"
|
||||||
|
[[ "$(wc -l < "$verified_dir/token-resolution.log")" -eq 1 ]] || fail "verified path did not bind inspection and merge to one credential resolution"
|
||||||
|
if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then
|
||||||
|
fail "verified path performed a forbidden second /users lookup"
|
||||||
|
fi
|
||||||
|
if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then
|
||||||
|
fail "verified path did not apply size/max-time/connect-time bounds to every provider download"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A linked email containing a terminal escape must block before mutation.
|
||||||
|
escape_email_dir=$(make_case escape-email)
|
||||||
|
set +e
|
||||||
|
escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
escape_email_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed"
|
||||||
|
[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API"
|
||||||
|
|
||||||
|
# Curl transfer and duration failures must remain failures even with HTTP 200.
|
||||||
|
for failure_mode in oversize stalled; do
|
||||||
|
failure_dir=$(make_case "curl-$failure_mode")
|
||||||
|
set +e
|
||||||
|
failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
failure_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output"
|
||||||
|
[[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The authenticated head is re-read under the mutation credential but cannot
|
||||||
|
# replace the canonical preflight/review head. A move blocks before enumeration
|
||||||
|
# or mutation even though the provider returned a valid new SHA.
|
||||||
|
moved_dir=$(make_case moved-head)
|
||||||
|
set +e
|
||||||
|
moved_output=$(MOSAIC_TEST_HEAD_MODE=moved \
|
||||||
|
run_case "$moved_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
moved_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$moved_rc" -ne 0 ]] || fail "moved authenticated head unexpectedly passed"
|
||||||
|
[[ "$moved_output" == *"authenticated PR head moved from reviewed"* ]] || fail "moved head refusal lost its diagnostic"
|
||||||
|
[[ "$moved_output" == *"tl-mosaic"* ]] || fail "moved head refusal omitted the named escalation principal"
|
||||||
|
[[ ! -e "$moved_dir/merge-payload.json" ]] || fail "moved head refusal reached the merge API"
|
||||||
|
moved_sequence=$(awk '{print $1 ":" $2}' "$moved_dir/curl.log" | paste -sd, -)
|
||||||
|
[[ "$moved_sequence" == "GET:token" ]] || fail "moved head refusal performed post-move inspection/mutation (calls=$moved_sequence)"
|
||||||
|
|
||||||
|
# Token resolution failure is not an authentication response. It must fail
|
||||||
|
# closed instead of borrowing a Basic credential under a different principal.
|
||||||
|
token_missing_dir=$(make_case token-missing)
|
||||||
|
set +e
|
||||||
|
token_missing_output=$(MOSAIC_TEST_TOKEN_AVAILABLE=false MOSAIC_TEST_BASIC_AVAILABLE=true \
|
||||||
|
run_case "$token_missing_dir" single 2>&1)
|
||||||
|
token_missing_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$token_missing_rc" -ne 0 ]] || fail "missing token unexpectedly borrowed Basic Auth"
|
||||||
|
[[ "$token_missing_output" == *"required Gitea token"* ]] || fail "missing token refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure"
|
||||||
|
[[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request"
|
||||||
|
|
||||||
|
# A failed Basic resolver must never use its nonempty output or reach mutation.
|
||||||
|
basic_rc_dir=$(make_case basic-resolver-rc)
|
||||||
|
set +e
|
||||||
|
basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||||
|
run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
basic_rc_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output"
|
||||||
|
[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API"
|
||||||
|
|
||||||
|
# HTTP 401 never changes principals: inspection rejection fails closed without
|
||||||
|
# resolving or attempting Basic Auth.
|
||||||
|
fallback_inspect_dir=$(make_case fallback-inspection)
|
||||||
|
set +e
|
||||||
|
fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||||
|
run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
fallback_inspect_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals"
|
||||||
|
[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic"
|
||||||
|
[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth"
|
||||||
|
[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation"
|
||||||
|
inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -)
|
||||||
|
[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)"
|
||||||
|
|
||||||
|
# Token rejection at merge likewise fails closed without cross-principal retry.
|
||||||
|
fallback_merge_dir=$(make_case fallback-merge)
|
||||||
|
set +e
|
||||||
|
fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \
|
||||||
|
run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
fallback_merge_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals"
|
||||||
|
[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic"
|
||||||
|
[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth"
|
||||||
|
[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload"
|
||||||
|
merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -)
|
||||||
|
[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)"
|
||||||
|
|
||||||
|
# BLOCK path: a commit email exists but author.login is null. It must name both
|
||||||
|
# facts, name the escalation principal, and never reach the merge endpoint.
|
||||||
|
null_dir=$(make_case null-login)
|
||||||
|
set +e
|
||||||
|
null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
null_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK"
|
||||||
|
[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic"
|
||||||
|
[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact"
|
||||||
|
[[ "$null_output" == *"[email protected]"* ]] || fail "null-login author omitted the commit email fact"
|
||||||
|
[[ "$null_output" == *'\n\x1b[31m'* ]] || fail "null-login author diagnostic did not escape control characters"
|
||||||
|
[[ "$null_output" != *$'\033'* ]] || fail "null-login author diagnostic emitted a raw terminal escape"
|
||||||
|
[[ "$(printf '%s\n' "$null_output" | wc -l)" -eq 1 ]] || fail "null-login author diagnostic permitted newline injection"
|
||||||
|
[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal"
|
||||||
|
[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API"
|
||||||
|
|
||||||
|
# Every provider-derived field in alternate BLOCK diagnostics is log-safe too,
|
||||||
|
# including an invalid non-head SHA that contains control characters.
|
||||||
|
unsafe_dir=$(make_case unsafe-identity)
|
||||||
|
set +e
|
||||||
|
unsafe_output=$(run_case "$unsafe_dir" unsafe-identity --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
unsafe_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$unsafe_rc" -ne 0 ]] || fail "unsafe identity expected a non-zero BLOCK"
|
||||||
|
[[ "$unsafe_output" == *"unusable linked identity"* ]] || fail "unsafe identity omitted its BLOCK reason"
|
||||||
|
[[ "$unsafe_output" == *'\n\x1b[31m'* ]] || fail "unsafe identity SHA did not escape control characters"
|
||||||
|
[[ "$unsafe_output" != *$'\033'* ]] || fail "unsafe identity diagnostic emitted a raw terminal escape"
|
||||||
|
[[ "$(printf '%s\n' "$unsafe_output" | wc -l)" -eq 1 ]] || fail "unsafe identity diagnostic permitted newline injection"
|
||||||
|
[[ ! -e "$unsafe_dir/merge-payload.json" ]] || fail "unsafe identity BLOCK still reached the merge API"
|
||||||
|
|
||||||
|
# The provider PR title cannot add an unchecked trailer outside the constructed
|
||||||
|
# message field: multi-line and trailer-shaped titles block before mutation.
|
||||||
|
title_dir=$(make_case title-injection)
|
||||||
|
set +e
|
||||||
|
title_output=$(MOSAIC_TEST_TITLE_MODE=injection \
|
||||||
|
run_case "$title_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
title_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$title_rc" -ne 0 ]] || fail "title trailer injection unexpectedly passed"
|
||||||
|
[[ "$title_output" == *"not one printable, non-trailer line"* ]] || fail "title injection refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$title_dir/merge-payload.json" ]] || fail "title injection reached the merge API"
|
||||||
|
|
||||||
|
# Provider failures remain diagnosable after their temporary response file is
|
||||||
|
# removed, but provider-controlled control characters stay log-safe.
|
||||||
|
error_dir=$(make_case provider-error)
|
||||||
|
set +e
|
||||||
|
error_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=provider-error \
|
||||||
|
run_case "$error_dir" single 2>&1)
|
||||||
|
error_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$error_rc" -ne 0 ]] || fail "provider error unexpectedly passed"
|
||||||
|
[[ "$error_output" == *"HTTP 409"* ]] || fail "provider error omitted the HTTP status"
|
||||||
|
[[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded"
|
||||||
|
[[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters"
|
||||||
|
[[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape"
|
||||||
|
[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback"
|
||||||
|
[[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback"
|
||||||
|
|
||||||
|
# Authorization denials likewise fail closed instead of changing principals.
|
||||||
|
forbidden_dir=$(make_case forbidden)
|
||||||
|
set +e
|
||||||
|
forbidden_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=forbidden \
|
||||||
|
run_case "$forbidden_dir" single 2>&1)
|
||||||
|
forbidden_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed"
|
||||||
|
[[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status"
|
||||||
|
[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback"
|
||||||
|
[[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback"
|
||||||
|
|
||||||
|
# The BLOCK destination cannot be generic or inferred after failure: opting in
|
||||||
|
# without a named principal is refused before any provider operation.
|
||||||
|
principal_dir=$(make_case missing-principal)
|
||||||
|
set +e
|
||||||
|
principal_output=$(run_case "$principal_dir" verified --co-author-trailers 2>&1)
|
||||||
|
principal_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$principal_rc" -ne 0 ]] || fail "co-author mode without a named principal unexpectedly passed"
|
||||||
|
[[ "$principal_output" == *"requires --escalate-to with a named principal"* ]] || fail "missing-principal refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$principal_dir/merge-payload.json" ]] || fail "missing-principal refusal reached the merge API"
|
||||||
|
|
||||||
|
# A trailing value-taking option receives a stable CLI diagnostic instead of a
|
||||||
|
# set -u unbound-variable crash.
|
||||||
|
value_dir=$(make_case missing-principal-value)
|
||||||
|
set +e
|
||||||
|
value_output=$(run_case "$value_dir" verified --co-author-trailers --escalate-to 2>&1)
|
||||||
|
value_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$value_rc" -ne 0 ]] || fail "missing --escalate-to value unexpectedly passed"
|
||||||
|
[[ "$value_output" == *"--escalate-to requires one principal name"* ]] || fail "missing --escalate-to value lost its diagnostic"
|
||||||
|
[[ "$value_output" != *"unbound variable"* ]] || fail "missing --escalate-to value crashed under set -u"
|
||||||
|
[[ ! -e "$value_dir/merge-payload.json" ]] || fail "missing --escalate-to value reached the merge API"
|
||||||
|
|
||||||
|
# Negative control: ordinary single-author merge remains byte-for-byte payload
|
||||||
|
# compatible and hardcoded to squash, with no optional message fields.
|
||||||
|
single_dir=$(make_case single)
|
||||||
|
set +e
|
||||||
|
single_output=$(run_case "$single_dir" single 2>&1)
|
||||||
|
single_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$single_rc" -ne 0 ]]; then
|
||||||
|
fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output"
|
||||||
|
elif [[ ! -s "$single_dir/merge-payload.json" ]]; then
|
||||||
|
fail "ordinary single-author merge did not reach the API payload"
|
||||||
|
else
|
||||||
|
python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed"
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert payload == {
|
||||||
|
"Do": "squash",
|
||||||
|
"head_commit_id": "1111111111111111111111111111111111111111",
|
||||||
|
}, payload
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config"
|
||||||
|
[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv"
|
||||||
|
[[ "$(wc -l < "$single_dir/token-resolution.log")" -eq 1 ]] || fail "ordinary path did not use exactly one credential resolution"
|
||||||
|
|
||||||
|
# Squash is not defaultable: an explicit non-squash method must remain refused.
|
||||||
|
method_dir=$(make_case method-refusal)
|
||||||
|
set +e
|
||||||
|
method_output=$(run_case "$method_dir" single -m merge 2>&1)
|
||||||
|
method_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed"
|
||||||
|
[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic"
|
||||||
|
[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API"
|
||||||
|
|
||||||
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
|
echo "pr-merge message-field regression failed ($failures assertions)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/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"
|
||||||
@@ -225,7 +225,10 @@ write_response() {
|
|||||||
emit() {
|
emit() {
|
||||||
# Split a two-line "status\n<json body>" python result into the response.
|
# Split a two-line "status\n<json body>" python result into the response.
|
||||||
local result="$1"
|
local result="$1"
|
||||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
response_status="${result%%$'\n'*}"
|
||||||
|
response_body=""
|
||||||
|
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
||||||
|
write_response "$response_status" "$response_body"
|
||||||
}
|
}
|
||||||
|
|
||||||
mode="${PR_REVIEW_TEST_MODE:-}"
|
mode="${PR_REVIEW_TEST_MODE:-}"
|
||||||
|
|||||||
@@ -222,8 +222,8 @@ grep -q 'Unknown action: bogus-action' "$OUTPUT_FILE"
|
|||||||
|
|
||||||
# --- Case 2: -h/--help documents both overrides.
|
# --- Case 2: -h/--help documents both overrides.
|
||||||
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
|
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
|
||||||
echo "$HELP_TEXT" | grep -q -- '-r, --repo'
|
grep -q -- '-r, --repo' <<<"$HELP_TEXT"
|
||||||
echo "$HELP_TEXT" | grep -q -- '-H, --host'
|
grep -q -- '-H, --host' <<<"$HELP_TEXT"
|
||||||
|
|
||||||
# --- Case 3 (comment): a TRUE no-git-origin dir + -r/-H must not silently die
|
# --- Case 3 (comment): a TRUE no-git-origin dir + -r/-H must not silently die
|
||||||
# and must not fail with "not a git repository or no origin remote" either.
|
# and must not fail with "not a git repository or no origin remote" either.
|
||||||
|
|||||||
@@ -91,10 +91,12 @@ fi
|
|||||||
|
|
||||||
if [[ -n "$dirty_files" ]]; then
|
if [[ -n "$dirty_files" ]]; then
|
||||||
echo " Modified files:"
|
echo " Modified files:"
|
||||||
echo "$dirty_files" | head -20 | while IFS= read -r line; do
|
mapfile -t dirty_lines <<<"$dirty_files"
|
||||||
echo " $line"
|
file_count="${#dirty_lines[@]}"
|
||||||
|
display_count=$((file_count < 20 ? file_count : 20))
|
||||||
|
for ((i = 0; i < display_count; i++)); do
|
||||||
|
echo " ${dirty_lines[$i]}"
|
||||||
done
|
done
|
||||||
file_count="$(echo "$dirty_files" | wc -l)"
|
|
||||||
if (( file_count > 20 )); then
|
if (( file_count > 20 )); then
|
||||||
echo " ... and $(( file_count - 20 )) more"
|
echo " ... and $(( file_count - 20 )) more"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ if jq -e '.next_task == "T-001"' "$capsule_file" >/dev/null 2>&1; then pass_case
|
|||||||
if grep -Fq 'Target runtime:** codex' <<< "$codex_continue_output"; then pass_case "continue prompt contains target runtime codex"; else fail_case "continue prompt contains target runtime codex"; fi
|
if grep -Fq 'Target runtime:** codex' <<< "$codex_continue_output"; then pass_case "continue prompt contains target runtime codex"; else fail_case "continue prompt contains target runtime codex"; fi
|
||||||
|
|
||||||
codex_run_prompt="$(MOSAIC_COORD_RUNTIME=codex bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
|
codex_run_prompt="$(MOSAIC_COORD_RUNTIME=codex bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
|
||||||
if [[ "$(printf '%s\n' "$codex_run_prompt" | head -n1)" == "Now initiating Orchestrator mode..." ]]; then pass_case "codex run prompt first line is mode declaration"; else fail_case "codex run prompt first line is mode declaration"; fi
|
if [[ "${codex_run_prompt%%$'\n'*}" == "Now initiating Orchestrator mode..." ]]; then pass_case "codex run prompt first line is mode declaration"; else fail_case "codex run prompt first line is mode declaration"; fi
|
||||||
if grep -Fq 'Do NOT ask clarifying questions before your first tool actions' <<< "$codex_run_prompt"; then pass_case "codex run prompt includes no-questions hard gate"; else fail_case "codex run prompt includes no-questions hard gate"; fi
|
if grep -Fq 'Do NOT ask clarifying questions before your first tool actions' <<< "$codex_run_prompt"; then pass_case "codex run prompt includes no-questions hard gate"; else fail_case "codex run prompt includes no-questions hard gate"; fi
|
||||||
if grep -Fq '"next_task": "T-001"' <<< "$codex_run_prompt"; then pass_case "codex run prompt embeds capsule json"; else fail_case "codex run prompt embeds capsule json"; fi
|
if grep -Fq '"next_task": "T-001"' <<< "$codex_run_prompt"; then pass_case "codex run prompt embeds capsule json"; else fail_case "codex run prompt embeds capsule json"; fi
|
||||||
|
|
||||||
claude_run_prompt="$(MOSAIC_COORD_RUNTIME=claude bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
|
claude_run_prompt="$(MOSAIC_COORD_RUNTIME=claude bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
|
||||||
if [[ "$(printf '%s\n' "$claude_run_prompt" | head -n1)" == "## Continuation Mission" ]]; then pass_case "claude run prompt remains continuation prompt format"; else fail_case "claude run prompt remains continuation prompt format"; fi
|
if [[ "${claude_run_prompt%%$'\n'*}" == "## Continuation Mission" ]]; then pass_case "claude run prompt remains continuation prompt format"; else fail_case "claude run prompt remains continuation prompt format"; fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Smoke test summary: pass=$PASS fail=$FAIL"
|
echo "Smoke test summary: pass=$PASS fail=$FAIL"
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ L="$WORK/live5.md"; G="$WORK/ledger5.md"; echo "# LEDGER" > "$G"
|
|||||||
make_board "$L" 6 1 400
|
make_board "$L" 6 1 400
|
||||||
before_l=$(cat "$L"); before_g=$(cat "$G")
|
before_l=$(cat "$L"); before_g=$(cat "$G")
|
||||||
out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 2000 --dry-run 2>&1) || note "dry-run exited nonzero: $out"
|
out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 2000 --dry-run 2>&1) || note "dry-run exited nonzero: $out"
|
||||||
echo "$out" | grep -qi "dry run" || note "dry-run did not announce itself"
|
grep -qi "dry run" <<<"$out" || note "dry-run did not announce itself"
|
||||||
echo "$out" | grep -q "would roll" || note "dry-run did not report a plan"
|
grep -q "would roll" <<<"$out" || note "dry-run did not report a plan"
|
||||||
[[ "$(cat "$L")" == "$before_l" ]] || note "dry-run modified LIVE"
|
[[ "$(cat "$L")" == "$before_l" ]] || note "dry-run modified LIVE"
|
||||||
[[ "$(cat "$G")" == "$before_g" ]] || note "dry-run modified LEDGER"
|
[[ "$(cat "$G")" == "$before_g" ]] || note "dry-run modified LEDGER"
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ present=0
|
|||||||
|
|
||||||
for entry in "${PRDY_REQUIRED_SECTIONS[@]}"; do
|
for entry in "${PRDY_REQUIRED_SECTIONS[@]}"; do
|
||||||
pattern="${entry#*|}"
|
pattern="${entry#*|}"
|
||||||
if echo "$PRD_CONTENT" | grep -qiE "$pattern"; then
|
if grep -qiE "$pattern" <<<"$PRD_CONTENT"; then
|
||||||
present=$((present + 1))
|
present=$((present + 1))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -169,13 +169,13 @@ main() {
|
|||||||
# classify_surface PATH → surface name (highest-risk match wins, mirrors TS)
|
# classify_surface PATH → surface name (highest-risk match wins, mirrors TS)
|
||||||
classify_surface() {
|
classify_surface() {
|
||||||
local p="$1"
|
local p="$1"
|
||||||
if printf '%s' "$p" | grep -qiE 'auth|login|session|token|permission|rbac|credential|secret'; then echo auth; return; fi
|
if grep -qiE 'auth|login|session|token|permission|rbac|credential|secret' <<<"$p"; then echo auth; return; fi
|
||||||
if printf '%s' "$p" | grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed'; then echo data; return; fi
|
if grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed' <<<"$p"; then echo data; return; fi
|
||||||
if printf '%s' "$p" | grep -qiE 'docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform'; then echo infra; return; fi
|
if grep -qiE 'docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform' <<<"$p"; then echo infra; return; fi
|
||||||
if printf '%s' "$p" | grep -qiE 'package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite'; then echo build; return; fi
|
if grep -qiE 'package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite' <<<"$p"; then echo build; return; fi
|
||||||
if printf '%s' "$p" | grep -qE '\.tsx|\.css|components/|apps/web/'; then echo ui; return; fi
|
if grep -qE '\.tsx|\.css|components/|apps/web/' <<<"$p"; then echo ui; return; fi
|
||||||
if printf '%s' "$p" | grep -qE '\.spec\.|\.test\.|__tests__/'; then echo test; return; fi
|
if grep -qE '\.spec\.|\.test\.|__tests__/' <<<"$p"; then echo test; return; fi
|
||||||
if printf '%s' "$p" | grep -qE '\.md$|docs/'; then echo docs; return; fi
|
if grep -qE '\.md$|docs/' <<<"$p"; then echo docs; return; fi
|
||||||
echo none
|
echo none
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ JSON_INPUT=$(cat)
|
|||||||
if command -v jq &>/dev/null; then
|
if command -v jq &>/dev/null; then
|
||||||
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // .tool_response.filePath // .file_path // empty' 2>/dev/null || echo "")
|
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // .tool_response.filePath // .file_path // empty' 2>/dev/null || echo "")
|
||||||
else
|
else
|
||||||
FILE_PATH=$(echo "$JSON_INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -1)
|
file_path_pattern='"file_path"[[:space:]]*:[[:space:]]*"([^"]*)"'
|
||||||
|
if [[ "$JSON_INPUT" =~ $file_path_pattern ]]; then
|
||||||
|
FILE_PATH="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
FILE_PATH=""
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Only check TypeScript files
|
# Only check TypeScript files
|
||||||
@@ -53,7 +58,7 @@ OUTPUT=$(npx tsc --noEmit --pretty --maxNodeModuleJsDepth 0 2>&1) || STATUS=$?
|
|||||||
if [ "${STATUS:-0}" -ne 0 ]; then
|
if [ "${STATUS:-0}" -ne 0 ]; then
|
||||||
# Filter output to only show errors related to the edited file (if possible)
|
# Filter output to only show errors related to the edited file (if possible)
|
||||||
BASENAME=$(basename "$FILE_PATH")
|
BASENAME=$(basename "$FILE_PATH")
|
||||||
RELEVANT=$(echo "$OUTPUT" | grep -A2 "$BASENAME" 2>/dev/null || echo "$OUTPUT" | head -20)
|
RELEVANT=$(grep -A2 "$BASENAME" <<<"$OUTPUT" 2>/dev/null || sed -n '1,20p' <<<"$OUTPUT")
|
||||||
|
|
||||||
echo "TypeScript type errors detected after editing $FILE_PATH:"
|
echo "TypeScript type errors detected after editing $FILE_PATH:"
|
||||||
echo "$RELEVANT"
|
echo "$RELEVANT"
|
||||||
|
|||||||
@@ -176,8 +176,12 @@ run_snap() {
|
|||||||
|
|
||||||
# Resolve the single pre-update-* snapshot dir under a state dir (newest if many).
|
# Resolve the single pre-update-* snapshot dir under a state dir (newest if many).
|
||||||
snap_dir() {
|
snap_dir() {
|
||||||
|
local -a snapshots=()
|
||||||
|
mapfile -t snapshots < <(
|
||||||
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
|
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
|
||||||
| LC_ALL=C sort -r | head -1
|
| LC_ALL=C sort -r
|
||||||
|
)
|
||||||
|
printf '%s\n' "${snapshots[0]:-}"
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
|
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
|
|||||||
# loop — which would make the control a false negative. A root dotfile is
|
# 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.
|
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||||
|
SIGNALED="$FW/.install-signal-control.tmp.sh"
|
||||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||||
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||||
|
|
||||||
pass=0; fail=0
|
pass=0; fail=0
|
||||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||||
@@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
|||||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
# ── 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
|
# A bash signal trap that merely returns lets the script continue past the
|
||||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
||||||
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
# success. The earlier test used a child cp shim to signal its parent, making
|
||||||
# fires and ONLY the signal path governs), and assert the shipped installer
|
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
||||||
# restores AND exits without reporting success. The control strips `exit 1` from
|
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
||||||
# the trap and shows the buggy resume-to-success.
|
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
||||||
make_term_shim() {
|
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
||||||
local dir="$1"
|
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
||||||
cat > "$dir/cp" <<SHIM
|
# than manufacturing a pass. The only semantic difference between fixtures is
|
||||||
#!/usr/bin/env bash
|
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
||||||
dest="\${@: -1}"
|
TERM_MARKER='[test-control] TERM handler entered'
|
||||||
case "\$dest" in
|
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||||
*/$POISON_REL)
|
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||||
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
|
||||||
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
make_signal_installer() {
|
||||||
esac
|
local output="$1" handler="$2"
|
||||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||||
SHIM
|
local target_cp=' cp "$abs" "$dst/$rel"'
|
||||||
chmod +x "$dir/cp"
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
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_signal_upgrade() {
|
run_signal_upgrade() {
|
||||||
local installer="$1" H OUT SHIM rc
|
local installer="$1" H OUT rc
|
||||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
H=$(mktemp -d); OUT=$(mktemp)
|
||||||
seed_home "$H"
|
seed_home "$H"
|
||||||
make_term_shim "$SHIM"
|
|
||||||
set +e
|
set +e
|
||||||
PATH="$SHIM:$ORIG_PATH" \
|
PATH="$ORIG_PATH" \
|
||||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||||
rc=$?
|
rc=$?
|
||||||
set -e 2>/dev/null || true
|
set -e 2>/dev/null || true
|
||||||
rm -rf "$SHIM"
|
|
||||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||||
}
|
}
|
||||||
|
|
||||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
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 ]"
|
||||||
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
||||||
"[ '$rcC' -ne 0 ]"
|
"[ '$rcC' -ne 0 ]"
|
||||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||||
@@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
|
|||||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||||
"! grep -q 'file phase complete' '$OUTC'"
|
"! grep -q 'file phase complete' '$OUTC'"
|
||||||
|
|
||||||
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||||
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
chk "[control] TERM handler observable fires exactly once" \
|
||||||
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||||
"$INSTALL" > "$NOEXIT"
|
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||||
chk "[control] the exit-strip actually changed the installer" \
|
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||||
"! cmp -s '$INSTALL' '$NOEXIT'"
|
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||||
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
"[ '$rcD' -eq 0 ]"
|
||||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||||
"grep -q 'file phase complete' '$OUTD'"
|
"grep -q 'file phase complete' '$OUTD'"
|
||||||
|
|
||||||
@@ -290,7 +336,7 @@ chk "[reset-fail] the manual-recovery pointer is emitted (not a silent set -e ex
|
|||||||
"grep -q 'Snapshot restore could not reset' '$OUTG'"
|
"grep -q 'Snapshot restore could not reset' '$OUTG'"
|
||||||
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
|
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
|
||||||
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
|
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
|
||||||
SNAP_E="$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG" | head -1)"
|
SNAP_E="$(grep -m1 -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG")"
|
||||||
chk "[reset-fail] the named snapshot directory actually survives for recovery" \
|
chk "[reset-fail] the named snapshot directory actually survives for recovery" \
|
||||||
"[ -n '$SNAP_E' ] && [ -d '$SNAP_E' ]"
|
"[ -n '$SNAP_E' ] && [ -d '$SNAP_E' ]"
|
||||||
chk "[reset-fail] operator secret value never appears in installer output" \
|
chk "[reset-fail] operator secret value never appears in installer output" \
|
||||||
@@ -307,12 +353,13 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
|
|||||||
"! grep -q 'Snapshot restore could not reset' '$OUTH'"
|
"! grep -q 'Snapshot restore could not reset' '$OUTH'"
|
||||||
[ -n "${SNAP_E:-}" ] && rm -rf "$SNAP_E"
|
[ -n "${SNAP_E:-}" ] && rm -rf "$SNAP_E"
|
||||||
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
# 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
|
orphan_snapshot="$(grep -m1 -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null || true)"
|
||||||
|
[ -n "$orphan_snapshot" ] && rm -rf "$orphan_snapshot"
|
||||||
|
|
||||||
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
|
# Cleanup (generated installer controls 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
|
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" \
|
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "RESULT: $pass passed, $fail failed"
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re
|
|||||||
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||||
|
|
||||||
# --- single-suite directories: unmeasured in CI ---
|
# --- single-suite directories: unmeasured in CI ---
|
||||||
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
|
|
||||||
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
|
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
|
||||||
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
|
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
|
||||||
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
|
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
|
|||||||
sleep 1.2
|
sleep 1.2
|
||||||
pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
|
pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
|
||||||
|
|
||||||
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then
|
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||||
status="queued"; break
|
status="queued"; break
|
||||||
fi
|
fi
|
||||||
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
|
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
|
||||||
@@ -121,7 +121,7 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
|
|||||||
fi
|
fi
|
||||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
||||||
# (Submitted messages scroll up into history; a draft stays on the ❯ line.)
|
# (Submitted messages scroll up into history; a draft stays on the ❯ line.)
|
||||||
if [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then
|
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
|
||||||
status="draft"; continue
|
status="draft"; continue
|
||||||
fi
|
fi
|
||||||
# Input box located AND clear of our tail => positively submitted. This is the
|
# Input box located AND clear of our tail => positively submitted. This is the
|
||||||
|
|||||||
@@ -34,16 +34,20 @@ tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --no
|
|||||||
|
|
||||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
|
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
capture_named | grep -qF "named socket hello" || fail "send-message.sh did not deliver to named socket"
|
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||||
if capture_default | grep -qF "named socket hello"; then
|
grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket"
|
||||||
|
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||||
|
if grep -qF "named socket hello" <<<"$default_pane"; then
|
||||||
fail "send-message.sh leaked named-socket message to default tmux server"
|
fail "send-message.sh leaked named-socket message to default tmux server"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out
|
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
capture_named | grep -qF "[tester:source ->" || fail "agent-send.sh did not include preamble"
|
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||||
capture_named | grep -qF "agent socket hello" || fail "agent-send.sh did not deliver to named socket"
|
grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble"
|
||||||
if capture_default | grep -qF "agent socket hello"; then
|
grep -qF "agent socket hello" <<<"$named_pane" || fail "agent-send.sh did not deliver to named socket"
|
||||||
|
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||||
|
if grep -qF "agent socket hello" <<<"$default_pane"; then
|
||||||
fail "agent-send.sh leaked named-socket message to default tmux server"
|
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -65,11 +69,11 @@ done
|
|||||||
sleep 0.2
|
sleep 0.2
|
||||||
for i in $(seq 1 "$CONC_N"); do
|
for i in $(seq 1 "$CONC_N"); do
|
||||||
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
|
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
|
||||||
printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${i}-END" \
|
grep -qF "CONCPAYLOAD-${i}-END" <<<"$pane" \
|
||||||
|| fail "concurrent send dropped payload for pane conc-$i"
|
|| fail "concurrent send dropped payload for pane conc-$i"
|
||||||
for j in $(seq 1 "$CONC_N"); do
|
for j in $(seq 1 "$CONC_N"); do
|
||||||
[ "$j" = "$i" ] && continue
|
[ "$j" = "$i" ] && continue
|
||||||
if printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${j}-END"; then
|
if grep -qF "CONCPAYLOAD-${j}-END" <<<"$pane"; then
|
||||||
fail "concurrent send cross-delivered payload $j to pane conc-$i"
|
fail "concurrent send cross-delivered payload $j to pane conc-$i"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ tmux -L "$SOCKET" new-session -d -s repl -c "$TMP" \
|
|||||||
'PS1="❯ " exec bash --noprofile --norc -i'
|
'PS1="❯ " exec bash --noprofile --norc -i'
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$?
|
out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$?
|
||||||
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qF "✓ delivered"; then
|
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||||
ok "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered"
|
ok "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered"
|
||||||
else
|
else
|
||||||
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ _manifest_val() {
|
|||||||
# _manifest_val KEY — echo VALUE for KEY=VALUE in the manifest (blank if none).
|
# _manifest_val KEY — echo VALUE for KEY=VALUE in the manifest (blank if none).
|
||||||
local key="$1"
|
local key="$1"
|
||||||
[ -f "$MANIFEST" ] || return 0
|
[ -f "$MANIFEST" ] || return 0
|
||||||
sed -n "s/^${key}=//p" "$MANIFEST" | head -n1 | tr -d '[:space:]'
|
awk -v key="$key" 'index($0, key "=") == 1 { sub(/^[^=]*=/, ""); gsub(/[[:space:]]/, ""); print; exit }' "$MANIFEST"
|
||||||
}
|
}
|
||||||
|
|
||||||
# _load_watchlist — validate the watch-list path + JSON + schema_version range.
|
# _load_watchlist — validate the watch-list path + JSON + schema_version range.
|
||||||
@@ -267,7 +267,7 @@ _poll_source() {
|
|||||||
if snap_json="$(jq -ce '.' <<<"$rawmeta" 2>/dev/null)"; then
|
if snap_json="$(jq -ce '.' <<<"$rawmeta" 2>/dev/null)"; then
|
||||||
snap_sha="$(jq -r 'if (.snapshot_sha|type) == "string" then .snapshot_sha else "" end' <<<"$snap_json")"
|
snap_sha="$(jq -r 'if (.snapshot_sha|type) == "string" then .snapshot_sha else "" end' <<<"$snap_json")"
|
||||||
snap_ts="$(jq -r 'if (.snapshot_ts|type) == "number" then (.snapshot_ts|floor|tostring) else "" end' <<<"$snap_json")"
|
snap_ts="$(jq -r 'if (.snapshot_ts|type) == "number" then (.snapshot_ts|floor|tostring) else "" end' <<<"$snap_json")"
|
||||||
if [ -n "$snap_sha" ] && ! printf '%s' "$snap_sha" | grep -Eq '^[0-9a-f]{7,64}$'; then
|
if [ -n "$snap_sha" ] && ! grep -Eq '^[0-9a-f]{7,64}$' <<<"$snap_sha"; then
|
||||||
echo "detector.sh: source '$kind/$id' snapshot_sha rejected (not a 7-64 char lowercase-hex git sha) — snapshot metadata DROPPED, poll continues (#940)." >&2
|
echo "detector.sh: source '$kind/$id' snapshot_sha rejected (not a 7-64 char lowercase-hex git sha) — snapshot metadata DROPPED, poll continues (#940)." >&2
|
||||||
snap_sha=""
|
snap_sha=""
|
||||||
snap_ts=""
|
snap_ts=""
|
||||||
@@ -275,7 +275,7 @@ _poll_source() {
|
|||||||
# A ts must be a sane positive epoch BEFORE any arithmetic touches it: a
|
# A ts must be a sane positive epoch BEFORE any arithmetic touches it: a
|
||||||
# negative or absurdly large value would make the shell integer comparison
|
# negative or absurdly large value would make the shell integer comparison
|
||||||
# below error out and silently KEEP the bad ts — validate first, compare after.
|
# below error out and silently KEEP the bad ts — validate first, compare after.
|
||||||
if [ -n "$snap_ts" ] && ! printf '%s' "$snap_ts" | grep -Eq '^[0-9]{1,12}$'; then
|
if [ -n "$snap_ts" ] && ! grep -Eq '^[0-9]{1,12}$' <<<"$snap_ts"; then
|
||||||
echo "detector.sh: source '$kind/$id' snapshot_ts rejected (not a sane positive epoch) — snapshot_ts DROPPED, poll continues (#940)." >&2
|
echo "detector.sh: source '$kind/$id' snapshot_ts rejected (not a sane positive epoch) — snapshot_ts DROPPED, poll continues (#940)." >&2
|
||||||
snap_ts=""
|
snap_ts=""
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -644,7 +644,8 @@ cmd_render() {
|
|||||||
oseq="$(jq -r '.observed_seq // "?"' <<<"$line")"
|
oseq="$(jq -r '.observed_seq // "?"' <<<"$line")"
|
||||||
oclass="$(jq -r '.class // "actionable"' <<<"$line")"
|
oclass="$(jq -r '.class // "actionable"' <<<"$line")"
|
||||||
oloc="$(jq -c '.locators // {}' <<<"$line")"
|
oloc="$(jq -c '.locators // {}' <<<"$line")"
|
||||||
olabel="$(_locator_line "$oloc" | head -n1)"
|
olabel="$(_locator_line "$oloc")"
|
||||||
|
olabel="${olabel%%$'\n'*}"
|
||||||
printf ' * seq %s [%s] %s\n' "$oseq" "$(_scrub_inline "$oclass")" "$olabel"
|
printf ' * seq %s [%s] %s\n' "$oseq" "$(_scrub_inline "$oclass")" "$olabel"
|
||||||
done <<<"$pending"
|
done <<<"$pending"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ EOF
|
|||||||
_manifest_val() {
|
_manifest_val() {
|
||||||
local key="$1"
|
local key="$1"
|
||||||
[ -f "$MANIFEST" ] || return 0
|
[ -f "$MANIFEST" ] || return 0
|
||||||
sed -n "s/^${key}=//p" "$MANIFEST" | head -n1 | tr -d '[:space:]'
|
awk -v key="$key" 'index($0, key "=") == 1 { sub(/^[^=]*=/, ""); gsub(/[[:space:]]/, ""); print; exit }' "$MANIFEST"
|
||||||
}
|
}
|
||||||
|
|
||||||
# _load_watchlist — validate path + JSON + shape + Gate B schema range (mirrors
|
# _load_watchlist — validate path + JSON + shape + Gate B schema range (mirrors
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ A Woodpecker API token is required. To configure:
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Script | Purpose |
|
| Script | Purpose |
|
||||||
| --------------------- | -------------------------------------------- |
|
| -------------------------- | -------------------------------------------------------------- |
|
||||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
| `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 |
|
||||||
|
|
||||||
## Common Options
|
## Common Options
|
||||||
|
|
||||||
@@ -59,4 +60,9 @@ A Woodpecker API token is required. To configure:
|
|||||||
|
|
||||||
# Block until one or more pipelines finish (event-driven CI wait)
|
# 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
|
~/.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" -
|
||||||
```
|
```
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/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'
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
#!/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",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.48",
|
"version": "0.0.49",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"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-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"
|
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.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-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -131,13 +131,14 @@ async function exists(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
||||||
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => {
|
it('maps a roster-v2 agent to exactly the nine generated projection keys', (): void => {
|
||||||
const roster = parseRosterV2(rosterYaml, 'yaml');
|
const roster = parseRosterV2(rosterYaml, 'yaml');
|
||||||
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
||||||
expect(agent).toBeDefined();
|
expect(agent).toBeDefined();
|
||||||
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
||||||
expect(values).toEqual({
|
expect(values).toEqual({
|
||||||
MOSAIC_AGENT_NAME: 'coder0',
|
MOSAIC_AGENT_NAME: 'coder0',
|
||||||
|
MOSAIC_GIT_IDENTITY: 'coder0',
|
||||||
MOSAIC_AGENT_CLASS: 'code',
|
MOSAIC_AGENT_CLASS: 'code',
|
||||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ describe('fleet roster parsing', () => {
|
|||||||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||||||
[
|
[
|
||||||
'MOSAIC_AGENT_NAME=coder0',
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
|
'MOSAIC_GIT_IDENTITY=coder0',
|
||||||
// Reflects the roster's canonicalized compatibility class (A3a).
|
// Reflects the roster's canonicalized compatibility class (A3a).
|
||||||
'MOSAIC_AGENT_CLASS=code',
|
'MOSAIC_AGENT_CLASS=code',
|
||||||
'MOSAIC_AGENT_RUNTIME=codex',
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
@@ -3799,6 +3800,7 @@ describe('fleet add command', () => {
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||||
|
expect(envContent).toContain('MOSAIC_GIT_IDENTITY=coder0');
|
||||||
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ function generateAgentEnvValues(
|
|||||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_GIT_IDENTITY: agent.name,
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ function generatedValues(
|
|||||||
): Readonly<Record<string, string>> {
|
): Readonly<Record<string, string>> {
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_GIT_IDENTITY: agent.name,
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.model,
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ const COMMAND_RECORDS: Readonly<Record<string, RegExp>> = {
|
|||||||
|
|
||||||
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
||||||
'DATA.DOTENV.FLEET_LAUNCH':
|
'DATA.DOTENV.FLEET_LAUNCH':
|
||||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||||
'DATA.TEXT_TABLE.FLEET_TASKS':
|
'DATA.TEXT_TABLE.FLEET_TASKS':
|
||||||
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
|
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
|
||||||
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
|
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
|
||||||
@@ -406,7 +406,7 @@ const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
|||||||
'DATA.JSON.MUTATION_RESULT':
|
'DATA.JSON.MUTATION_RESULT':
|
||||||
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
|
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
|
||||||
'DATA.DOTENV.GENERATED_ENV':
|
'DATA.DOTENV.GENERATED_ENV':
|
||||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||||
'DATA.YAML.ROSTER_FIELDS':
|
'DATA.YAML.ROSTER_FIELDS':
|
||||||
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
|
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
|
||||||
};
|
};
|
||||||
@@ -922,8 +922,8 @@ describe('fleet operator documentation', (): void => {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
|
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
|
||||||
).toHaveLength(858);
|
).toHaveLength(863);
|
||||||
expect(surfaces).toHaveLength(882);
|
expect(surfaces).toHaveLength(887);
|
||||||
|
|
||||||
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
||||||
const auxiliary: CodeSurface = {
|
const auxiliary: CodeSurface = {
|
||||||
|
|||||||
@@ -597,6 +597,7 @@ export function projectRosterV2AgentGeneratedEnv(
|
|||||||
): Readonly<Record<string, string>> {
|
): Readonly<Record<string, string>> {
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_GIT_IDENTITY: agent.name,
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.model,
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
|
|
||||||
const generatedValues = {
|
const generatedValues = {
|
||||||
MOSAIC_AGENT_NAME: 'coder0',
|
MOSAIC_AGENT_NAME: 'coder0',
|
||||||
|
MOSAIC_GIT_IDENTITY: 'coder0',
|
||||||
MOSAIC_AGENT_CLASS: 'code',
|
MOSAIC_AGENT_CLASS: 'code',
|
||||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
||||||
@@ -45,6 +46,7 @@ describe('generated fleet agent environment boundary', (): void => {
|
|||||||
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
||||||
[
|
[
|
||||||
'MOSAIC_AGENT_NAME=coder0',
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
|
'MOSAIC_GIT_IDENTITY=coder0',
|
||||||
'MOSAIC_AGENT_CLASS=code',
|
'MOSAIC_AGENT_CLASS=code',
|
||||||
'MOSAIC_AGENT_RUNTIME=pi',
|
'MOSAIC_AGENT_RUNTIME=pi',
|
||||||
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
||||||
@@ -78,6 +80,22 @@ describe('generated fleet agent environment boundary', (): void => {
|
|||||||
expect(String(error)).toMatch(/key=.*sha256=/);
|
expect(String(error)).toMatch(/key=.*sha256=/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['unsafe-git-identity', 'other/identity'],
|
||||||
|
['git-identity-mismatch', 'reviewer0'],
|
||||||
|
])('rejects %s before any launch consumer can use it', (code: string, identity: string): void => {
|
||||||
|
expect((): void => {
|
||||||
|
renderGeneratedAgentEnvironment({
|
||||||
|
...generatedValues,
|
||||||
|
MOSAIC_GIT_IDENTITY: identity,
|
||||||
|
});
|
||||||
|
}).toThrow(
|
||||||
|
expect.objectContaining({
|
||||||
|
diagnostic: expect.objectContaining({ code, key: 'MOSAIC_GIT_IDENTITY' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
||||||
expect((): void => {
|
expect((): void => {
|
||||||
renderGeneratedAgentEnvironment({
|
renderGeneratedAgentEnvironment({
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export class AgentEnvBoundaryError extends Error {
|
|||||||
|
|
||||||
export const GENERATED_AGENT_ENV_KEYS = [
|
export const GENERATED_AGENT_ENV_KEYS = [
|
||||||
'MOSAIC_AGENT_NAME',
|
'MOSAIC_AGENT_NAME',
|
||||||
|
'MOSAIC_GIT_IDENTITY',
|
||||||
'MOSAIC_AGENT_CLASS',
|
'MOSAIC_AGENT_CLASS',
|
||||||
'MOSAIC_AGENT_RUNTIME',
|
'MOSAIC_AGENT_RUNTIME',
|
||||||
'MOSAIC_AGENT_MODEL',
|
'MOSAIC_AGENT_MODEL',
|
||||||
@@ -402,6 +403,7 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
|||||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||||
}
|
}
|
||||||
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
||||||
|
const gitIdentity = requiredGeneratedValue(values, 'MOSAIC_GIT_IDENTITY');
|
||||||
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
||||||
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
||||||
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
||||||
@@ -412,6 +414,12 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
|||||||
|
|
||||||
if (!AGENT_NAME.test(name))
|
if (!AGENT_NAME.test(name))
|
||||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
||||||
|
if (!AGENT_NAME.test(gitIdentity)) {
|
||||||
|
throw new AgentEnvBoundaryError('unsafe-git-identity', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
||||||
|
}
|
||||||
|
if (gitIdentity !== name) {
|
||||||
|
throw new AgentEnvBoundaryError('git-identity-mismatch', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
||||||
|
}
|
||||||
if (!POLICY_NAME.test(className)) {
|
if (!POLICY_NAME.test(className)) {
|
||||||
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1405,6 +1405,7 @@ function generatedValues(
|
|||||||
): Readonly<Record<string, string>> {
|
): Readonly<Record<string, string>> {
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_GIT_IDENTITY: agent.name,
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.model,
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
|||||||
@@ -72,8 +72,9 @@ elif [[ -n "$DATA_DIR" ]]; then
|
|||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
[[ -z "$file" ]] && continue
|
[[ -z "$file" ]] && continue
|
||||||
done_total=$((done_total + 1))
|
done_total=$((done_total + 1))
|
||||||
if git -C "$DATA_DIR" log --since="${WINDOW_DAYS} days ago" --pretty='%s' -- "$file" 2>/dev/null \
|
history_rc=0
|
||||||
| grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo'; then
|
history="$(git -C "$DATA_DIR" log --since="${WINDOW_DAYS} days ago" --pretty='%s' -- "$file" 2>/dev/null)" || history_rc=$?
|
||||||
|
if [[ "$history_rc" -eq 0 ]] && grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo' <<<"$history"; then
|
||||||
detectable=$((detectable + 1))
|
detectable=$((detectable + 1))
|
||||||
fi
|
fi
|
||||||
done < <(find "$DATA_DIR" -type f -name '*.json' 2>/dev/null)
|
done < <(find "$DATA_DIR" -type f -name '*.json' 2>/dev/null)
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ for line in "${LINES[@]}"; do
|
|||||||
# - build/test/lint/type/ci signals → CI would have caught it
|
# - build/test/lint/type/ci signals → CI would have caught it
|
||||||
# - security/auth/permission/data/migration → human review would flag it
|
# - security/auth/permission/data/migration → human review would flag it
|
||||||
# - everything else (logic/UX/assumption/edge) → only-self-reflection bucket
|
# - everything else (logic/UX/assumption/edge) → only-self-reflection bucket
|
||||||
if printf '%s' "$subj" | grep -qiE 'test|lint|type|build|ci|compile|typo'; then
|
if grep -qiE 'test|lint|type|build|ci|compile|typo' <<<"$subj"; then
|
||||||
ci=$((ci + 1))
|
ci=$((ci + 1))
|
||||||
elif printf '%s' "$subj" | grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection'; then
|
elif grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection' <<<"$subj"; then
|
||||||
human=$((human + 1))
|
human=$((human + 1))
|
||||||
else
|
else
|
||||||
selfonly=$((selfonly + 1))
|
selfonly=$((selfonly + 1))
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[
|
||||||
|
"tools/matrix-presence-harness/run.sh:TSX_CLI=\"$(ls -d \"${REPO}\"/node_modules/.pnpm/tsx@*/node_modules/tsx/dist/cli.mjs 2>/dev/null | head -1)\"",
|
||||||
|
"tools/e2e-install-test.sh:if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then",
|
||||||
|
"tools/install.sh:EXTRACTED_DIR=\"$(find \"$WORK_DIR\" -maxdepth 1 -mindepth 1 -type d | head -1)\"",
|
||||||
|
"scripts/analysis/reflect-board-history.sh:if git -C \"$DATA_DIR\" log --since=\"${WINDOW_DAYS} days ago\" --pretty='%s' -- \"$file\" 2>/dev/null | grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo'; then",
|
||||||
|
"scripts/analysis/reflect-git-history.sh:if printf '%s' \"$subj\" | grep -qiE 'test|lint|type|build|ci|compile|typo'; then",
|
||||||
|
"scripts/analysis/reflect-git-history.sh:elif printf '%s' \"$subj\" | grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection'; then",
|
||||||
|
"packages/mosaic/framework/tools/authentik/user-create.sh:group_pk=$(echo \"$group_response\" | jq -r \".results[] | select(.name == \\\"$GROUP\\\") | .pk\" | head -1)",
|
||||||
|
"packages/mosaic/framework/tools/git/mutate-push-guard.sh:PROSE_LO=\"$(grep -n '^usage() {' \"$BAK\" | head -1 | cut -d: -f1)\"",
|
||||||
|
"packages/mosaic/framework/tools/orchestrator/session-resume.sh:echo \"$dirty_files\" | head -20 | while IFS= read -r line; do",
|
||||||
|
"packages/mosaic/framework/tools/prdy/prdy-status.sh:if echo \"$PRD_CONTENT\" | grep -qiE \"$pattern\"; then",
|
||||||
|
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'auth|login|session|token|permission|rbac|credential|secret'; then echo auth; return; fi",
|
||||||
|
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'migration|prisma|schema|\\.sql|entity|repository|seed'; then echo data; return; fi",
|
||||||
|
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'docker|\\.woodpecker|compose|traefik|deploy|helm|k8s|terraform'; then echo infra; return; fi",
|
||||||
|
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'package\\.json|tsconfig|turbo\\.json|pnpm-|\\.config\\.|eslint|vite'; then echo build; return; fi",
|
||||||
|
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.tsx|\\.css|components/|apps/web/'; then echo ui; return; fi",
|
||||||
|
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.spec\\.|\\.test\\.|__tests__/'; then echo test; return; fi",
|
||||||
|
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.md$|docs/'; then echo docs; return; fi",
|
||||||
|
"packages/mosaic/framework/tools/qa/typecheck-hook.sh:FILE_PATH=$(echo \"$JSON_INPUT\" | grep -o '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | sed 's/.*\"\\([^\"]*\\)\"$/\\1/' | head -1)",
|
||||||
|
"packages/mosaic/framework/tools/qa/typecheck-hook.sh:RELEVANT=$(echo \"$OUTPUT\" | grep -A2 \"$BASENAME\" 2>/dev/null || echo \"$OUTPUT\" | head -20)",
|
||||||
|
"packages/mosaic/framework/tools/tmux/send-message.sh:if printf '%s' \"$pane\" | grep -qF \"$QUEUED_RE\"; then",
|
||||||
|
"packages/mosaic/framework/tools/tmux/send-message.sh:if [ -n \"$snippet\" ] && printf '%s' \"$promptline\" | grep -qF \"$snippet\"; then",
|
||||||
|
"packages/mosaic/framework/tools/wake/detector.sh:sed -n \"s/^${key}=//p\" \"$MANIFEST\" | head -n1 | tr -d '[:space:]'",
|
||||||
|
"packages/mosaic/framework/tools/wake/detector.sh:if [ -n \"$snap_sha\" ] && ! printf '%s' \"$snap_sha\" | grep -Eq '^[0-9a-f]{7,64}$'; then",
|
||||||
|
"packages/mosaic/framework/tools/wake/detector.sh:if [ -n \"$snap_ts\" ] && ! printf '%s' \"$snap_ts\" | grep -Eq '^[0-9]{1,12}$'; then",
|
||||||
|
"packages/mosaic/framework/tools/wake/digest.sh:olabel=\"$(_locator_line \"$oloc\" | head -n1)\"",
|
||||||
|
"packages/mosaic/framework/tools/wake/reconcile.sh:sed -n \"s/^${key}=//p\" \"$MANIFEST\" | head -n1 | tr -d '[:space:]'"
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[
|
||||||
|
"packages/mosaic/framework/systemd/user/test-fleet-units.sh:if tmux -L \"$TEST_SOCKET\" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then",
|
||||||
|
"packages/mosaic/framework/tools/git/test-issue-comment-readback.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
|
||||||
|
"packages/mosaic/framework/tools/git/test-issue-comment-readback.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
|
||||||
|
"packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh:contains() { printf '%s\\n' \"$1\" | grep -qx \"$2\"; }",
|
||||||
|
"packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
|
||||||
|
"packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh:echo \"$HELP_TEXT\" | grep -q -- '-r, --repo'",
|
||||||
|
"packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh:echo \"$HELP_TEXT\" | grep -q -- '-H, --host'",
|
||||||
|
"packages/mosaic/framework/tools/orchestrator/smoke-test.sh:if [[ \"$(printf '%s\\n' \"$codex_run_prompt\" | head -n1)\" == \"Now initiating Orchestrator mode...\" ]]; then pass_case \"codex run prompt first line is mode declaration\"; else fail_case \"codex run prompt first line is mode declaration\"; fi",
|
||||||
|
"packages/mosaic/framework/tools/orchestrator/smoke-test.sh:if [[ \"$(printf '%s\\n' \"$claude_run_prompt\" | head -n1)\" == \"## Continuation Mission\" ]]; then pass_case \"claude run prompt remains continuation prompt format\"; else fail_case \"claude run prompt remains continuation prompt format\"; fi",
|
||||||
|
"packages/mosaic/framework/tools/orchestrator/test-board-roll.sh:echo \"$out\" | grep -qi \"dry run\" || note \"dry-run did not announce itself\"",
|
||||||
|
"packages/mosaic/framework/tools/orchestrator/test-board-roll.sh:echo \"$out\" | grep -q \"would roll\" || note \"dry-run did not report a plan\"",
|
||||||
|
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh:find \"$1/mosaic/backups\" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null | LC_ALL=C sort -r | head -1",
|
||||||
|
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh:SNAP_E=\"$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' \"$OUTG\" | head -1)\"",
|
||||||
|
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh:grep -o '/[^ ]*mosaic-snapshot[^ ]*' \"$OUTH\" 2>/dev/null | head -1 | while read -r s; do rm -rf \"$s\"; done",
|
||||||
|
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"named socket hello\" || fail \"send-message.sh did not deliver to named socket\"",
|
||||||
|
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if capture_default | grep -qF \"named socket hello\"; then",
|
||||||
|
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"[tester:source ->\" || fail \"agent-send.sh did not include preamble\"",
|
||||||
|
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"agent socket hello\" || fail \"agent-send.sh did not deliver to named socket\"",
|
||||||
|
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if capture_default | grep -qF \"agent socket hello\"; then",
|
||||||
|
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:printf '%s' \"$pane\" | grep -qF \"CONCPAYLOAD-${i}-END\" || fail \"concurrent send dropped payload for pane conc-$i\"",
|
||||||
|
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if printf '%s' \"$pane\" | grep -qF \"CONCPAYLOAD-${j}-END\"; then",
|
||||||
|
"packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh:if [ \"$rc\" -eq 0 ] && printf '%s' \"$out\" | grep -qF \"✓ delivered\"; then"
|
||||||
|
]
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const ROOT = new URL('../', import.meta.url);
|
||||||
|
const EXPECTED_BASELINE_SITES = 26;
|
||||||
|
const EXPECTED_TEST_BASELINE_SITES = 22;
|
||||||
|
const TARGETS = [
|
||||||
|
'tools/matrix-presence-harness/run.sh',
|
||||||
|
'tools/e2e-install-test.sh',
|
||||||
|
'tools/install.sh',
|
||||||
|
'scripts/agent/session-start.sh',
|
||||||
|
'scripts/analysis/reflect-board-history.sh',
|
||||||
|
'scripts/analysis/reflect-git-history.sh',
|
||||||
|
'packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh',
|
||||||
|
'packages/mosaic/framework/tools/authentik/user-create.sh',
|
||||||
|
'packages/mosaic/framework/tools/git/mutate-push-guard.sh',
|
||||||
|
'packages/mosaic/framework/tools/orchestrator/session-resume.sh',
|
||||||
|
'packages/mosaic/framework/tools/prdy/prdy-status.sh',
|
||||||
|
'packages/mosaic/framework/tools/qa/reflect-stop-hook.sh',
|
||||||
|
'packages/mosaic/framework/tools/qa/typecheck-hook.sh',
|
||||||
|
'packages/mosaic/framework/tools/tmux/send-message.sh',
|
||||||
|
'packages/mosaic/framework/tools/wake/detector.sh',
|
||||||
|
'packages/mosaic/framework/tools/wake/digest.sh',
|
||||||
|
'packages/mosaic/framework/tools/wake/reconcile.sh',
|
||||||
|
'packages/mosaic/framework/systemd/user/test-fleet-units.sh',
|
||||||
|
'packages/mosaic/framework/tools/git/test-issue-comment-readback.sh',
|
||||||
|
'packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh',
|
||||||
|
'packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh',
|
||||||
|
'packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh',
|
||||||
|
'packages/mosaic/framework/tools/orchestrator/smoke-test.sh',
|
||||||
|
'packages/mosaic/framework/tools/orchestrator/test-board-roll.sh',
|
||||||
|
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh',
|
||||||
|
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh',
|
||||||
|
'packages/mosaic/framework/tools/tmux/test-send-message-socket.sh',
|
||||||
|
'packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh',
|
||||||
|
];
|
||||||
|
|
||||||
|
// These statuses are explicitly non-load-bearing or unreachable at designed input.
|
||||||
|
// They remain inventoried until the final #1099 tranche records every verdict.
|
||||||
|
const ACCEPTED = [
|
||||||
|
['tools/install.sh', 'mosaic-bak-', '|| true'],
|
||||||
|
['tools/install.sh', 'mosaicstack-mosaic-*.tgz', 'head -1'],
|
||||||
|
['tools/install.sh', 'mosaicstack-gateway-*.tgz', 'head -1'],
|
||||||
|
['scripts/agent/session-start.sh', 'docs/scratchpads/*.md', '|| true'],
|
||||||
|
[
|
||||||
|
'packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh',
|
||||||
|
'docs/scratchpads/*.md',
|
||||||
|
'|| true',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const earlyExit =
|
||||||
|
/(?<!\|)\|(?!\|)[^;\n]*(?:grep\b[^;\n]*(?:-[A-Za-z]*q|--quiet|-m\s*1)|head\b(?:\s|$))/;
|
||||||
|
|
||||||
|
function scan(sources) {
|
||||||
|
const found = [];
|
||||||
|
for (const [file, rawSource] of sources) {
|
||||||
|
const source = rawSource.replace(/\\\n\s*/g, ' ');
|
||||||
|
for (const rawLine of source.split('\n')) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!earlyExit.test(line)) continue;
|
||||||
|
const accepted = ACCEPTED.some(
|
||||||
|
([acceptedFile, ...fragments]) =>
|
||||||
|
acceptedFile === file && fragments.every((item) => line.includes(item)),
|
||||||
|
);
|
||||||
|
if (!accepted) found.push(`${file}:${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentSources() {
|
||||||
|
return Promise.all(
|
||||||
|
TARGETS.map(async (file) => [file, await readFile(new URL(file, ROOT), 'utf8')]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertBaselineFixture(file, expectedCount, expectedUnique = expectedCount) {
|
||||||
|
const baseline = JSON.parse(await readFile(new URL(file, ROOT), 'utf8'));
|
||||||
|
assert.equal(baseline.length, expectedCount);
|
||||||
|
assert.equal(new Set(baseline).size, expectedUnique);
|
||||||
|
const fixtureSources = baseline.map((site) => {
|
||||||
|
const separator = site.indexOf(':');
|
||||||
|
assert.ok(separator > 0, `invalid baseline site: ${site}`);
|
||||||
|
return [site.slice(0, separator), site.slice(separator + 1)];
|
||||||
|
});
|
||||||
|
assert.deepEqual(scan(fixtureSources), baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the registered runtime baseline denominator is exactly 26 unsafe sites', async () => {
|
||||||
|
await assertBaselineFixture(
|
||||||
|
'scripts/fixtures/pipefail-early-exit-baseline.json',
|
||||||
|
EXPECTED_BASELINE_SITES,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the registered test baseline denominator is exactly 22 unsafe sites', async () => {
|
||||||
|
await assertBaselineFixture(
|
||||||
|
'scripts/fixtures/pipefail-early-exit-test-baseline.json',
|
||||||
|
EXPECTED_TEST_BASELINE_SITES,
|
||||||
|
21,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('load-bearing pipefail paths do not pipe into early-exiting consumers', async () => {
|
||||||
|
assert.deepEqual(scan(await currentSources()), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gateway verify capability preserves the complete help-probe truth table', async () => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), 'gateway-help-probe-'));
|
||||||
|
const mosaic = path.join(directory, 'mosaic');
|
||||||
|
const probe = new URL('tools/e2e-gateway-verify-supported.sh', ROOT).pathname;
|
||||||
|
try {
|
||||||
|
await writeFile(
|
||||||
|
mosaic,
|
||||||
|
'#!/usr/bin/env bash\nprintf \'%s\\n\' "${MOCK_HELP_OUTPUT:-}"\nexit "${MOCK_HELP_RC:-0}"\n',
|
||||||
|
);
|
||||||
|
await chmod(mosaic, 0o755);
|
||||||
|
const run = (rc, output) =>
|
||||||
|
spawnSync('bash', [probe], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${directory}:${process.env.PATH}`,
|
||||||
|
MOCK_HELP_RC: String(rc),
|
||||||
|
MOCK_HELP_OUTPUT: output,
|
||||||
|
},
|
||||||
|
}).status;
|
||||||
|
|
||||||
|
assert.equal(run(0, 'commands: verify'), 0);
|
||||||
|
assert.equal(run(0, 'commands: install'), 1);
|
||||||
|
assert.equal(run(1, 'commands: verify'), 1);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('board-history preserves non-git data-dir as a non-detectable result', async () => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), 'reflect-board-non-git-'));
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(directory, 'task.json'), '{}\n');
|
||||||
|
const result = spawnSync(
|
||||||
|
'bash',
|
||||||
|
[
|
||||||
|
new URL('scripts/analysis/reflect-board-history.sh', ROOT).pathname,
|
||||||
|
'--data-dir',
|
||||||
|
directory,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /"done_tasks": 1/);
|
||||||
|
assert.match(result.stdout, /"detectable_outcomes": 0/);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Exit 0 only when the capability probe itself succeeds and advertises verify.
|
||||||
|
# A failed help command and a successful response without verify are both
|
||||||
|
# unsupported, matching the historical e2e-install-test.sh conditional.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
gateway_help_rc=0
|
||||||
|
gateway_help="$(mosaic gateway --help 2>&1)" || gateway_help_rc=$?
|
||||||
|
[[ "$gateway_help_rc" -eq 0 ]] || exit 1
|
||||||
|
grep -q 'verify' <<<"$gateway_help"
|
||||||
@@ -136,7 +136,7 @@ fi
|
|||||||
echo "=== [inner] Running mosaic gateway verify ==="
|
echo "=== [inner] Running mosaic gateway verify ==="
|
||||||
# `gateway verify` was added in feat/mosaic-first-run-ux.
|
# `gateway verify` was added in feat/mosaic-first-run-ux.
|
||||||
# If the installed version pre-dates this, skip gracefully.
|
# If the installed version pre-dates this, skip gracefully.
|
||||||
if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then
|
if ! bash /repo/tools/e2e-gateway-verify-supported.sh; then
|
||||||
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
|
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
|
||||||
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
|
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
|
||||||
echo "[SKIP] Re-run after the new version is published to validate this step."
|
echo "[SKIP] Re-run after the new version is published to validate this step."
|
||||||
|
|||||||
+8
-4
@@ -308,13 +308,17 @@ ensure_monorepo() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Gitea archives extract to <repo-name>/ inside the work dir
|
# Gitea archives extract to exactly one <repo-name>/ inside the work dir.
|
||||||
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
# Read the complete population so a malformed multi-root archive reaches the
|
||||||
if [[ -z "$EXTRACTED_DIR" ]] || [[ ! -d "$EXTRACTED_DIR" ]]; then
|
# named diagnostic instead of aborting on an upstream SIGPIPE under pipefail.
|
||||||
fail "Could not locate extracted source in archive."
|
local -a extracted_dirs=()
|
||||||
|
mapfile -d '' -t extracted_dirs < <(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d -print0)
|
||||||
|
if [[ "${#extracted_dirs[@]}" -ne 1 ]] || [[ ! -d "${extracted_dirs[0]:-}" ]]; then
|
||||||
|
fail "Could not locate exactly one extracted source directory in archive."
|
||||||
ls -la "$WORK_DIR" >&2
|
ls -la "$WORK_DIR" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
EXTRACTED_DIR="${extracted_dirs[0]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
|
# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user