Compare commits
1
Commits
main
..
fa36da8087
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa36da8087 |
@@ -8,7 +8,6 @@ coverage
|
|||||||
.env.local
|
.env.local
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
__pycache__/
|
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||||
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||||
# Non-root checkouts use their own HOME. Override without editing this file via
|
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||||
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
# instead of repopulating a fresh one.
|
||||||
store-dir=${HOME}/.local/share/pnpm/store
|
store-dir=/root/.local/share/pnpm/store
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ pnpm-lock.yaml
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
# Python build/test artifacts — same category as node_modules/dist/.next above.
|
|
||||||
# Prettier must never scan generated trees; without these a local venv poisons
|
|
||||||
# `pnpm format:check` with thousands of third-party files.
|
|
||||||
**/venv
|
|
||||||
**/__pycache__
|
|
||||||
**/.mypy_cache
|
|
||||||
**/.pytest_cache
|
|
||||||
**/htmlcov
|
|
||||||
.claude/
|
.claude/
|
||||||
docs/tess/TASKS.md
|
docs/tess/TASKS.md
|
||||||
docs/scratchpads/
|
docs/scratchpads/
|
||||||
|
|||||||
@@ -41,15 +41,6 @@ steps:
|
|||||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||||
# Test-membership guard (#1017): also first link of test:framework-shell.
|
|
||||||
# Invoked from BOTH surfaces it audits (F2, PR #1018) — the guard is link
|
|
||||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
|
||||||
# with everything it guards; this direct line keeps one instrument running.
|
|
||||||
- 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
|
||||||
|
|||||||
@@ -201,21 +201,8 @@ git clone [email protected]:mosaicstack/stack.git
|
|||||||
cd stack
|
cd stack
|
||||||
|
|
||||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||||
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
|
||||||
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Verify dependencies and generated state before running source-quality gates.
|
|
||||||
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
|
||||||
# The web build certifies its exact standalone symlink manifest; added, removed,
|
|
||||||
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
|
||||||
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
|
||||||
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
|
||||||
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
|
||||||
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
|
||||||
# trust anchor outside worktree authority.
|
|
||||||
pnpm preflight
|
|
||||||
|
|
||||||
# Optional local queue service only. This does not start PostgreSQL.
|
# Optional local queue service only. This does not start PostgreSQL.
|
||||||
docker compose up -d valkey
|
docker compose up -d valkey
|
||||||
|
|
||||||
@@ -243,7 +230,6 @@ Gateway start command until KBN-101-02 makes that state fail closed.
|
|||||||
### Quality Gates
|
### Quality Gates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm preflight # Checkout/dependency/generated-state validation
|
|
||||||
pnpm typecheck # TypeScript type checking (all packages)
|
pnpm typecheck # TypeScript type checking (all packages)
|
||||||
pnpm lint # ESLint (all packages)
|
pnpm lint # ESLint (all packages)
|
||||||
pnpm test # Vitest (all packages)
|
pnpm test # Vitest (all packages)
|
||||||
|
|||||||
@@ -245,21 +245,9 @@ 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
|
||||||
// The property under test is CLAMPING: a 9999s request must come back as 900s.
|
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ../../scripts/build-web.mjs",
|
"build": "next build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
-62
@@ -146,68 +146,6 @@ 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; Git identity is derived from the exact agent name. |
|
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
||||||
| 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,7 +24,6 @@ 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>
|
||||||
@@ -34,10 +33,8 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
||||||
split runtime and repository identity authority. The generated launch contract supports `claude`,
|
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
||||||
`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,12 +3,11 @@
|
|||||||
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, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
|
2. Render deterministic <name>.env.generated data from that roster.
|
||||||
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. Reject a Git identity that is unsafe or differs from the generated agent name.
|
5. Derive the runtime command from validated runtime/model/reasoning data.
|
||||||
6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
|
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||||
7. Target only the exact configured tmux socket and roster session after ownership checks.
|
|
||||||
|
|
||||||
## File precedence and ownership
|
## File precedence and ownership
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ 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>
|
||||||
@@ -45,9 +44,8 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
|
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
||||||
operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
|
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||||
`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.
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<!-- board-roll: 1 entry rolled from BOARD.md -->
|
|
||||||
|
|
||||||
### **D-1 / P-ACTIVATION + hygiene — committed `.npmrc` hard-pins `store-dir=/root/.local/share/pnpm/store`.**
|
|
||||||
|
|
||||||
Correct for the CI container (runs as root), fatal for EVERY non-root local checkout: `EACCES` on `/root/.local/share/pnpm/store/v10/server/server.json`. A committed config that only works on one runtime is exactly the activation-skew class. Fix candidate: make store-dir env-overridable, not hardcoded.
|
|
||||||
|
|
||||||
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
|
||||||
|
|
||||||
### **D-3 / P-FLEET-001 — the seats running this mission are UNMANAGED.** `mos-remediation`, `rev-974`,
|
|
||||||
|
|
||||||
`planner-opus`, `planner-sol` appear in NO roster (`~/.config/mosaic/fleet/roster.yaml`, `agents/`). Planners run on socket `default`; the roster declares `mosaic-fleet`. This is the exact "one roster-owned socket/host + quarantine unmanaged + stale GC" failure P-FLEET-001 indicts — observed on the remediation mission's own fleet. Prerequisite for INBOX identity-addressing.
|
|
||||||
|
|
||||||
### **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).
|
|
||||||
|
|
||||||
<!-- 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.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
|
||||||
|
|
||||||
**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next.
|
|
||||||
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
|
||||||
|
|
||||||
## Head
|
|
||||||
|
|
||||||
- Mission charter + 15 decisions + 4-build plan: PERSISTED (`docs/remediation/MISSION.md`).
|
|
||||||
- HOLD lifted for this workstream (Jason 2026-07-31). Nothing implemented yet — planning first.
|
|
||||||
- Orchestrator seat `mos-remediation` is LIVE and owns the mission. Residency attestation: PASS.
|
|
||||||
- **TASK-0 DONE** — checkout repaired, all three gates green HONESTLY (no `--no-verify`), branch pushed.
|
|
||||||
- **TASK-1 DONE** — both planners delivered independently on clean context; reconciled into `TASKS.md`
|
|
||||||
(58 tasks across P0–P5, 7 convergences, 7 adjudicated disagreements, 3 escalated decisions).
|
|
||||||
- **NEXT ACTION IS NOT MINE:** DECISION-1/2/3 (`TASKS.md` §5) must be ruled before P0 dispatch.
|
|
||||||
RM-01 is dispatchable immediately regardless — it depends on nothing and blocks everything.
|
|
||||||
|
|
||||||
## In-flight
|
|
||||||
|
|
||||||
| Task | Owner | State |
|
|
||||||
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
|
|
||||||
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
|
|
||||||
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
|
|
||||||
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
|
|
||||||
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
|
|
||||||
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
|
|
||||||
|
|
||||||
## Fleet seats
|
|
||||||
|
|
||||||
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
|
||||||
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
|
|
||||||
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
|
|
||||||
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
|
||||||
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
|
||||||
|
|
||||||
## Gate status
|
|
||||||
|
|
||||||
- Delivery gates active: author≠reviewer, diff-blind pre-registered checks, CI-green, merged-PR completion.
|
|
||||||
- Freeze: LIFTED for this workstream only.
|
|
||||||
- Git identity: `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
|
||||||
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**
|
|
||||||
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
|
|
||||||
|
|
||||||
## Sequencing (from MISSION.md)
|
|
||||||
|
|
||||||
1. Spine + choke-point service (MACP wiring @ mosaic_orchestrator.py::run_single_task) + PG/Redis
|
|
||||||
⚠ **CONTESTED — see DECISION-1.** Both planners independently reject this wire-in point: that
|
|
||||||
controller is `"enabled": false` and references a dispatcher that does not exist here. Charter text
|
|
||||||
left UNCHANGED pending Mos/Jason ruling; do not treat it as settled.
|
|
||||||
2. Rotation daemon (finish Mission Control Plane, reuse packages/coord)
|
|
||||||
3. Comms service (envelope→service→PG/Redis→adapters)
|
|
||||||
4. Hygiene + conformance harness
|
|
||||||
Cross-cutting retirements: flat-file tracking, 3 MACP islands, silent MOSAIC BYPASS.
|
|
||||||
|
|
||||||
## Dogfood evidence — live failure classes, not hypotheticals
|
|
||||||
|
|
||||||
> Newest first. Oldest entries roll to `BOARD-LEDGER.md` via `board-roll.sh` when this file
|
|
||||||
> exceeds its 8 KB cap. Keystone detail is duplicated in `TASKS.md` §1a, so rolling loses nothing.
|
|
||||||
|
|
||||||
<!-- BOARD-ROLL:START -->
|
|
||||||
|
|
||||||
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
|
|
||||||
|
|
||||||
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
|
|
||||||
|
|
||||||
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
|
|
||||||
|
|
||||||
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
|
|
||||||
|
|
||||||
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
|
|
||||||
|
|
||||||
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
|
|
||||||
|
|
||||||
<!-- BOARD-ROLL:END -->
|
|
||||||
|
|
||||||
## Decisions log
|
|
||||||
|
|
||||||
- 2026-07-31 — Mission set up by Mos post-postmortem (15/15 decided). Dogfood posture active.
|
|
||||||
- 2026-07-31 — Mos: stale `.mosaic/orchestrator/mission.json` is RESIDUE of the disabled Python
|
|
||||||
orchestrator rail that this plan RETIRES. Do NOT invest in it; do NOT build on that rail. The 0/0
|
|
||||||
milestone banner is cosmetic. (Supersedes any plan to repair it.)
|
|
||||||
- 2026-07-31 — Mos: planners must be dispatched with GUARANTEED clean context, not requested-clean.
|
|
||||||
Prior default-socket planner sessions predate this mission; dirty context is the indicted hygiene.
|
|
||||||
- 2026-07-31 — mos-remediation: worker briefs forbid all git ops and restrict each worker to a single
|
|
||||||
named output file, so two planners can share one checkout without a branch race (M2-era incident doctrine).
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,424 +0,0 @@
|
|||||||
# Adversarial Decomposition — Pragmatic / Shortest-Path Side
|
|
||||||
|
|
||||||
**Planner:** `planner-sol`
|
|
||||||
**Bias:** make one real fleet task pass through one enforced path as early as possible; reuse before building.
|
|
||||||
**Scope source:** `MISSION.md`, `MACP-WIRING-SCOUT.md`, `BOARD.md`, existing `@mosaicstack/macp`, `packages/coord`, PG/Valkey, Tess durable inbox/outbox, and the Mission Control PRD.
|
|
||||||
|
|
||||||
## Executive position
|
|
||||||
|
|
||||||
The first useful milestone is **not** “complete Builds 1 and 2.” It is this narrow vertical slice:
|
|
||||||
|
|
||||||
> A DB-backed mission task is atomically claimed by `packages/coord`, executed by one Node `@mosaicstack/macp` TaskExecutor, gated, and terminally recorded with identity-bound events and a tri-state mutation result. No `docs/TASKS.md`, `mission.json`, `tasks.json`, Python gate loop, or NDJSON ledger participates.
|
|
||||||
|
|
||||||
That slice is **SOL-03 → SOL-04 → SOL-05 → SOL-06 → SOL-07 → SOL-08**, estimated at **72K tokens**, mostly Codex. PG polling is acceptable for this first proof. Redis acceleration follows only after correctness is observable. This is the shortest path that is both dogfoodable and not throwaway work.
|
|
||||||
|
|
||||||
### Cost posture
|
|
||||||
|
|
||||||
- **25 PR tasks, ~294K tokens total:** ~164K Codex, ~130K Sonnet, **0K Opus**.
|
|
||||||
- First live choke-point dogfood: ~72K on the hard path; SOL-01 and SOL-02 can run beside it.
|
|
||||||
- Opus is not justified for planned implementation. Escalate only if an independent security review finds an unresolved architecture-level authority flaw.
|
|
||||||
- Every row is one PR. Estimates include implementation, focused tests, docs affected by that PR, and one remediation pass—not orchestration/reviewer overhead.
|
|
||||||
|
|
||||||
## Gates and critical path
|
|
||||||
|
|
||||||
| gate | opens when | proof required before downstream work |
|
|
||||||
| ------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **G0 — trustworthy launch gates** | SOL-01, SOL-02 | non-root checkout works; queue status cannot become false-green |
|
|
||||||
| **G1 — first dogfood / minimum viable cut** | SOL-08 | one live fleet task completes DB → MACP executor → gates → DB with no flat-file state |
|
|
||||||
| **G2 — Builds 1+2 closed** | SOL-09..SOL-12 | all producers use the executor; duplicate islands retired; Redis loss is recoverable from PG |
|
|
||||||
| **G3 — rotation real** | SOL-13..SOL-16 | stale generation cannot mutate; fresh session resumes typed state; Pi-brick recovery works without broker |
|
|
||||||
| **G4 — sole-path comms real** | SOL-17..SOL-22 | roster identity is stable; bounced/stale messages converge through PG/Redis and adapters |
|
|
||||||
| **G5 — mission proof** | SOL-23..SOL-25 | workflow sweep cannot capture unknown files; fault bank passes, including 100 rotations |
|
|
||||||
|
|
||||||
**Critical path:** `03 → 04 → 05 → 06 → 08 → 10 → 12 → 13 → 14 → 15 → 16 → 17 → 18 → 19 → 20 → 21 → 22 → 24 → 25`.
|
|
||||||
|
|
||||||
## Ordered task list
|
|
||||||
|
|
||||||
### SOL-01 — Repair activation coherence and non-root checkout hygiene
|
|
||||||
|
|
||||||
- **build:** 5 (hygiene; pulled forward)
|
|
||||||
- **depends_on:** —
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. A clean non-root checkout can run dependency/bootstrap preparation without accessing `/root`.
|
|
||||||
2. A root CI checkout still uses an isolated writable pnpm store.
|
|
||||||
3. Activation installs CLI, hooks, broker/runtime assets, and version manifest transactionally: induced failure leaves the prior complete generation active.
|
|
||||||
4. Launch with a deliberately skewed component version is rejected with the exact repair command; diagnostics remain usable.
|
|
||||||
5. No test uses `--no-verify` or suppresses hooks.
|
|
||||||
- **dogfood seed:** BOARD D-1 root-pinned `.npmrc` and D-2 root-owned Husky path.
|
|
||||||
- **est. tokens:** 6K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-02 — Make queue guard fail-safe with exit-asserting tests
|
|
||||||
|
|
||||||
- **build:** 1
|
|
||||||
- **depends_on:** —
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Fixture responses `pending`, `success`, `failure`, no-status, malformed JSON, provider error, and unknown status produce explicitly asserted process exits.
|
|
||||||
2. Only terminal success/no-active-queue returns 0; unknown, malformed, and transport failure return non-zero with actionable output.
|
|
||||||
3. A payload larger than 150 KiB is consumed without argv expansion or truncation.
|
|
||||||
4. At least one mutant changes unknown→success and is killed by the test suite.
|
|
||||||
- **dogfood seed:** inert gate-6 and recursive #1019 failure (unknown→exit 0; ARG_MAX).
|
|
||||||
- **est. tokens:** 8K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-03 — Complete the canonical MACP contract, not another protocol
|
|
||||||
|
|
||||||
- **build:** 1
|
|
||||||
- **depends_on:** —
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. `@mosaicstack/macp` validates typed Task, TaskResult, lifecycle Event, state Claim, and `verified | written-unverified | failed` mutation outcome records.
|
|
||||||
2. Claims require source, confidence, issued-at, TTL/expiry, refresh instruction, and HMAC integrity; tamper/expiry returns a typed refusal, never partial data.
|
|
||||||
3. Lifecycle events include launch, mission generation, checkpoint, rotation, recovery, inbox receipt, and terminal disposition while preserving existing task events.
|
|
||||||
4. `MOSAIC_AGENT_NAME` is required for mutating execution and appears in credential/actor binding; missing identity fails closed.
|
|
||||||
5. Target metadata requires repository identity, task/record ID, and head or generation where applicable.
|
|
||||||
- **dogfood seed:** rev-974 identity drift plus the three observed write outcomes.
|
|
||||||
- **est. tokens:** 8K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-04 — Add the narrow PG orchestration spine schema
|
|
||||||
|
|
||||||
- **build:** 2
|
|
||||||
- **depends_on:** SOL-03
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Migration up creates mission, task, dependency, task-claim, MACP event, typed state-claim, session-generation, and dispatch-outbox records with tenant/mission keys and uniqueness constraints.
|
|
||||||
2. The database rejects a task without a mission, a dependency outside its mission, duplicate idempotency keys, and terminal→running regression.
|
|
||||||
3. Event and claim records reference canonical mission/task/generation identities; claims store integrity metadata.
|
|
||||||
4. Migration rollback on an empty test DB succeeds; rerunning migration is safe.
|
|
||||||
5. No comms-specific “universal message” schema is invented here; Build 4 reuses/extends existing interaction inbox/outbox tables.
|
|
||||||
- **dogfood seed:** mission convention existed but a lane could act with no mechanically valid mission/task.
|
|
||||||
- **est. tokens:** 12K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-05 — Implement atomic PG task claims, transitions, ledger, and outbox
|
|
||||||
|
|
||||||
- **build:** 2
|
|
||||||
- **depends_on:** SOL-04
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Two concurrent claimers for one runnable task yield exactly one lease owner.
|
|
||||||
2. Dependencies are evaluated transactionally; an unmet dependency can never be claimed.
|
|
||||||
3. Claim, state transition, MACP event, and dispatch-outbox append commit atomically or all roll back.
|
|
||||||
4. Expired leases are reclaimable with a higher fencing generation; stale owners cannot complete or mutate.
|
|
||||||
5. Querying mission status is derived solely from PG and returns the next runnable task deterministically.
|
|
||||||
- **dogfood seed:** model-maintained live board and stale claims surviving session changes.
|
|
||||||
- **est. tokens:** 12K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-06 — Build the one production Node MACP TaskExecutor
|
|
||||||
|
|
||||||
- **build:** 1
|
|
||||||
- **depends_on:** SOL-03, SOL-05
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. A public Node executor accepts only a claimed canonical MACP Task, resolves credentials/identity, runs the worker, runs structured gates, and persists terminal result/events through SOL-05.
|
|
||||||
2. Worker exit 0 plus a failed gate cannot produce `completed`; worker failure cannot skip terminal ledger emission.
|
|
||||||
3. Claude, Codex, and Pi fixture backends emit the same runtime-neutral lifecycle sequence.
|
|
||||||
4. Every mutation returns one mandatory tri-state outcome; callers cannot compile while discarding it.
|
|
||||||
5. Crash after worker success but before terminal commit leaves a recoverable fenced claim and no false completion.
|
|
||||||
- **dogfood seed:** stranded MACP, gate-6 inert completion, and `written-unverified` being treated as success.
|
|
||||||
- **est. tokens:** 16K
|
|
||||||
- **suggested runtime tier:** sonnet
|
|
||||||
|
|
||||||
### SOL-07 — Provide one-shot flat-file import and cutover readiness audit
|
|
||||||
|
|
||||||
- **build:** 2
|
|
||||||
- **depends_on:** SOL-05
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. A dry-run parses existing mission/TASKS artifacts, reports unsupported/ambiguous rows, and performs zero writes.
|
|
||||||
2. Apply is idempotent and records source digests; repeated apply creates no duplicates.
|
|
||||||
3. Unknown status, dangling dependency, duplicate task ID, and malformed table block import with row-level diagnostics.
|
|
||||||
4. Readiness reports “cutover-ready” only when imported PG projections exactly match source counts/dependencies/statuses.
|
|
||||||
5. This command is migration-only; it exposes no dual-write or ongoing sync mode.
|
|
||||||
- **dogfood seed:** current remediation board/TASKS state needs a clean DB landing without silently losing tasks.
|
|
||||||
- **est. tokens:** 8K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-08 — Hard-cut `packages/coord` to PG and dogfood one live task
|
|
||||||
|
|
||||||
- **build:** 1
|
|
||||||
- **depends_on:** SOL-06, SOL-07
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. `mosaic coord run/status/continue` reads and mutates PG only; absent/unmigrated DB state fails with the SOL-07 repair path.
|
|
||||||
2. No fallback reads/writes `docs/TASKS.md`, mission JSON, task JSON, state JSON, results JSON, or events NDJSON.
|
|
||||||
3. A live canary task assigned to a fleet seat travels PG claim → TaskExecutor → worker → gate → terminal PG result/event and closes only after gate success.
|
|
||||||
4. Killing the coordinator after claim and restarting it neither duplicates execution nor allows the stale lease to close the task.
|
|
||||||
5. Evidence query shows actor seat, mission/task, target metadata, gate results, and tri-state outcome.
|
|
||||||
- **dogfood seed:** this remediation mission itself; reproduce a gate-6-style non-null task and identity-bound write.
|
|
||||||
- **est. tokens:** 16K
|
|
||||||
- **suggested runtime tier:** sonnet
|
|
||||||
|
|
||||||
> **G1 FIRST-DOGFOOD:** stop and validate here before broadening. If SOL-08 cannot carry a real task, do not build Redis, rotation, comms, or UI.
|
|
||||||
|
|
||||||
### SOL-09 — Route Forge and OpenClaw/MACP producers through TaskExecutor
|
|
||||||
|
|
||||||
- **build:** 1
|
|
||||||
- **depends_on:** SOL-08
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Forge and the OpenClaw MACP runtime submit the canonical Task type to SOL-06; neither executes a worker or gate itself.
|
|
||||||
2. Their success callbacks are derived from canonical terminal results, not local/stub completion.
|
|
||||||
3. A failed canonical gate is observed identically from Coord, Forge, and OpenClaw fixtures.
|
|
||||||
4. Repository search plus an executable import boundary test finds no production-local redefinition of Task/TaskResult/GateResult on these paths.
|
|
||||||
- **dogfood seed:** Forge’s immediate empty-gate completion and the plugin’s redefined MACP-shaped result.
|
|
||||||
- **est. tokens:** 10K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-10 — Retire flat-file orchestration and the disabled duplicate rail
|
|
||||||
|
|
||||||
- **build:** 1
|
|
||||||
- **depends_on:** SOL-09
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. The Python controller execution/gate/event path, `tasks_md_sync`, plugin-local protocol types, orphaned context loader, and production flat-file orchestration writers/readers are absent from shipped assets.
|
|
||||||
2. Framework guides/templates/startup context point to DB mission commands, not `docs/TASKS.md` as orchestration SoR.
|
|
||||||
3. A regression scan fails CI if production code reintroduces `events.ndjson`, `tasks.json`, `mission.json`, or `docs/TASKS.md` orchestration mutation.
|
|
||||||
4. jarvis-brain PDA flat files and unrelated project docs remain untouched.
|
|
||||||
5. Upgrade removes/quarantines obsolete generated rail files without deleting user source/docs.
|
|
||||||
- **dogfood seed:** three parallel islands and stale `.mosaic/orchestrator/mission.json` 0/0 residue.
|
|
||||||
- **est. tokens:** 14K
|
|
||||||
- **suggested runtime tier:** sonnet
|
|
||||||
|
|
||||||
### SOL-11 — Add Redis hot dispatch as a derived outbox consumer
|
|
||||||
|
|
||||||
- **build:** 2
|
|
||||||
- **depends_on:** SOL-08
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Task creation commits mission/task/outbox in PG before any Redis enqueue.
|
|
||||||
2. Induced Redis failure leaves the task durable and pending; a sweeper later enqueues it exactly once logically.
|
|
||||||
3. Deleting the Redis queue and rebuilding from PG restores all non-terminal dispatches without reviving terminal tasks.
|
|
||||||
4. Duplicate delivery is neutralized by PG claim fencing/idempotency.
|
|
||||||
5. Existing `packages/queue` adapter/config is reused; no second broker API is introduced.
|
|
||||||
- **dogfood seed:** inert/unknown queue transport and broker outage during task dispatch.
|
|
||||||
- **est. tokens:** 12K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-12 — Lock Builds 1+2 with black-box failure cases
|
|
||||||
|
|
||||||
- **build:** 2
|
|
||||||
- **depends_on:** SOL-02, SOL-10, SOL-11
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. A black-box suite proves: unknown queue status blocks; malformed task blocks; gate failure blocks completion; dropped identity blocks mutation; HMAC corruption forces refresh; Redis loss recovers from PG; stale lease cannot close.
|
|
||||||
2. The suite invokes shipped CLI/service boundaries, not internal mocks.
|
|
||||||
3. Every asserted failure checks process/result status and durable terminal/non-terminal state.
|
|
||||||
4. The same canary task succeeds under Claude, Codex, and Pi adapters or a documented unavailable-runtime fixture fails explicitly.
|
|
||||||
- **dogfood seed:** gate-6/#1019, identity drift, and built-but-unwired MACP.
|
|
||||||
- **est. tokens:** 8K
|
|
||||||
- **suggested runtime tier:** sonnet
|
|
||||||
|
|
||||||
### SOL-13 — Bind session authority to contract hash and generation
|
|
||||||
|
|
||||||
- **build:** 3
|
|
||||||
- **depends_on:** SOL-12
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Launch computes a stable hash over the effective Constitution/AGENTS/runtime/skills set and stores it with session generation.
|
|
||||||
2. Policy change or compaction detection marks the generation stale before any subsequent mutation.
|
|
||||||
3. A stale/mismatched generation can read diagnostics but cannot claim, write task state, acknowledge comms, merge, or close.
|
|
||||||
4. Re-attestation creates a new generation; old credentials/leases remain fenced.
|
|
||||||
5. Hash input order/path normalization is deterministic across two clean launches.
|
|
||||||
- **dogfood seed:** compacted orchestrator losing directives and D-4 ignoring an in-message reset.
|
|
||||||
- **est. tokens:** 12K
|
|
||||||
- **suggested runtime tier:** sonnet
|
|
||||||
|
|
||||||
### SOL-14 — Persist compact typed rotation checkpoints using Coord primitives
|
|
||||||
|
|
||||||
- **build:** 3
|
|
||||||
- **depends_on:** SOL-13
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Checkpoint contains mission/task, completed/blocked state, next three actions, constraints, claims, contract hash/generation, and cursors—never transcript text.
|
|
||||||
2. Checkpoint is HMAC-verified before rehydration; corrupt/expired/missing required claims refuse resume and request deterministic refresh.
|
|
||||||
3. Writing checkpoint and rotation-intent event is atomic in PG.
|
|
||||||
4. Existing Coord continuation capsule semantics are reused; no competing handoff schema/file is created.
|
|
||||||
- **dogfood seed:** manual MOS-ORCHESTRATION-BOARD checkpoint and incomplete-rehydration risk.
|
|
||||||
- **est. tokens:** 12K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-15 — Finish the deterministic coordinator rotation daemon
|
|
||||||
|
|
||||||
- **build:** 3
|
|
||||||
- **depends_on:** SOL-14
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Configured token threshold triggers checkpoint → revoke old authority → terminate → launch fresh → verify rehydration in that order.
|
|
||||||
2. Compaction-detected is a backstop that forces the same rotation path; it never requests recursive compaction.
|
|
||||||
3. A launch failure leaves the mission recoverable and visibly paused, not assigned to two active generations.
|
|
||||||
4. Ephemeral seats die/respawn without mission checkpoint; persistent/orchestrator seats rotate.
|
|
||||||
5. The implementation extends `packages/coord`; untracked `apps/coordinator` residue is not revived.
|
|
||||||
- **dogfood seed:** planner-sol dirty-context dispatch and the old coordinator’s log-only `_check_context()` behavior.
|
|
||||||
- **est. tokens:** 16K
|
|
||||||
- **suggested runtime tier:** sonnet
|
|
||||||
|
|
||||||
### SOL-16 — Add broker-independent recovery and remove silent MOSAIC BYPASS
|
|
||||||
|
|
||||||
- **build:** 3
|
|
||||||
- **depends_on:** SOL-15
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. With Redis/broker unavailable, a diagnostic/bootstrap command can inspect PG mission state, repair broker configuration, and resume without traversing the broker gate.
|
|
||||||
2. Normal recovery remains broker-gated and is labeled as such.
|
|
||||||
3. Break-glass requires explicit scope and expiry, emits a durable event, displays a loud banner, and auto-expires; permanent/silent bypass text or behavior is absent.
|
|
||||||
4. The Pi-brick fixture recovers the broker, then returns to normal gated operation without editing source/config by hand.
|
|
||||||
5. Orchestrator guidance removes “/compact and continue” only after the rotation command is available; ephemeral guidance remains explicit.
|
|
||||||
- **dogfood seed:** Pi brick and silent `MOSAIC BYPASS 2026-07-22`.
|
|
||||||
- **est. tokens:** 12K
|
|
||||||
- **suggested runtime tier:** sonnet
|
|
||||||
|
|
||||||
### SOL-17 — Converge each host on one roster-owned lifecycle domain
|
|
||||||
|
|
||||||
- **build:** 5 (hygiene; hard prerequisite for addressed comms)
|
|
||||||
- **depends_on:** SOL-16
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Reconcile establishes exactly one roster-declared tmux socket/lifecycle domain per host.
|
|
||||||
2. Unknown sessions are reported and quarantined; they are never killed without positive unmanaged classification.
|
|
||||||
3. Max-age/max-context stale sessions invoke SOL-15 rotation for persistent seats or reap for ephemerals.
|
|
||||||
4. Seat identity survives respawn and equals the roster/MOSAIC_AGENT_NAME binding.
|
|
||||||
5. A fixture matching the current four unmanaged remediation seats converges them or produces explicit quarantine actions.
|
|
||||||
- **dogfood seed:** scout-bounce and BOARD D-3 seats split between default and `mosaic-fleet` sockets.
|
|
||||||
- **est. tokens:** 14K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-18 — Publish authenticated `comms/v1` envelope and compatibility rules
|
|
||||||
|
|
||||||
- **build:** 4
|
|
||||||
- **depends_on:** SOL-13, SOL-17
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Envelope validates protocol version, message/idempotency ID, sender/recipient seat identity, class, ordering/coalesce key, creation/expiry, correlation, payload digest, and authentication.
|
|
||||||
2. Current and immediately previous supported protocol versions are accepted; unsupported versions are rejected loudly with supported range.
|
|
||||||
3. Framework/runtime version is diagnostic metadata and never the compatibility key.
|
|
||||||
4. Forged sender, changed recipient/payload, expired envelope, and replay with conflicting content fail closed.
|
|
||||||
- **dogfood seed:** wrong-socket bare tmux message with no authoritative sender/recipient receipt.
|
|
||||||
- **est. tokens:** 8K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-19 — Build the logical PG-first comms service with tmux adapter
|
|
||||||
|
|
||||||
- **build:** 4
|
|
||||||
- **depends_on:** SOL-18
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Sending commits envelope/payload and PENDING state in PG before adapter delivery.
|
|
||||||
2. State machine enforces PENDING → RECEIVED → CONSUMED or DEAD-LETTER; illegal regressions are rejected.
|
|
||||||
3. Recipient-filtered claims and append/coalesce policy are deterministic by message class.
|
|
||||||
4. tmux is a dumb adapter: delivery failure changes no PG authority state and is retryable.
|
|
||||||
5. Existing Tess durable repository/state-machine patterns are extended or generalized; no new deployable microservice or second inbox framework appears.
|
|
||||||
- **dogfood seed:** MACP scout bounce that was discovered only by manual liveness check.
|
|
||||||
- **est. tokens:** 16K
|
|
||||||
- **suggested runtime tier:** sonnet
|
|
||||||
|
|
||||||
### SOL-20 — Make `agent-send` use the sole path and prove stale-message handling
|
|
||||||
|
|
||||||
- **build:** 4
|
|
||||||
- **depends_on:** SOL-19
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Normal `agent-send` creates a comms/v1 record and observes RECEIVED/CONSUMED; it cannot directly invoke tmux.
|
|
||||||
2. A wrong/missing socket leaves PENDING with retry diagnostics, then reaches RECEIVED after roster repair without resending.
|
|
||||||
3. A stale coalescible message arriving after a newer terminal message is marked superseded/consumed and is not surfaced as live work.
|
|
||||||
4. Duplicate identical send is idempotent; same ID with changed content is rejected.
|
|
||||||
5. Inbox receipt/terminal disposition emits canonical MACP lifecycle events.
|
|
||||||
- **dogfood seed:** scout-bounce and #1018 stale-consumed message arriving after merge.
|
|
||||||
- **est. tokens:** 12K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-21 — Add Redis Streams hot delivery and PG reconciliation
|
|
||||||
|
|
||||||
- **build:** 4
|
|
||||||
- **depends_on:** SOL-11, SOL-20
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. PG commit precedes XADD; induced XADD failure is repaired by sweeper.
|
|
||||||
2. Consumer uses a PEL; ack sequence is PG CONSUMED commit before XACK.
|
|
||||||
3. Redis flush/restart rebuilds pending delivery from PG without duplicating consumed messages.
|
|
||||||
4. Pending, abandoned, and dead-letter transitions are observable with bounded retry/backoff.
|
|
||||||
5. Existing Redis/queue connection/configuration is reused.
|
|
||||||
- **dogfood seed:** delivery bounce plus broker loss between durable write and hot enqueue.
|
|
||||||
- **est. tokens:** 12K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-22 — Prove adapter pluggability with the existing Matrix connector
|
|
||||||
|
|
||||||
- **build:** 4
|
|
||||||
- **depends_on:** SOL-21
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Existing Matrix connector consumes/produces comms/v1 through SOL-19 without owning authority state.
|
|
||||||
2. The same envelope can fail tmux and later deliver through Matrix while producing one logical message lifecycle.
|
|
||||||
3. Matrix retry/reconnect cannot regress PG state or duplicate CONSUMED work.
|
|
||||||
4. Removing Matrix availability leaves PG/Redis/tmux behavior intact.
|
|
||||||
- **dogfood seed:** cross-socket scout notification bounce; alternate reach must not become alternate authority.
|
|
||||||
- **est. tokens:** 8K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-23 — Constrain auto-sync and agent writes by allowlist and lease
|
|
||||||
|
|
||||||
- **build:** 5
|
|
||||||
- **depends_on:** SOL-10
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Auto-sync stages only an explicit allowlist; an unknown modified/untracked docs/source file remains unstaged and is reported.
|
|
||||||
2. Agent source/docs writes require the correct worktree/lease; two seats cannot acquire the same mutable target concurrently.
|
|
||||||
3. Generated files are positively identified, not inferred by denylist.
|
|
||||||
4. The measured annotation/index mid-write fixture cannot be swept into an unrelated commit.
|
|
||||||
5. DB orchestration state is absent from repository staging concerns.
|
|
||||||
- **dogfood seed:** auto-sync sweep commit `517bd5c26` capturing agent-authored docs mid-write.
|
|
||||||
- **est. tokens:** 8K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
### SOL-24 — Build the real-artifact lifecycle conformance harness
|
|
||||||
|
|
||||||
- **build:** 5
|
|
||||||
- **depends_on:** SOL-16, SOL-17, SOL-22, SOL-23
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Harness launches shipped CLI/runtime artifacts and fault-injects compaction, broker outage, delivery bounce, identity drop, stale contract hash, queue unknown/malformed, Redis loss, and auto-sync collision.
|
|
||||||
2. One deterministic test executes 100 sequential rotations with no lost/duplicated task, claim, receipt, or terminal disposition.
|
|
||||||
3. Tests assert DB state/event order and process exits, not log substrings alone.
|
|
||||||
4. Harness runs against isolated PG/Redis namespaces and cleans only resources it created.
|
|
||||||
5. Every banked dogfood seed has a named case and evidence output suitable for CI/release attachment.
|
|
||||||
- **dogfood seed:** the complete failure bank: Pi brick, scout-bounce, gate-6/#1019, identity drift, auto-sync, #1018 stale-consumed, D-4 dirty context.
|
|
||||||
- **est. tokens:** 18K
|
|
||||||
- **suggested runtime tier:** sonnet
|
|
||||||
|
|
||||||
### SOL-25 — Complete operator cutover docs and activation proof
|
|
||||||
|
|
||||||
- **build:** 5
|
|
||||||
- **depends_on:** SOL-01, SOL-02, SOL-10, SOL-16, SOL-22, SOL-24
|
|
||||||
- **acceptance criteria (diff-blind testable):**
|
|
||||||
1. Operator docs give exact DB import/cutover, rollback-before-cutover, recovery, break-glass expiry, rotation, comms, quarantine, and conformance commands.
|
|
||||||
2. Link/command checks find no orchestrator instruction to mutate flat-file mission/tasks, use silent bypass, direct-tmux normal comms, or “compact and continue” a persistent seat.
|
|
||||||
3. A clean non-root install activates one coherent version and runs the conformance smoke subset.
|
|
||||||
4. Release evidence maps all 15 decisions and every live seed to a passing check or an explicit deferred item below.
|
|
||||||
- **dogfood seed:** activation skew plus the tendency to leave built fixes unwired or undocumented.
|
|
||||||
- **est. tokens:** 6K
|
|
||||||
- **suggested runtime tier:** codex
|
|
||||||
|
|
||||||
## Explicit DEFER list (10)
|
|
||||||
|
|
||||||
These are not rejected; they are **past first dogfood** and should not delay G1/G2. Each is gold-plating unless a live failure makes it necessary.
|
|
||||||
|
|
||||||
1. **DEFER — Mission dashboard/TUI views.** CLI/DB queries are enough to operate and prove the spine.
|
|
||||||
2. **DEFER — PRD-to-board automatic decomposition.** This is LLM/judgment-heavy and unrelated to enforcing already-decided tasks.
|
|
||||||
3. **DEFER — General heuristic churn scoring.** Implement token threshold + compaction sensor first; repeated-tool-loop inference can follow measured need.
|
|
||||||
4. **DEFER — Discord comms adapter.** Existing plugin reach remains; migrate only after tmux+Matrix prove the service contract.
|
|
||||||
5. **DEFER — Slack comms adapter.** No current dogfood dependency.
|
|
||||||
6. **DEFER — Telegram comms adapter.** No current dogfood dependency.
|
|
||||||
7. **DEFER — Public MCP comms surface.** `agent-send` and service API are sufficient for the mission proof.
|
|
||||||
8. **DEFER — Protocol-v2 features/general negotiation framework.** Ship v1 with a bounded current/previous acceptance window; do not predict v2.
|
|
||||||
9. **DEFER — Multi-region/HA PG or Redis.** Existing in-stack PG+Redis and rebuildability satisfy current failure classes.
|
|
||||||
10. **DEFER — Event analytics/search UI and long-term warehouse.** Indexed PG evidence plus CLI queries is enough for audit/conformance.
|
|
||||||
|
|
||||||
## Suspect abstractions register
|
|
||||||
|
|
||||||
| proposed thing | verdict |
|
|
||||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Canonical Node `TaskExecutor` | **JUSTIFIED:** explicitly required single choke point; wraps existing MACP functions rather than replacing them. |
|
|
||||||
| PG repository methods | **JUSTIFIED but narrow:** ordinary adapters around existing Drizzle/DB patterns, not a new “state platform.” |
|
|
||||||
| Rotation daemon | **JUSTIFIED:** finishes `packages/coord`; do not revive `apps/coordinator` or create another service. |
|
|
||||||
| Comms service | **JUSTIFIED only as a logical in-process boundary:** reuse Tess durable inbox/outbox and existing connectors; no new deployable microservice this cycle. |
|
|
||||||
| Universal queue/broker abstraction | **SUSPECT / DO NOT BUILD:** reuse `packages/queue`, PG outbox, and Redis Streams/BullMQ configuration already present. |
|
|
||||||
| Universal envelope/state framework | **SUSPECT / DO NOT BUILD:** MACP Task/Claim/Event and comms/v1 have different bounded purposes. |
|
|
||||||
| Generic compatibility-negotiation engine | **SUSPECT / DEFER:** a small supported-version check meets v1 needs. |
|
|
||||||
|
|
||||||
## Dissent (7)
|
|
||||||
|
|
||||||
1. **Do not wire new production behavior into `mosaic_orchestrator.py::run_single_task`.** The scout correctly identified the duplicated block, but BOARD’s later ruling says the disabled Python rail is residue and must be retired. Building a Node bridge only to delete it is throwaway. Put the Node TaskExecutor in `@mosaicstack/macp`, route the live Coord path to it at SOL-08, migrate remaining producers at SOL-09, then delete the Python block at SOL-10.
|
|
||||||
2. **Redis is not on the first-dogfood critical path.** PG claim/polling is sufficient for one real task and exposes correctness earlier. Add Redis only after G1; otherwise queue debugging obscures whether the choke point works.
|
|
||||||
3. **“One choke-point service” does not justify a new deployable service.** An exported executor plus Coord daemon is enough. A new Nest app, RPC protocol, deployment, auth layer, and health plane would be greenfield.
|
|
||||||
4. **The Mission Control PRD’s file-first and board-regeneration assumptions are superseded.** Keep its mission/rotation semantics, but obey the accepted hard DB cutover; do not implement its file-first milestones or PRD-to-board generator now.
|
|
||||||
5. **Do not gate every interactive runtime launch as if it were a mission task.** Enforce every tracked task/data mutation at the executor/DB authority boundary. Ephemeral interactive shells may launch, but receive no task mutation authority unless attached to a valid claim.
|
|
||||||
6. **Do not implement broad “churn intelligence.”** Token threshold and compaction-detected are deterministic sensors. Repeated-loop semantic detection is expensive, noisy, and premature until telemetry demonstrates a gap.
|
|
||||||
7. **The 100-rotation test is a final conformance bar, not an early unit-test tax.** First prove one rotation, then fault cases, then 100 repetitions in SOL-24. Requiring 100 before G3 would delay feedback without changing the design.
|
|
||||||
|
|
||||||
## Orchestrator reconciliation notes
|
|
||||||
|
|
||||||
- Pre-register each task’s acceptance checks from this document before showing implementation diffs to its reviewer. Author and reviewer remain different seats.
|
|
||||||
- SOL-07 permits a one-time import, **not** an interim store: no shadow writes, dual reads, or sync daemon.
|
|
||||||
- G1 is the budget escape hatch. If the 72K hard-path slice does not work, stop and remediate instead of spending the remaining ~222K.
|
|
||||||
- Docs belong in each behavior-changing PR where required; SOL-25 is cross-link/cutover validation, not permission to postpone essential docs.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# mos-remediation — Orchestrator Kickstart / Compaction-Survival Resume
|
|
||||||
|
|
||||||
**You are `mos-remediation`, the project orchestrator for the Mosaic Stack remediation, launched in `/src/mosaic-stack`.**
|
|
||||||
This file is your fail-closed resume procedure. Read it on EVERY fresh/cleared session and on the FIRST turn
|
|
||||||
after any compaction. This mission's whole point is that manual compaction-survival is fragile — so follow this
|
|
||||||
mechanically until Build 3 (rotation) makes it automatic.
|
|
||||||
|
|
||||||
## On resume (do in order, before any orchestration action)
|
|
||||||
|
|
||||||
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).
|
|
||||||
3. Read `docs/remediation/BOARD.md` **at `origin/remediation/state`** — the LIVE state: current phase,
|
|
||||||
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:
|
|
||||||
`../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
|
|
||||||
current build/phase, (c) the BOARD head (in-flight tasks + who owns them). If you cannot, HALT and re-read.
|
|
||||||
Do NOT act on memory alone; a compaction may have dropped context silently.
|
|
||||||
|
|
||||||
## Standing invariants (never violate)
|
|
||||||
|
|
||||||
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
|
|
||||||
- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff;
|
|
||||||
CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity.
|
|
||||||
- **Dogfooding:** every fix validated against its live seed case (MISSION.md lists them).
|
|
||||||
- **Tracking → DB** (hard cutover); do NOT re-invest in flat-file tracking. jarvis-brain PDA is off-limits.
|
|
||||||
- **Git identity:** export `MOSAIC_GIT_IDENTITY=<your-seat>` so wrappers author correctly and survive respawn.
|
|
||||||
|
|
||||||
## After every significant event
|
|
||||||
|
|
||||||
Overwrite stale lines in `BOARD.md`, keep it < 8 KB, commit + push. The board IS your checkpoint until the
|
|
||||||
DB-backed rotation daemon (Build 3) exists. Persist typed state (phase, tasks, owners, gates) — never the transcript.
|
|
||||||
|
|
||||||
## Fleet
|
|
||||||
|
|
||||||
- Adversarial planners: `planner-opus` (robustness), `planner-sol` (pragmatic) — dispatch for task decomposition; reconcile their oppositional decomps.
|
|
||||||
- Coders/reviewers: dispatch per roster + delivery gates. Comms: `~/.config/mosaic/tools/tmux/agent-send.sh`
|
|
||||||
(`-L <socket> -s <dst> -S <yourhost>:<yourseat> --class <class>`); always pass `-S`.
|
|
||||||
- Lead coordinator: Mos (`mos-claude`). Escalate only on the Constitution's escalation triggers.
|
|
||||||
|
|
||||||
## Remote control
|
|
||||||
|
|
||||||
On first startup, activate remote control for this session (`/remote-control`) so Jason can reach/drive you while
|
|
||||||
away. If the command is unavailable in this runtime, report it to Mos and continue — it is not a blocker.
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# MACP wiring investigation
|
|
||||||
|
|
||||||
**Scope:** `/src/mosaic-stack` inspected at HEAD `b79336a8c11e2a4646a47ff8d295a226e0c71404`; read-only. Existing dirty/untracked state was not touched.
|
|
||||||
|
|
||||||
## Verdict
|
|
||||||
|
|
||||||
**(c) STRANDED.** `packages/macp` is exported, unit-tested, and registered as a CLI command group, but no production dispatch/execution code invokes its credential resolver, gate runner, or event emitter.
|
|
||||||
A separate MACP-named OpenClaw/orchestrator rail exists, but it redefines task/result types and gate/event logic instead of importing `@mosaicstack/macp`; direct `mosaic yolo|claude|codex|opencode|pi` also bypasses it.
|
|
||||||
|
|
||||||
## 1. Production call sites vs tests
|
|
||||||
|
|
||||||
### Production references to `@mosaicstack/macp`
|
|
||||||
|
|
||||||
| Surface | Evidence | Actual use |
|
|
||||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Unified CLI | `packages/mosaic/src/cli.ts:8,385` | Imports and registers `registerMacpCommand`; no task/gate/event execution. |
|
|
||||||
| Forge | `packages/forge/src/types.ts:1,17,68,79` | **Type-only** imports of `GateEntry` and `TaskResult`. Pipeline calls an injected abstract executor at `packages/forge/src/pipeline-runner.ts:189-190,299-300`, not MACP. |
|
|
||||||
| Mosaic package metadata | `packages/mosaic/package.json:36`; `packages/mosaic/src/runtime/update-checker.ts:172` | Dependency/update inventory only. |
|
|
||||||
| Agent | No match under production `packages/agent/src/**` | No MACP import/call. |
|
|
||||||
| Coord | No match under production `packages/coord/src/**`; dependency list is only `@mosaicstack/types` at `packages/coord/package.json:25-27` | No MACP import/call. |
|
|
||||||
| Plugins | No `@mosaicstack/macp` import under `plugins/**` | No package use; the MACP-named plugin is an independent implementation (below). |
|
|
||||||
|
|
||||||
**Repository-wide production call-site search result:** excluding `packages/macp/**`, tests, worktrees, and build output, there are **zero** calls to `runGate`, `runGates`, `emitEvent`, `appendEvent`, or `resolveCredentials`.
|
|
||||||
|
|
||||||
### `packages/macp` implementation is internally connected only
|
|
||||||
|
|
||||||
- Public exports: `packages/macp/src/index.ts:1-48` exports Task/GateEntry/MACPEvent/TaskResult, credential resolution, `runGate(s)`, risk-floor, and event emission.
|
|
||||||
- Gate runner calls its own event emitter: `packages/macp/src/gate-runner.ts:187-236`.
|
|
||||||
- Event persistence implementation appends NDJSON to a caller-supplied path: `packages/macp/src/event-emitter.ts:11-27`.
|
|
||||||
- There is **no exported programmatic `submit` implementation** in `packages/macp/src/index.ts:1-48`; only the CLI placeholder named `submit`.
|
|
||||||
|
|
||||||
### Test-only invocations
|
|
||||||
|
|
||||||
- Gate runner: `packages/macp/__tests__/gate-runner.test.ts:96-242` invokes `runGate/runGates`.
|
|
||||||
- Event ledger: `packages/macp/__tests__/event-emitter.test.ts:46-133` invokes `appendEvent/emitEvent` against temporary `events.ndjson` files.
|
|
||||||
- Credential resolver: `packages/macp/__tests__/credential-resolver.test.ts` exercises resolver behavior.
|
|
||||||
- CLI tests only verify command registration: `packages/macp/src/cli.spec.ts:37-73`; `packages/mosaic/src/cli-smoke.spec.ts:8` imports registration.
|
|
||||||
|
|
||||||
## 2. Gate on the live dispatch path
|
|
||||||
|
|
||||||
### Direct Mosaic runtime launch bypasses MACP
|
|
||||||
|
|
||||||
- Runtime commands dispatch directly to harness launch: `packages/mosaic/src/commands/launch.ts:730-801`.
|
|
||||||
- Claude/Pi go through the lease broker, then spawn the runtime: `packages/mosaic/src/commands/launch.ts:817-843`.
|
|
||||||
- Commander wiring sends `mosaic yolo <runtime>` and direct runtime commands to `launchRuntime`: `packages/mosaic/src/commands/launch.ts:1102-1157,1165-1167`.
|
|
||||||
- None of those ranges imports/calls `@mosaicstack/macp`, `runGates`, or `emitEvent`.
|
|
||||||
|
|
||||||
**Result:** a direct `mosaic yolo`, `mosaic claude/codex/opencode/pi`, or underlying exec does not create a typed MACP Task, run the package gate-runner, or append a package MACPEvent.
|
|
||||||
|
|
||||||
### Coord bypasses MACP
|
|
||||||
|
|
||||||
- Coord reads/updates `docs/TASKS.md`: `packages/coord/src/runner.ts:6,306-386`; parser/writer is `packages/coord/src/tasks-file.ts:326-377`.
|
|
||||||
- Coord launches a child process directly: `packages/coord/src/runner.ts:397-427`.
|
|
||||||
- Mission state is its own `.mosaic/orchestrator/mission.json`/`next-task.json`: `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:15-16,355-384`.
|
|
||||||
|
|
||||||
**Result:** Coord task execution has no MACP Task validation, package gate runner, or event append.
|
|
||||||
|
|
||||||
### Forge bypasses MACP execution
|
|
||||||
|
|
||||||
- Forge defines its own `ForgeTask` and abstract `TaskExecutor`: `packages/forge/src/types.ts:48-80`.
|
|
||||||
- The production CLI injects a **stub executor** that immediately reports completion with empty gates: `packages/forge/src/cli.ts:13-31,167,185`.
|
|
||||||
|
|
||||||
**Result:** even `mosaic forge run` does not execute MACP gates or persist MACP events.
|
|
||||||
|
|
||||||
### Separate MACP-named rail is not `packages/macp`
|
|
||||||
|
|
||||||
- OpenClaw plugin registers an ACP backend named `macp`: `plugins/macp/src/index.ts:1-18,72-102`.
|
|
||||||
- It locally redefines `OrchestratorTask`, `TaskResult`, and gate-result shapes instead of importing package types: `plugins/macp/src/macp-runtime.ts:43-77`.
|
|
||||||
- It appends directly to `.mosaic/orchestrator/tasks.json`, triggers an external controller, and polls `results/<task>.json`: `plugins/macp/src/macp-runtime.ts:290-329,437-483`.
|
|
||||||
- The controller independently implements `append_event`, `emit_event`, shell execution, gate execution, and results: `packages/mosaic/framework/tools/orchestrator-matrix/controller/mosaic_orchestrator.py:29-91,126-276`.
|
|
||||||
- Its gate loop runs raw string gates after worker success: `mosaic_orchestrator.py:213-235`; it does not support the package's structured `GateEntry`/AI-review behavior.
|
|
||||||
- Current checkout disables this controller: `.mosaic/orchestrator/config.json:2` (`"enabled": false`).
|
|
||||||
- Plugin references `tools/macp/dispatcher/pi_runner.ts` at `plugins/macp/src/macp-runtime.ts:85-91`, but `tools/macp/` does not exist in this checkout.
|
|
||||||
|
|
||||||
**Result:** there is a parallel, optionally enabled MACP-shaped rail, not package integration. It cannot make `packages/macp` the enforced path.
|
|
||||||
|
|
||||||
## 3. Event ledger status
|
|
||||||
|
|
||||||
- Package persistence exists only as a library primitive: `packages/macp/src/event-emitter.ts:11-27` appends JSON lines to an arbitrary `eventsPath`.
|
|
||||||
- Package event emission is reached only from package `runGates`: `packages/macp/src/gate-runner.ts:204-236`.
|
|
||||||
- No production caller invokes package `runGates/emitEvent/appendEvent`; therefore no runtime destination path is configured for the package ledger.
|
|
||||||
- Test-only ledgers use temp paths: `packages/macp/__tests__/event-emitter.test.ts:35-133`; gate tests use temp `events.ndjson`: `packages/macp/__tests__/gate-runner.test.ts:171-242`.
|
|
||||||
- The separate Python controller writes `.mosaic/orchestrator/events.ndjson`: `mosaic_orchestrator.py:129-133,159-161,219-235`; the Mosaic Framework plugin only **reads** that file for context at `plugins/mosaic-framework/src/index.ts:279-316,430-438`.
|
|
||||||
- In this checkout, `.mosaic/orchestrator/events.ndjson` is absent and the controller is disabled (`.mosaic/orchestrator/config.json:2`).
|
|
||||||
|
|
||||||
**Conclusion:** `MACPEvent` from `packages/macp` is defined/tested but not emitted or persisted by live production call sites. The similarly shaped Python ledger is a duplicate island.
|
|
||||||
|
|
||||||
## 4. Coord link
|
|
||||||
|
|
||||||
- `packages/coord` has no `@mosaicstack/macp` dependency/import: `packages/coord/package.json:25-27`; no matches in `packages/coord/src/**`.
|
|
||||||
- Coord's task model is Markdown `docs/TASKS.md` plus mission/session JSON: `packages/coord/src/tasks-file.ts:1-10,257-377`; `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:306-427`.
|
|
||||||
- It does not consume `.mosaic/orchestrator/events.ndjson`, MACP Task, MACPEvent, GateEntry, or TaskResult.
|
|
||||||
|
|
||||||
**Conclusion:** Coord and `packages/macp` are disconnected islands.
|
|
||||||
|
|
||||||
## Shortest wiring gap
|
|
||||||
|
|
||||||
**Single integration point:** replace the duplicated execution/gate/event block in `mosaic_orchestrator.py::run_single_task` (`:126-276`) with one production Node `TaskExecutor` backed by `@mosaicstack/macp` (typed Task validation + `resolveCredentials` + `runGates` + `emitEvent`), and make Coord/Forge/OpenClaw submit through that executor. This queue/controller choke point is where `yolo|acp|exec` worker outcomes can be gated and journaled before completion is recorded.
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
# Mosaic Stack Remediation — Mission Charter
|
|
||||||
|
|
||||||
**Owner:** project orchestrator `mos-remediation` (Claude, launched in `/src/mosaic-stack`).
|
|
||||||
**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."
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Convert the 15 accepted postmortem remediation proposals into a working, **dogfooded** implementation.
|
|
||||||
**North star:** anything with a deterministic right answer moves OUT of the LLM into a deterministic
|
|
||||||
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)
|
|
||||||
|
|
||||||
- **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).
|
|
||||||
- Discussion checkpoint (rich rationale per proposal): `jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`.
|
|
||||||
- Postmortem report: mosaicstack/stack PR #107 (merged 88f4ee04).
|
|
||||||
- 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
|
|
||||||
|
|
||||||
| 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`** — 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. |
|
|
||||||
| **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. |
|
|
||||||
| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 1–4 hold. |
|
|
||||||
|
|
||||||
## The finding that sets the cost
|
|
||||||
|
|
||||||
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
|
|
||||||
exist; the Mission Control PRD exists; PG + Redis already run in-stack. Three duplicate MACP islands, an
|
|
||||||
orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retire, NOT greenfield. "Finish, don't re-spec."**
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
(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.
|
|
||||||
3. **Comms service** (build 4) — envelope → service → PG/Redis → adapters; retire direct-tmux.
|
|
||||||
4. **Hygiene + conformance** (build 5) — fleet convergence, allowlist sync, dogfood harness.
|
|
||||||
|
|
||||||
- **Cross-cutting retirements:** flat-file orchestration tracking (hard cutover to DB), the 3 duplicate MACP islands, the silent `MOSAIC BYPASS`.
|
|
||||||
|
|
||||||
## Standing directives (Jason, 2026-07-31)
|
|
||||||
|
|
||||||
- **Dogfooding:** validate EACH fix against the live fleet failure that motivated it. Seed acceptance tests:
|
|
||||||
Pi brick (RECOVERY), scout-bounce (INBOX/FLEET), gate-6 inert + #1019 recursion (QUEUE), identity drift
|
|
||||||
(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
|
|
||||||
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)
|
|
||||||
|
|
||||||
1. **P-ACTIVATION-001** accept — transactional CLI+hooks+broker+version release; block launch on skew, fail-SAFE.
|
|
||||||
2. **P-AUTHORITY-001** MODIFY — structured authenticated inbox; envelope carries comms-PROTOCOL version; version the protocol not participants; N-version window.
|
|
||||||
3. **P-LIFECYCLE-001** accept — rotation not recursive compaction; pre-empt at token threshold; enforcer = deterministic coordinator; = finish Mission Control Plane.
|
|
||||||
4. **P-MISSION-001** accept — bind lanes to mission+task ledger; convention exists, ENFORCEMENT is the gap; mission+tasks → DB spine (hard cutover).
|
|
||||||
5. **P-QUEUE-001** accept — repair queue transport + exit-asserting non-null-case tests (gate-6 was INERT fleet-wide; #1019 fix recursed the same bug).
|
|
||||||
6. **P-STATE-001** accept — typed claims (source/confidence/TTL) not prose blob; MACP typed record; integrity fail-closed HMAC; don't fork a 4th island.
|
|
||||||
7. **P-AUDIT-001** accept — MACPEvent lifecycle ledger; EXTEND enum to lifecycle events; runtime-neutral (executor-emitted); retire duplicate Python ledger.
|
|
||||||
8. **P-WRAPPER-001** accept — identity derives from seat name + survives respawn; tri-state write outcomes MANDATORY; name safe target metadata.
|
|
||||||
9. **P-CONTRACT-001** accept — bind session to contract hash; re-anchor on policy-change OR compaction-detected; stale generation loses authority MECHANICALLY.
|
|
||||||
10. **P-INBOX-001** MODIFY — sole-path comms SERVICE; PG durable SoR + Redis hot queue (outbox, reconciliation sweeper); pluggable adapters; protocol-first, PG-first-then-Redis.
|
|
||||||
11. **P-RECOVERY-001** accept — broker-independent bootstrap recovery; honest capability labeling; break-glass LOUD+AUDITED+TEMPORARY not silent permanent bypass.
|
|
||||||
12. **P-GUIDE-001** accept — delete `/compact and continue` from orchestrator path (keep for ephemeral); removal = substitution (wire rotation trigger).
|
|
||||||
13. **P-FLEET-001** accept — one roster-owned socket/host; quarantine unmanaged; stale-session GC; prerequisite for INBOX identity-addressing.
|
|
||||||
14. **P-WORKFLOW-001** accept — auto-sync ALLOWLIST not denylist; worktree/lease isolation for agent docs/source; DB-tracking obviates the flat-file-sweep criterion.
|
|
||||||
15. **P-CONFORMANCE-001** accept — fleet lifecycle harness on REAL runtime artifacts + fault injection; the 100-rotations-lossless bar is a test; target the DB substrate.
|
|
||||||
|
|
||||||
## Fleet operating model
|
|
||||||
|
|
||||||
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
|
|
||||||
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
|
|
||||||
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
|
|
||||||
- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed
|
|
||||||
before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer.
|
|
||||||
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
|
|
||||||
@@ -1,894 +0,0 @@
|
|||||||
# Remediation Backlog — Reconciled Execution Plan
|
|
||||||
|
|
||||||
**Owner:** `mos-remediation` (sole writer). Workers read; they never modify this file.
|
|
||||||
**Sources:** [`DECOMP-OPUS.md`](./DECOMP-OPUS.md) (robustness, 38 tasks / 8 dissents) and
|
|
||||||
[`DECOMP-SOL.md`](./DECOMP-SOL.md) (pragmatic, 25 tasks / 10 defers / 7 dissents), produced
|
|
||||||
**independently** — neither planner read the other. Charter: [`MISSION.md`](./MISSION.md).
|
|
||||||
**Status:** EXECUTING — all three blocking decisions RULED by Mos on 2026-07-31 (§5). **RM-01 is
|
|
||||||
dispatched.** RM-03 is held pending Jason's disposition of PR #1023; nothing else is blocked.
|
|
||||||
|
|
||||||
> **Provenance of the inputs (both clean).** `planner-opus` ran in a fresh session throughout.
|
|
||||||
> `planner-sol` initially began work at 64.3% dirty context despite a brief instructing it to reset;
|
|
||||||
> that run was **interrupted and discarded before it produced any output**, the seat was reset
|
|
||||||
> out-of-band to 0.0%, and the brief was re-dispatched. `DECOMP-SOL.md` is the product of the clean
|
|
||||||
> run only (it peaked at ~26% context). Both decompositions are therefore clean-context artifacts and
|
|
||||||
> are weighted equally here. The discarded dirty run is banked as dogfood seed D-4 and as task RM-58 —
|
|
||||||
> the failure it demonstrates is that _asking_ an agent to reset is not enforcement.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What the two planners agreed on without collusion
|
|
||||||
|
|
||||||
Independent convergence is the strongest signal available here, because neither planner could see the
|
|
||||||
other's file. Where both arrived at the same conclusion from opposite biases, I treat it as settled.
|
|
||||||
|
|
||||||
| # | Convergent finding | OPUS | SOL |
|
|
||||||
| --- | --------------------------------------------------------------------------------------------------------------------- | ------------------- | -------------- |
|
|
||||||
| C1 | **The charter's wire-in point is wrong.** Do NOT wire the choke point into `mosaic_orchestrator.py::run_single_task`. | D2 (headline) | Dissent 1 |
|
|
||||||
| C2 | P0 hygiene/gate work must precede the spine, not follow it. | D1, phase P0 | G0, SOL-01/02 |
|
|
||||||
| C3 | No new deployable microservice; the executor is a library + the coord daemon. | implicit throughout | Dissent 3 |
|
|
||||||
| C4 | Comms adapters beyond tmux are out of scope for this mission. | D7 | DEFER 4/5/6 |
|
|
||||||
| C5 | The "100 rotations lossless" bar is a late conformance gate, not an early tax. | D4 | Dissent 7 |
|
|
||||||
| C6 | Redis is a derived hot path, never an authority; PG commits first. | R-013, R-054 | SOL-11, SOL-21 |
|
|
||||||
| C7 | Reuse `packages/coord`; do NOT revive the untracked `apps/coordinator` residue. | R-042 | SOL-15 AC5 |
|
|
||||||
|
|
||||||
**C1 is the single most consequential output of this exercise.** The charter (`MISSION.md`) and my
|
|
||||||
kickoff instruction both name `mosaic_orchestrator.py::run_single_task:126-276` as the integration
|
|
||||||
point. Both planners independently rejected it on the same evidence: that controller is
|
|
||||||
`"enabled": false` (`.mosaic/orchestrator/config.json:2`) and references a dispatcher path
|
|
||||||
(`tools/macp/dispatcher/pi_runner.ts`) that does not exist in this checkout. Wiring the new choke
|
|
||||||
point into a disabled rail produces **a stranded executor — the identical built-but-unwired disease,
|
|
||||||
one layer up, that would look "done" in a PR.** The live paths are
|
|
||||||
`packages/mosaic/src/commands/launch.ts` and `packages/coord/src/runner.ts`.
|
|
||||||
This contradicted the charter and was escalated as DECISION-1 — **now RULED in the planners' favour by
|
|
||||||
Mos (§5)**. The corrected target is a new production Node `TaskExecutor` on the live dispatch path
|
|
||||||
(`packages/mosaic` launch + `packages/coord`) that Coord/Forge/live dispatch submit through; the
|
|
||||||
Python rail is deleted, not ported.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1a. ★ KEYSTONE DOGFOOD CASE — an inert gate that erased its own evidence
|
|
||||||
|
|
||||||
**A merged commit shipped past `pnpm format:check` — and then the evidence quietly erased itself.**
|
|
||||||
|
|
||||||
Verified chain (blob-level, under the repo's own prettier config, at the file's real path):
|
|
||||||
|
|
||||||
| commit | state of `packages/mosaic/framework/tools/orchestrator/README.md` |
|
|
||||||
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
||||||
| `b79336a8` — **merged PR #868** | blob `3ee7f104` — **FAILS** `pnpm format:check` |
|
|
||||||
| `48fd1df2` — merged PR #872 (unrelated: ci-queue-wait 404 handling) | blob `3d3bb132` — passes; incidentally reformatted by that PR's `lint-staged` |
|
|
||||||
| current `origin/main` (`06e0d403`) | passes — **the gate now looks green** |
|
|
||||||
|
|
||||||
So: PR #868 merged a file that fails a required gate ⇒ **the CI format gate did not block it.** The
|
|
||||||
gate was inert for that merge. Then an unrelated later PR's pre-commit hook reformatted the file as a
|
|
||||||
side effect, so `main` went green again **without anyone ever learning the gate had failed to fire.**
|
|
||||||
|
|
||||||
> **Correction on record:** my first report to Mos said "format:check is RED on main _now_." That was
|
|
||||||
> true of the `main` my checkout was pinned to (`b79336a8`) and is **no longer true of current `main`**,
|
|
||||||
> which advanced mid-session. The inert-gate finding itself is unchanged and verified; only its
|
|
||||||
> present-tense framing was wrong. The hygiene PR therefore carries the `.prettierignore` fix only —
|
|
||||||
> the README needs no fix today.
|
|
||||||
|
|
||||||
### Third live instance, same class — the queue guard, hit by this orchestrator
|
|
||||||
|
|
||||||
Running the **mandated** pre-push guard during TASK-0:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
|
||||||
[ci-queue-wait] platform=gitea purpose=push branch=main sha=06e0d403…
|
|
||||||
[ci-queue-wait] state=unknown purpose=push branch=main
|
|
||||||
$ echo $? → 0
|
|
||||||
```
|
|
||||||
|
|
||||||
**Two distinct defects in one tool**, both feeding RM-03:
|
|
||||||
|
|
||||||
1. **Wrong exit** — `state=unknown` ⇒ `exit 0`. The defect at `ci-queue-wait.sh:282-288` that OPUS
|
|
||||||
documented and that PR #1023 is parked on. A required gate returned PASS on an indeterminate result.
|
|
||||||
2. **Wrong branch** — it evaluated `branch=main`, not the branch actually being pushed. Even a
|
|
||||||
correctly-exiting guard would have been answering the wrong question.
|
|
||||||
|
|
||||||
**Standing doctrine (Mos):** until RM-03 lands, a green from this guard carries **zero information**
|
|
||||||
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 —
|
|
||||||
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
|
|
||||||
|
|
||||||
On PR #1025 the author (me) pre-registered AC2 with the fixture snippet `mkdir -p apps/*/venv/lib`.
|
|
||||||
In bash, when no `venv` exists the glob is unmatched and passes through literally, creating a
|
|
||||||
directory named `apps/*/venv/lib` rather than one per workspace. The check as written did not test
|
|
||||||
what it claimed to test.
|
|
||||||
|
|
||||||
`rev-974` ran it **exactly as written**, observed the wrong behaviour, then re-ran the intended
|
|
||||||
assertion at an explicit path — **and said so in the review** rather than silently substituting a
|
|
||||||
working fixture and reporting PASS.
|
|
||||||
|
|
||||||
Two things this establishes:
|
|
||||||
|
|
||||||
1. **The instruction "do not adjust a check to fit the diff; if it is unrunnable, say so explicitly"
|
|
||||||
worked.** A silent substitution here would have produced a green AC2 that proved nothing, on the
|
|
||||||
exact task whose subject is gates that appear to work. The disclosure is what made the PASS
|
|
||||||
meaningful.
|
|
||||||
2. **Pre-registration does not confer correctness.** A pre-registered check is protected from being
|
|
||||||
retrofitted to the implementation; it is not protected from being _wrong when written_. This is a
|
|
||||||
small instance of the mission's own class — an unverified gate — occurring inside the mechanism
|
|
||||||
built to catch unverified gates.
|
|
||||||
|
|
||||||
**Requirement on RM-02 (non-negotiable, sharpened by Mos).** The registry must **self-verify** that
|
|
||||||
every registered case demonstrably **runs** and demonstrably **fails on a known-bad input**.
|
|
||||||
Presence in the registry is **not** evidence. **A check is not trusted until it has been shown to
|
|
||||||
fail.** This is mutation testing / negative control applied _at the registry level_ — meaning
|
|
||||||
**the conformance harness must itself be conformance-tested.** A registered case that cannot fail, or
|
|
||||||
cannot run, is exactly as inert as an unregistered one, and the registry check must detect that
|
|
||||||
itself rather than assume it.
|
|
||||||
|
|
||||||
**Requirement on RM-55.** The same recursion applies to the harness: it must be observed red before
|
|
||||||
its green is worth anything (OPUS R-063 AC1 already states this; D-8 is the empirical case for it).
|
|
||||||
|
|
||||||
**Second, equally load-bearing lesson — reviewer disclosure is what makes a review trustworthy.**
|
|
||||||
rev-974 could have silently swapped in a working fixture and reported `AC2 PASS`. Nothing in the
|
|
||||||
process would have caught it, and the resulting green would have certified nothing — on the very task
|
|
||||||
whose subject is gates that only appear to work. The brief's instruction — _"do not adjust a check to
|
|
||||||
fit the diff; if it is genuinely unrunnable as specified, say so explicitly and explain why rather
|
|
||||||
than silently substituting your own"_ — is therefore not boilerplate. It is the clause that makes a
|
|
||||||
PASS mean something, and it must appear in **every** reviewer brief this mission issues.
|
|
||||||
|
|
||||||
### D-7 — shared-tmpfs contention → cascading ENOSPC (live incident, 2026-07-31)
|
|
||||||
|
|
||||||
The shared 30 G `/tmp` hit **100% ENOSPC** mid-session. It broke tool calls in **two different seats**
|
|
||||||
(mine and Mos's) — a single full disk degrades every agent on the host at once. Recurring: prior
|
|
||||||
incidents 2026-06-18 and 2026-07-17.
|
|
||||||
|
|
||||||
Attribution matters, because the wrong owner cleans the wrong thing. Measured:
|
|
||||||
|
|
||||||
| path | size | last modified | owner |
|
|
||||||
| ---------------------------------------------- | --------- | ---------------------------- | ------------------------------------------------- |
|
|
||||||
| `…/-src-mosaic-stack/6d2faee6…` (this session) | **88 K** | live | mos-remediation |
|
|
||||||
| `…/-src-mosaic-stack/c743185d…` | **3.6 G** | **2026-07-22** (9 days dead) | abandoned session, same project path |
|
|
||||||
| `…/claude-1001/pnpm-store` | **1.6 G** | **2026-07-23** (8 days dead) | abandoned; the live store is correctly on `$HOME` |
|
|
||||||
|
|
||||||
So ~5.2 G — the bulk of the pressure — is **dead session scratch that nothing will ever read again**.
|
|
||||||
This is not a quota problem; it is **P-FLEET-001's stale-session GC, applied to disk instead of tmux
|
|
||||||
sessions.** The same missing capability (nothing owns reaping dead ephemeral state) produces both the
|
|
||||||
orphaned-session failure and this one. Reaping dead-session scratch belongs in RM-50 alongside stale
|
|
||||||
tmux-session GC.
|
|
||||||
|
|
||||||
**Added to RM-01 as acceptance criteria:** heavy build artifacts (node_modules, package stores, build
|
|
||||||
output) must land on the main disk in the worktree, never on the shared 30 G `/tmp`.
|
|
||||||
|
|
||||||
**Resolution, and the part that is actually the finding.** Mos verified the attribution independently
|
|
||||||
(mtimes, no process or `lsof` holding either path, no live session maps) and reaped both as lead
|
|
||||||
coordinator: `/tmp` went to 79%, 6.0 G free. But note _how_ it was resolved — **a human-authority seat
|
|
||||||
did it by hand, because the authority exists and the reaper does not.** That gap is the finding, not
|
|
||||||
the disk usage.
|
|
||||||
|
|
||||||
Two doctrine points fall out, both binding on RM-50:
|
|
||||||
|
|
||||||
1. **The fix is not "agents should tidy up."** Asking each seat to clean its own scratch is
|
|
||||||
`instructions are not enforcement` (D-4) wearing a different hat. A deterministic reaper must own
|
|
||||||
it — same conclusion the north star reaches for every other class in this mission.
|
|
||||||
2. **Refusing to unilaterally delete another session's scratch was correct, and the resolution is not
|
|
||||||
"be braver about deleting."** An agent guessing that someone else's state is garbage is exactly the
|
|
||||||
unreviewed destructive act the Constitution forbids. The resolution is that _ownership and liveness
|
|
||||||
become mechanically decidable_, so reaping is a determination rather than a judgement call.
|
|
||||||
|
|
||||||
**Reaper requirements for RM-50:** liveness determined mechanically (process/`lsof`/session-map, not
|
|
||||||
mtime alone); an age threshold; a dry-run that reports what it would reap and why; and an audit event
|
|
||||||
per reap. Never a heuristic sweep — that would reintroduce the P-WORKFLOW-001 auto-sync failure in a
|
|
||||||
more destructive form.
|
|
||||||
|
|
||||||
**The self-erasure is the important part.** An inert gate that is masked by unrelated downstream
|
|
||||||
commits produces no lasting artifact, which is precisely why this class survives for months. Detection
|
|
||||||
cannot rely on "is `main` currently red" — it must be per-merge-commit.
|
|
||||||
|
|
||||||
This matters more than the one-line fix:
|
|
||||||
|
|
||||||
- It is the **P-QUEUE-001 / P-CONFORMANCE-001 class** ("gate-6 was inert fleet-wide"), reproduced in
|
|
||||||
the repository this mission is remediating, discovered incidentally.
|
|
||||||
- It independently **validates OPUS premise A1** ("every gate is inert until proven otherwise") with
|
|
||||||
live evidence rather than argument — which is why RM-02 is adopted as the keystone (§2, X2).
|
|
||||||
- The file fix rides in its own hygiene PR. **The inert gate itself is NOT quiet-patched.** Per Mos:
|
|
||||||
it stays a first-class backlog item, because patching the symptom would destroy the signal.
|
|
||||||
|
|
||||||
**Binding requirement on RM-02 and RM-55:** the gate registry and the conformance harness must assert
|
|
||||||
**"every merged commit passed every required gate"** — evaluated **per merge commit, against that
|
|
||||||
commit's own tree**, not against current `main`. As the table above proves, a "is main green today"
|
|
||||||
check would have reported all-clear. A merged-commit-that-fails-a-required-gate is the exact detection
|
|
||||||
signal, and it must be a registered must-fail case. A gate that cannot prove it blocked something has
|
|
||||||
not been shown to work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Where they genuinely disagree (not averaged — adjudicated)
|
|
||||||
|
|
||||||
| # | Axis | OPUS | SOL | My ruling |
|
|
||||||
| --- | ------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| X1 | **Total cost** | 38 tasks, ~5.3M tok | 25 tasks, ~294K tok | **~18× apart.** Not reconcilable by splitting. They measure different things: SOL explicitly excludes orchestration/review/iteration overhead and assumes one remediation pass; OPUS prices the full loop. Adopt **SOL's scope** with **OPUS's rigor**, and treat SOL's G1 as a hard budget checkpoint (§4). Re-estimate empirically after the first three merged PRs rather than trusting either number. |
|
|
||||||
| X2 | **Gate registry (OPUS R-002)** | Keystone; blocks all P2 | Absent; only a queue-guard fix | **ADOPT OPUS.** Empirically validated in this very session: I found `pnpm format:check` red on `main` via merged PR #868 — a required gate that did not block. OPUS's premise A1 ("every gate is inert until proven otherwise") is not theoretical; it reproduced today, unprompted. Scope it tighter than 120K. |
|
|
||||||
| X3 | **Drizzle PG first-install defect (R-010)** | Hidden blocker; everything downstream depends on it | Not mentioned | **ADOPT OPUS.** `packages/db/src/migrate.ts:30-38` carries a TODO admitting postgres-tier first-install fails today. The spine has only ever been proven on PGlite. Every later migration silently depends on this. SOL missed it. |
|
|
||||||
| X4 | **Rollback artifact for the hard cutover** | D3: hard cutover needs a rehearsed rollback snapshot | SOL-07: import-only, explicitly no dual-write | Both obey "no flat-file interim." OPUS wants a one-directional snapshot nothing reads as authority. I read that as compatible with the directive, but it is Jason's call → **DECISION-2** (§5). |
|
|
||||||
| X5 | **Where the queue guard sits** | P0, independent of spine | SOL-02, also early | Agree it is P0. But ownership collides with **parked PR #1023** → **DECISION-3** (§5). |
|
|
||||||
| X6 | **Report-only rollout** | D5: only with a hard expiry, else withdraw | not raised | **ADOPT OPUS.** A report-only gate is by definition inert; expiry is the mechanism that stops it becoming the new fail-open. |
|
|
||||||
| X7 | **Availability trade (FC-7/FC-11)** | D8: "no DB ⇒ fleet stops" must be pre-committed in writing | not raised | Genuine availability regression, correctly identified. Needs Jason → folded into **DECISION-2**. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Reconciled DAG
|
|
||||||
|
|
||||||
Phases run in order; `⛔` marks a hard barrier. `src` shows lineage (`O`=opus, `S`=sol, `O+S`=both).
|
|
||||||
Estimates are given as a **range** (SOL low / OPUS high) rather than a fabricated midpoint — the
|
|
||||||
spread is itself information, and X1 says we calibrate on real merged PRs.
|
|
||||||
|
|
||||||
### P0 — Make gates provable, and stop the fleet re-bricking
|
|
||||||
|
|
||||||
⛔ _No gate-introducing task in any later phase may merge before RM-02._
|
|
||||||
|
|
||||||
| id | task | src | depends_on | est (S/O) | tier |
|
|
||||||
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------ | ---------------- | ------ |
|
|
||||||
| RM-01 | Reproducible non-root checkout; gate fails on **code, not env**; heavy artifacts OFF shared `/tmp` (banks D-1/D-2/D-5/D-7) | O+S+live | — | 6K / 60K | codex |
|
|
||||||
| RM-02 | **Gate registry + negative-control CI check** (anti-inert-gate harness) ★keystone | O | RM-01 | — / 120K | opus |
|
|
||||||
| RM-03 ⏸**HOLD** | Queue-guard: **two** defects — (a) `unknown`/`no-status`/malformed ⇒ ≠0, (b) guard evaluates `branch=main` instead of the branch being pushed | O+S+live | RM-02 | 8K / 100K | sonnet |
|
|
||||||
| RM-04 | Activation/version coherence; block launch on skew, fail SAFE; honest `doctor` labels | O+S | RM-01 | (in S-01) / 140K | sonnet |
|
|
||||||
| RM-05 | Break-glass replaces the three silent `MOSAIC BYPASS` fail-opens | O | RM-04, RM-02 | — / 120K | opus |
|
|
||||||
|
|
||||||
> ⚠ **RM-05 must not merge before RM-04.** The bypasses exist because the lease-broker daemon was
|
|
||||||
> never _deployed_ on this host — removing the fail-open before deployment coherence is real
|
|
||||||
> re-creates the 2026-07-22 bricking incident. Hard edge, from OPUS.
|
|
||||||
|
|
||||||
### P1 — Durable spine (PG)
|
|
||||||
|
|
||||||
⛔ _No migration may merge before RM-10._
|
|
||||||
|
|
||||||
| id | task | src | depends_on | est (S/O) | tier |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------------- | --- | ---------- | ---------- | ------ |
|
|
||||||
| RM-10 | **Fix the Drizzle postgres-tier first-install defect** ★hidden blocker | O | RM-01 | — / 90K | sonnet |
|
|
||||||
| RM-11 | Orchestration spine schema (tasks, attempts, gate_results, hash-chained ledger, typed claims) | O+S | RM-10 | 12K / 160K | opus |
|
|
||||||
| RM-12 | Spine client, fail-closed connection (no silent PGlite in prod) | O | RM-11 | — / 80K | sonnet |
|
|
||||||
| RM-13 | Atomic claims/transitions + transactional outbox + reconciliation sweeper | O+S | RM-12 | 12K / 140K | opus |
|
|
||||||
|
|
||||||
### P2 — The single choke point
|
|
||||||
|
|
||||||
⛔ _RM-25 (no-second-path) lands in the same milestone as RM-20, or the choke point is optional._
|
|
||||||
|
|
||||||
| id | task | src | depends_on | est (S/O) | tier |
|
|
||||||
| ----- | ---------------------------------------------------------------------------------------------- | --- | ------------------- | ---------------- | ------ |
|
|
||||||
| RM-20 | Canonical MACP contract completion (Task/Result/Event/Claim/tri-state outcome) | S | — | 8K / (in R-020) | codex |
|
|
||||||
| RM-21 | **Production `TaskExecutor`** backed by `@mosaicstack/macp` ★keystone | O+S | RM-12, RM-02, RM-20 | 16K / 220K | opus |
|
|
||||||
| RM-22 | Gate-runner hardening: `fail_on`, timeouts, **empty gate set = failure** | O | RM-21 | — / 120K | sonnet |
|
|
||||||
| RM-23 | Hash-chained MACPEvent ledger in PG + lifecycle EventType extension | O+S | RM-21, RM-11 | — / 160K | opus |
|
|
||||||
| RM-24 | Seat identity from `MOSAIC_AGENT_NAME` + **mandatory** tri-state write outcomes | O+S | RM-21 | (in S-03) / 150K | opus |
|
|
||||||
| RM-25 | **No-second-path gate:** terminal status writable only by the executor | O | RM-21, RM-23 | — / 140K | opus |
|
|
||||||
| RM-26 | `packages/coord` submits through the executor (retire direct spawn) | O+S | RM-21 | 16K / 140K | sonnet |
|
|
||||||
| RM-27 | `mosaic yolo/claude/codex/pi` launch path records typed Task + events | O | RM-21, RM-23 | — / 160K | sonnet |
|
|
||||||
| RM-28 | Delete the Forge stub executor (empty-gate-list "success"); Forge submits through the real one | O+S | RM-21 | 10K / 90K | codex |
|
|
||||||
| RM-29 | One-shot flat-file import + cutover readiness audit (dry-run, idempotent, no dual-write) | S | RM-13 | 8K / (in R-062) | codex |
|
|
||||||
|
|
||||||
> **★ G1 — FIRST DOGFOOD. Stop here and prove it.** One live fleet task travels
|
|
||||||
> PG claim → TaskExecutor → worker → gates → terminal PG result/event, with **no** flat-file state.
|
|
||||||
> Adopted from SOL wholesale. If G1 cannot carry a real task, **do not build Redis, rotation, comms,
|
|
||||||
> or conformance** — remediate instead. This is the budget escape hatch (§4).
|
|
||||||
|
|
||||||
### P3 — Rotation lifecycle (finish the Mission Control Plane)
|
|
||||||
|
|
||||||
| id | task | src | depends_on | est (S/O) | tier |
|
|
||||||
| ----- | -------------------------------------------------------------------------------------------- | --- | ------------ | ---------------- | ------ |
|
|
||||||
| RM-30 | Typed state claims (source/confidence/TTL) with HMAC integrity, fail-closed | O+S | RM-11, RM-21 | (in S-03) / 170K | opus |
|
|
||||||
| RM-31 | Contract-hash binding; stale generation loses mutation authority **mechanically** | O+S | RM-21, RM-30 | 12K / 180K | opus |
|
|
||||||
| RM-32 | Durable compaction/token sensor (per-runtime thresholds, PreCompact event) | O | RM-23, RM-31 | — / 130K | sonnet |
|
|
||||||
| RM-33 | Typed checkpoint writer (structured claims, never transcript) + digest | O+S | RM-30, RM-32 | 12K / 150K | opus |
|
|
||||||
| RM-34 | **Rotation daemon:** watch → checkpoint → revoke → kill → relaunch → rehydrate | O+S | RM-33, RM-26 | 16K / 240K | opus |
|
|
||||||
| RM-35 | Rehydration attestation gate: refuse to act on an incomplete claim set | O | RM-33 | — / 130K | opus |
|
|
||||||
| RM-36 | Broker-independent recovery; remove silent bypass; honest capability labels | S | RM-34 | 12K / (in R-004) | sonnet |
|
|
||||||
| RM-37 | Delete `/compact and continue` from the persistent-seat path (**substitution**, not removal) | O+S | RM-34, RM-44 | (in S-16) / 60K | codex |
|
|
||||||
|
|
||||||
### P4 — Comms service
|
|
||||||
|
|
||||||
⛔ _RM-50 (one roster-owned socket per host) precedes identity-addressed delivery._
|
|
||||||
|
|
||||||
| id | task | src | depends_on | est (S/O) | tier |
|
|
||||||
| ----- | ---------------------------------------------------------------------------- | --- | ------------ | ---------------- | ------ |
|
|
||||||
| RM-40 | `comms/v1` envelope + protocol-version negotiation, LOUD reject | O+S | RM-11, RM-31 | 8K / 140K | opus |
|
|
||||||
| RM-41 | Comms service: PG state machine PENDING→RECEIVED→CONSUMED→DEAD-LETTER | O+S | RM-40, RM-13 | 16K / 200K | opus |
|
|
||||||
| RM-42 | tmux transport as a **dumb adapter**; durable retry before cursor advance | O+S | RM-41, RM-50 | (in S-19) / 160K | sonnet |
|
|
||||||
| RM-43 | Per-class coalescing + supersede (the stale-consumed-as-live fix) | O+S | RM-41 | 12K / 130K | sonnet |
|
|
||||||
| RM-44 | Redis Streams hot delivery + provenance guard (**Redis is never authority**) | O+S | RM-41, RM-13 | 12K / 170K | opus |
|
|
||||||
| RM-45 | Retire direct tmux sends; only the service may write a pane | O+S | RM-42, RM-43 | (in S-20) / 100K | codex |
|
|
||||||
|
|
||||||
### P5 — Retirements, hygiene, conformance
|
|
||||||
|
|
||||||
| 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-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-53 | Flat-file orchestration → DB hard cutover, with rehearsed rollback artifact | O+S | RM-27, RM-30, RM-34, RM-29 | (in S-10) / 200K | opus |
|
|
||||||
| RM-54 | Fleet-wide inert-gate audit against the RM-02 registry | O | RM-02 | — / 120K | sonnet |
|
|
||||||
| 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-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 |
|
|
||||||
|
|
||||||
**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`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Execution discipline
|
|
||||||
|
|
||||||
- **Every row is one PR.** Author ≠ reviewer; `rev-974` is the mosaicstack reviewer identity.
|
|
||||||
- **Pre-registered, diff-blind acceptance checks are committed BEFORE the reviewer reads the diff.**
|
|
||||||
Both decomps wrote their ACs in runnable `⇒0` / `⇒≠0` form specifically to make this possible.
|
|
||||||
- **Every gate-introducing task carries at least one registered must-fail negative control.** This is
|
|
||||||
RM-02's whole purpose; a gate with no proven failure path manufactures evidence.
|
|
||||||
- **Cost tiers:** codex for mechanical/unambiguous, sonnet for normal feature work, opus reserved for
|
|
||||||
security/integrity/cross-cutting-invariant tasks. SOL priced 0 opus tokens; OPUS priced 14 opus
|
|
||||||
tasks. I am keeping opus only where the failure is _integrity_, not merely complexity.
|
|
||||||
- **G1 is the budget checkpoint.** If the first-dogfood slice overruns SOL's estimate by >3×, stop and
|
|
||||||
re-plan rather than spending the remainder. X1 says neither estimate is trustworthy until calibrated.
|
|
||||||
- **Defer list adopted from SOL** (10 items): mission dashboard/TUI, PRD-to-board auto-decomposition,
|
|
||||||
heuristic churn scoring, Discord/Slack/Telegram adapters, public MCP comms surface, protocol-v2
|
|
||||||
negotiation, multi-region PG/Redis, event analytics UI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Decisions — all three ruled by Mos, 2026-07-31
|
|
||||||
|
|
||||||
**DECISION-1 — the wire-in point. ✅ RULED: accept the planners (Mos, 2026-07-31).**
|
|
||||||
The charter's `mosaic_orchestrator.py::run_single_task` target is the **disabled Python controller
|
|
||||||
this mission retires**; wiring the new choke point into the rail we are deleting is wrong.
|
|
||||||
|
|
||||||
> **Corrected target (authoritative):** a **new production Node `TaskExecutor`** sitting on the
|
|
||||||
> **live dispatch path** — `packages/mosaic` launch + `packages/coord` — which Coord, Forge, and live
|
|
||||||
> dispatch all **submit through**. This is the MACP scout's _full_ recommendation ("replace the block
|
|
||||||
> **with** a Node executor **and** make Coord/Forge submit through it"), not a resurrection of the
|
|
||||||
> Python controller. RM-52 is therefore a **deletion** task, and Build 1's acceptance is measured on a
|
|
||||||
> live `mosaic yolo` invocation.
|
|
||||||
|
|
||||||
Mos ruled this resolvable from the already-accepted retire-the-Python-rail decision — his authority,
|
|
||||||
not a Jason escalation. RM-21/RM-26/RM-27/RM-52 all take the corrected target.
|
|
||||||
|
|
||||||
**DECISION-2 — rollback artifact + availability trade. ⏸ JASON-PENDING — NOT BLOCKING.**
|
|
||||||
The DB build is phases away, so this is queued for Jason's next session rather than escalated now.
|
|
||||||
**Binding requirement in the meantime (Mos, from P-RECOVERY-001):** the DB spine **must NOT be a
|
|
||||||
single-point hard-stop.** Design for a broker-independent / degraded mode **plus** a rollback
|
|
||||||
artifact. Jason finalises only the specific availability target. This reverses my earlier reading of
|
|
||||||
OPUS D8 ("the fallback is: the fleet stops") — that answer is **not** pre-committed; a degraded mode
|
|
||||||
is now a design requirement on RM-12, RM-13, RM-23, RM-36 and RM-53.
|
|
||||||
|
|
||||||
**DECISION-3 — RM-03 vs. parked PR #1023. ✅ RULED: HOLD RM-03 (Mos, 2026-07-31).**
|
|
||||||
Do **not** open a third gate-6 lane — that is the postmortem's own anti-pattern performed by the
|
|
||||||
remediation. PR #1023 sits in Jason's **parked delivery stack**; its disposition (close, or supersede
|
|
||||||
by RM-03) is Jason's at his next session.
|
|
||||||
|
|
||||||
- **PR #1023 → `SUPERSEDED-PENDING-JASON`.** RM-03 stays `HOLD`; when Jason rules, RM-03 proceeds as
|
|
||||||
the single correct lane.
|
|
||||||
- **RM-02 and RM-55 proceed independently and are NOT held.** The per-merge-commit gate-assertion
|
|
||||||
requirement is the _conformance_ capability, not the gate-6 fix itself — different scope, no
|
|
||||||
ownership collision.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Status
|
|
||||||
|
|
||||||
| phase | state |
|
|
||||||
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| Decomposition | DONE — both planners delivered independently |
|
|
||||||
| Reconciliation | DONE — this document |
|
|
||||||
| Blocking decisions | **RULED** — all 3 closed by Mos 2026-07-31 (§5); D-2's availability target is Jason-pending but non-blocking |
|
|
||||||
| Dispatch | **RM-01 IN FLIGHT** — f10-coder (codex), worktree-isolated, AC1–AC8 pre-registered |
|
|
||||||
| Review | PR #1025 with rev-974; ACs pre-registered 22:12:26Z before diff exposure |
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# #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.
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# RM-61 — CI contract exemption for #1000 teardown artifact
|
|
||||||
|
|
||||||
**Tracking:** RM-61 / issue #1000
|
|
||||||
|
|
||||||
**Branch:** `fix/rm-61-ci-contract-exemption`
|
|
||||||
**Owner:** `coder-mos1`
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Determine, by red-first provider controls, whether the `ci-postgres` pod-not-found teardown signature discriminates from a real PostgreSQL failure. Only if it discriminates may a named, bounded CI-contract exemption be implemented. The exemption must retire when #1000 is fixed; fixing #1000 is the closure path.
|
|
||||||
|
|
||||||
## Pre-registered kill criterion
|
|
||||||
|
|
||||||
If an injected real `ci-postgres` failure also yields `pods "wp-svc-<ULID>-ci-postgres" not found` as the service's provider-visible failure, the signature does not discriminate. Option B is unsafe; stop exemption implementation and fall to Option A (#1000).
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Capture full `-f json` records for the 11 supplied observations and state counts.
|
|
||||||
2. Run one startup-failure control using the real pgvector/PostgreSQL image with an invalid `initdb` argument.
|
|
||||||
3. Run one post-readiness crash control using real PostgreSQL, `pg_isready`, and a deliberate postmaster kill while a DB-dependent probe is active.
|
|
||||||
4. Compare the raw `ci-postgres` service record independently of failures in dependent steps.
|
|
||||||
5. Investigate runner/time/head clustering only as a hypothesis; never encode incidental correlates or retries into policy.
|
|
||||||
6. If and only if the controls discriminate, implement and test the exact exemption, document its two-way boundary, and track retirement at #1000.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
No explicit token cap supplied. Working estimate: 20K–30K tokens. Limit provider controls to the two pre-registered runs; no retries or re-roll policy.
|
|
||||||
|
|
||||||
## Initial evidence
|
|
||||||
|
|
||||||
Historical JSON saved locally under `.evidence/rm-61/` (not for commit). Supplied pipelines: 11 total. Child-step counts: five pipelines with 9 children and six with 10 children. Seven contain the `ci-postgres` pod-not-found failure (#2170, #2175, #2180, #2181, #2182, #2187, #2188); four do not (#2158, #2167, #2184, #2186). Every observed workflow reports `agent_id=44`, so the available JSON does not separate clean and artifact runs by runner. This refutes runner identity as a discriminator in the sampled record.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- [x] Requirements and kill criterion recorded before control implementation.
|
|
||||||
- [x] Historical full-JSON records captured.
|
|
||||||
- [x] Startup-failure control observed terminal.
|
|
||||||
- [x] Post-readiness crash control observed terminal.
|
|
||||||
- [x] Discrimination verdict recorded: Option B may proceed.
|
|
||||||
- [x] Conditional exemption implementation.
|
|
||||||
|
|
||||||
## Tests / evidence
|
|
||||||
|
|
||||||
### Control 1 — real startup failure
|
|
||||||
|
|
||||||
- Commit: `3931b0e29eb834914f7b17e4db7e221481d436fa`
|
|
||||||
- Pipeline: #2189, exact commit match.
|
|
||||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
|
||||||
- `ci-postgres`: `state=failure`, `exit_code=1`, `error=null`, with a five-second execution window.
|
|
||||||
- `test`: `state=failure`, `exit_code=1` after the readiness budget expired.
|
|
||||||
- Pipeline/workflow: terminal `failure`.
|
|
||||||
|
|
||||||
This control is red and its service record differs from #1000 (`exit_code=0` plus pod-not-found). It proves the startup-failure direction only. It does not settle the dangerous post-readiness crash/garbage-collection path.
|
|
||||||
|
|
||||||
### Control 2 — real post-readiness crash
|
|
||||||
|
|
||||||
- Commit: `25ac59715a94dd1b52ef42577472eb44ecc4b446`
|
|
||||||
- Pipeline: #2191, exact commit match.
|
|
||||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
|
||||||
- Service log proves PostgreSQL reached `database system is ready to accept connections`, the test created the arm table, and the service then killed postmaster PID 7.
|
|
||||||
- Test log proves a successful `SELECT 1` followed by `Connection refused`; it exited the pre-registered control code 61.
|
|
||||||
- `ci-postgres`: `state=failure`, `exit_code=137`, `error=null`, with a 203-second execution window.
|
|
||||||
- `test`: `state=failure`, `exit_code=61`.
|
|
||||||
- Pipeline/workflow: terminal `failure`.
|
|
||||||
|
|
||||||
This is the dangerous post-readiness crash path. Its service record is not pod-not-found and therefore differs from #1000 independently of the dependent test failure.
|
|
||||||
|
|
||||||
### Discrimination verdict
|
|
||||||
|
|
||||||
Both real failures are provider-visible as process exits (`exit_code=1` startup; `exit_code=137` crash) with no pod-not-found error. The seven observed #1000 artifacts are provider reconciliation misses (`exit_code=0` plus the exact pod-not-found error). The declared kill criterion did not fire, so Option B may proceed with a matcher requiring the full conjunction. This evidence does **not** prove every future Kubernetes failure is distinguishable; it proves these two concrete real-failure classes remain blocking and bounds the exemption to the observed reconciliation shape.
|
|
||||||
|
|
||||||
### Unit red-first checkpoint
|
|
||||||
|
|
||||||
The nine-case contract harness was written before the verifier. First execution exited 1 because `verify-terminal-green.py` did not exist; no exemption implementation was live. Cases pre-register ordinary green, the exact artifact, both provider controls, near-miss signatures, an independent failure, and a skipped step.
|
|
||||||
|
|
||||||
### Control 2 setup attempt — invalid, excluded from evidence
|
|
||||||
|
|
||||||
- Commit: `9455cd6a2650b2b7e70f746c07933d96e5cb3d20`
|
|
||||||
- Pipeline: #2190, exact commit match.
|
|
||||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
|
||||||
- Service log: `/bin/sh: 0: -c requires an argument`.
|
|
||||||
- Root cause: Woodpecker service `commands` did not become the third `sh -c` argument. PostgreSQL never started, so this run is **not** the post-readiness crash control and provides no discrimination evidence.
|
|
||||||
- Focused remediation: place the script directly in the third `entrypoint` element and supply `PGPASSWORD` for the marker query. This is a control-fixture correction, not a retry of #1000 and not evidence for either verdict.
|
|
||||||
|
|
||||||
## Implementation evidence
|
|
||||||
|
|
||||||
- `verify-terminal-green.py` consumes only the full JSON/API record; it performs no fetch, retry, or trigger.
|
|
||||||
- Exact #2188 record: exit 0, 10 children, 9 success + 1 named exemption.
|
|
||||||
- Historical set: #2158/#2167/#2184/#2186 pass with no exemption; #2170/#2175/#2182/#2187/#2188 pass with one named exemption; #2180/#2181 remain red because independent failures exist.
|
|
||||||
- Provider controls: #2189 and #2191 both exit 1 under the verifier; neither is exempted.
|
|
||||||
- Unit harness: initial 9/9 cases passed after the red-first checkpoint; review remediation expands this to 12 cases with expected-head match/missing/mismatch coverage.
|
|
||||||
- Test-membership guard: PASS, population 45; 26 enumerated, 19 signed exclusions; all 39 surface paths present.
|
|
||||||
- Python compile: PASS.
|
|
||||||
- `pnpm typecheck`: PASS, 45/45 tasks.
|
|
||||||
- `pnpm lint`: PASS, 25/25 tasks.
|
|
||||||
- `pnpm format:check`: PASS after moving local evidence outside the repository tree.
|
|
||||||
- `test:framework-shell`: RM-61 and all preceding suites passed, then the pre-existing wake assertion aborted with exit 97 because this host's Bash 5.2.15 reports `BASH_LINENO [3 5]` where that suite requires `[3 4]`. RM-61 does not modify the wake suite; the command is not fully runnable on this host as written and no substitute result is claimed.
|
|
||||||
|
|
||||||
## Independent review
|
|
||||||
|
|
||||||
- Review 67 / comment 20403 at exact head `e7b29219e11efd0a19395156ac0b154bec0c3a73`: **REQUEST CHANGES**.
|
|
||||||
- Blocker: the verifier echoed the pipeline commit but did not bind it to the current PR head; mutating only #2188's commit still returned terminal-green.
|
|
||||||
- Remediation: require `--expect-commit <full-40>`, add a pipeline anomaly on missing/mismatched record commits, emit expected and observed values, wire both CI documentation and the merge-gate baseline to pass provider PR head, and add match/missing/mismatch tests.
|
|
||||||
- This binding is not prohibited head-based clustering policy: it proves the evidence belongs to the commit under verdict. Runner/node/time/head correlation remains excluded from the teardown signature itself.
|
|
||||||
- Review 69 later approved the commit-binding remediation at exact head `033b2ffb46674b2c0bcc5197273c109b461f62d9`; pipeline #2193 was 9/9 success. Before merge-gate, an independent adjudicator found that Python treats JSON `false == 0`, allowing a non-integer exit value to match. The prior gate-ready state was withdrawn. The type-strict set distinguishes genuine red-first controls (`false`, `0.0`, which wrongly exempted) from regression guards (`true`, `"0"`, `null`, which already blocked). Remediation requires the decoded type to be exactly `int` and excludes `bool` explicitly.
|
|
||||||
|
|
||||||
## Documentation checklist
|
|
||||||
|
|
||||||
- [x] CI contract documented in the canonical framework CI/CD guide.
|
|
||||||
- [x] Operator command documented in the Woodpecker tool README.
|
|
||||||
- [x] Merge-gate baseline points to the deterministic verifier and named retirement.
|
|
||||||
- [x] Tracking and retirement cite issue #1000.
|
|
||||||
- [x] Both positive and negative guarantee boundaries are stated.
|
|
||||||
- [x] No API/auth/schema/user-facing navigation change; OpenAPI, user guide, and sitemap are not applicable.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
The controls establish discrimination for deterministic startup failure and an armed post-readiness postmaster crash on the current Woodpecker Kubernetes provider. They cannot prove that every future Kubernetes failure mode will preserve a non-zero exit before reconciliation. The exact matcher minimizes that residual risk, and issue #1000 remains the mandatory provider-seam closure and retirement trigger.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# #1019 — Zero-timeout queue-guard harness race
|
|
||||||
|
|
||||||
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
|
||||||
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
|
||||||
- **Owner:** `be-coder-08`
|
|
||||||
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
|
||||||
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
|
||||||
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
|
||||||
2. Every case that intends status classification positively proves provider observation.
|
|
||||||
3. Pending observes `pending` before deterministic virtual-time expiration.
|
|
||||||
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
|
||||||
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
|
||||||
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
|
||||||
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
|
||||||
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
|
||||||
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
|
||||||
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
|
||||||
|
|
||||||
## Evidence
|
|
||||||
|
|
||||||
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
|
||||||
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
|
||||||
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
|
||||||
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
|
||||||
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
|
||||||
|
|
||||||
## Review remediation — semantic timeout vs. liveness bound
|
|
||||||
|
|
||||||
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
|
||||||
|
|
||||||
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
|
||||||
|
|
||||||
Remediation:
|
|
||||||
|
|
||||||
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
|
||||||
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
|
||||||
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
|
||||||
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
|
||||||
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
|
||||||
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
|
||||||
|
|
||||||
Post-review evidence:
|
|
||||||
|
|
||||||
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
|
||||||
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
|
||||||
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
|
||||||
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
|
||||||
|
|
||||||
## 60% context hold
|
|
||||||
|
|
||||||
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
# #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.
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# #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.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# #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.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# RM-01 — Reproducible checkout
|
|
||||||
|
|
||||||
- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking)
|
|
||||||
- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats.
|
|
||||||
- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation.
|
|
||||||
- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge.
|
|
||||||
- Acceptance: AC1–AC8 from the orchestrator dispatch/addendum.
|
|
||||||
- Plan:
|
|
||||||
1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation.
|
|
||||||
2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation.
|
|
||||||
3. Run focused tests, install/build/baseline gates, and explicit AC negative controls.
|
|
||||||
4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`.
|
|
||||||
- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup.
|
|
||||||
- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification.
|
|
||||||
|
|
||||||
## Progress / evidence
|
|
||||||
|
|
||||||
- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`.
|
|
||||||
- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`.
|
|
||||||
- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness.
|
|
||||||
|
|
||||||
## Checkpoint evidence (c45e5e19)
|
|
||||||
|
|
||||||
- AC1 IN PROGRESS: non-root `pnpm install --frozen-lockfile --store-dir "$HOME/.local/share/pnpm/store"` exited 0; `pnpm exec turbo run typecheck --force` exited 0 (45/45 uncached). Clean CI-container run not performed.
|
|
||||||
- AC2 DONE: with `node_modules` absent, `pnpm preflight` exited 42 with `MOSAIC_PREFLIGHT_MISSING_DEPS` and `run pnpm install`; after install it exited 0.
|
|
||||||
- AC3 DONE: appending `export const x: number = "s"` to `packages/types/src/index.ts` made `pnpm -w typecheck` exit 2 with TS2322; reverting made it exit 0.
|
|
||||||
- AC4 IN PROGRESS: local `pnpm -w build` exited 0 and `git status --porcelain` showed no generated residue beyond the intended RM-01 source changes. Fresh-clone proof not performed.
|
|
||||||
- AC5 DONE: non-root install exited 0; `pnpm store path` resolved `/home/hermes/.local/share/pnpm/store/v10`; no `/root` write was attempted.
|
|
||||||
- AC6 IN PROGRESS: focused failure/rollback tests passed, but final review found a concurrent-install race. Two installers can both observe `.husky/_` absent; after one installs successfully, the losing install's catch path can quarantine the winner's active hooks and restore stale Git config (`scripts/install-hooks.mjs`, activation/catch transaction). A RED regression is committed after the checkpoint.
|
|
||||||
- AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially.
|
|
||||||
- AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree.
|
|
||||||
|
|
||||||
## Continuation evidence
|
|
||||||
|
|
||||||
- AC6 DONE: the committed race reproducer was observed RED (`node --test --test-name-pattern='a competing successful installer is not removed by the losing process' scripts/install-hooks.test.mjs`, exit 1/ENOENT), then passed after cleanup became ownership-safe. The losing installer never removes an active hook set or restores Git configuration it did not activate. `pnpm test:checkout` passes 21/21, exit 0, including the original race and a post-rename peer-replacement regression.
|
|
||||||
- Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs.
|
|
||||||
- Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result.
|
|
||||||
- Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974.
|
|
||||||
- AC1 DONE at `0f706119`: a clean clone created inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest` ran the exact acceptance sequence `pnpm install --frozen-lockfile && pnpm -w typecheck`; exit 0 with 45/45 uncached typecheck tasks successful. An earlier bind-mounted clone attempt exited 1 because root in the container rejected the host-owned Git directory; that failed attempt is not counted as evidence.
|
|
||||||
- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0.
|
|
||||||
- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`.
|
|
||||||
|
|
||||||
## Review remediation — restated AC2
|
|
||||||
|
|
||||||
- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation.
|
|
||||||
- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest.
|
|
||||||
- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted.
|
|
||||||
- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0.
|
|
||||||
- Threat-model ruling: the manifest detects accidental, independent, stale, and foreign-residue mutation—the class exposed by the five-month-stale `.next` that produced 19 phantom TS2307 errors. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. RM-59 tracks the residual: executor/spine-side attestation outside worktree authority, dependent on RM-12, RM-21, and RM-25.
|
|
||||||
- AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence.
|
|
||||||
|
|
||||||
## Handoff
|
|
||||||
|
|
||||||
1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation.
|
|
||||||
2. The latest automated review rejected oldest-generated-file mtime as a false positive for valid incremental Next output. Use a source-content fingerprint marker written only after successful `next build`; do not continue tuning mtimes.
|
|
||||||
3. For Husky, generation in an isolated temporary Git repo avoids mutating real `core.hooksPath` during staging. Preserve that design. Fix the losing concurrent process so it never removes a peer's completed hook set or restores stale config.
|
|
||||||
4. Codex review runs in a read-only sandbox, so its attempts to run the fixture-writing Node tests report opaque test-file failures. The same tests run normally in the worktree.
|
|
||||||
5. Full `pnpm test` is not green on this host: it exits 97 at the pre-existing Bash `BASH_LINENO` convention guard (#1003), after the changed checkout tests and package tests pass. Do not weaken that gate.
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# RM-03 — CI Queue Guard Repair
|
|
||||||
|
|
||||||
- **Task:** RM-03
|
|
||||||
- **Issue:** #1019
|
|
||||||
- **Branch:** `fix/rm-03-queue-guard`
|
|
||||||
- **Owner:** coder-mos1
|
|
||||||
- **Reviewer:** rev-974 (independent; author != reviewer)
|
|
||||||
- **Started:** 2026-08-01
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
|
|
||||||
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
|
|
||||||
- TDD is mandatory. Every behavior case must be observed red before implementation.
|
|
||||||
- No bypass flags or hook suppression.
|
|
||||||
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
|
|
||||||
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
|
|
||||||
- No merge: coordinator holds the merge hand pending Jason.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
|
|
||||||
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
|
|
||||||
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
|
|
||||||
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
|
|
||||||
|
|
||||||
## Test matrix
|
|
||||||
|
|
||||||
| Case | Required outcome |
|
|
||||||
| --- | --- |
|
|
||||||
| success | exit 0; terminal-success |
|
|
||||||
| pending | nonzero after bounded timeout |
|
|
||||||
| failure | nonzero |
|
|
||||||
| no-status | nonzero |
|
|
||||||
| malformed | nonzero |
|
|
||||||
| >=150 KiB payload | unchanged classification; never rc126 |
|
|
||||||
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
|
|
||||||
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
|
|
||||||
| audit unavailable | nonzero |
|
|
||||||
| implicit push branch | provider URL uses checked-out feature branch |
|
|
||||||
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
|
|
||||||
|
|
||||||
## RED-first evidence
|
|
||||||
|
|
||||||
Observed against the unmodified `origin/main` implementation before source edits:
|
|
||||||
|
|
||||||
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
|
|
||||||
- Success payload was reported `state=unknown`.
|
|
||||||
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
|
|
||||||
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
|
|
||||||
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
|
|
||||||
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
|
|
||||||
- Audit-unavailable emitted no audit diagnostic.
|
|
||||||
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
|
|
||||||
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
|
|
||||||
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
|
|
||||||
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
|
|
||||||
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
|
|
||||||
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
|
|
||||||
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
|
|
||||||
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
|
|
||||||
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
|
|
||||||
|
|
||||||
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
|
|
||||||
- [x] Isolated worktree created and identity configured coherently.
|
|
||||||
- [x] Mutant tests authored and observed red.
|
|
||||||
- [x] Implementation green.
|
|
||||||
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
|
|
||||||
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
|
|
||||||
- [ ] PR CI terminal-green at exact head by full step scan.
|
|
||||||
- [ ] Merge-gate verdict issued against frozen head.
|
|
||||||
|
|
||||||
## Scope disposition
|
|
||||||
|
|
||||||
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
|
|
||||||
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
|
|
||||||
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
|
|
||||||
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
|
|
||||||
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
|
|
||||||
|
|
||||||
## Review remediation
|
|
||||||
|
|
||||||
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
|
|
||||||
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
|
|
||||||
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
|
|
||||||
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
|
|
||||||
|
|
||||||
## Risks / boundaries
|
|
||||||
|
|
||||||
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
|
|
||||||
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
|
|
||||||
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
|
|
||||||
|
|
||||||
## Test evidence
|
|
||||||
|
|
||||||
Fresh after rescue checkpoint `b7175012`:
|
|
||||||
|
|
||||||
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
|
|
||||||
- `bash -n` on the three production shell scripts passed.
|
|
||||||
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
|
|
||||||
- `pnpm typecheck` passed (45/45 Turbo tasks).
|
|
||||||
- `pnpm lint` passed (25/25 Turbo tasks).
|
|
||||||
- `pnpm format:check` passed.
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
|
|
||||||
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
|
|
||||||
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
|
|
||||||
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
|
|
||||||
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
|
|
||||||
|
|
||||||
## Final evidence
|
|
||||||
|
|
||||||
Pending.
|
|
||||||
+3
-6
@@ -6,14 +6,11 @@
|
|||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"preflight": "node scripts/preflight.mjs",
|
"typecheck": "turbo run typecheck",
|
||||||
"clean:generated": "node scripts/clean-generated.mjs",
|
"test": "turbo run test",
|
||||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
|
||||||
"test:checkout": "node --test scripts/*.test.mjs",
|
|
||||||
"test": "pnpm test:checkout && turbo run test",
|
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"prepare": "node scripts/install-hooks.mjs"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
|
|||||||
@@ -13,14 +13,7 @@ It is a **gate** role: the one and only merge path.
|
|||||||
2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges
|
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). Before issuing a verdict, scan the full JSON/API child-step
|
CI before merging). These two scripts are the _only_ sanctioned merge path.
|
||||||
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,38 +868,6 @@ steps:
|
|||||||
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
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.
|
||||||
@@ -925,16 +893,14 @@ 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
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -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>
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
- If pipeline state is running/queued/pending, wait until queue clears.
|
||||||
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
|
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
||||||
- 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 --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
|
||||||
|
|
||||||
## Review Checklist
|
## 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 on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
|
||||||
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
|
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 <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
||||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
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 <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||||
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
- 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 --expect-head {approved_full_sha}`
|
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||||
- 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 {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||||
|
|
||||||
## Git Scripts
|
## Git Scripts
|
||||||
|
|
||||||
@@ -638,9 +638,8 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
|||||||
|
|
||||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
- `~/.config/mosaic/tools/git/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`
|
||||||
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
||||||
- 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`
|
||||||
- `~/.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,12 +23,10 @@ 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; defaults to the checked-out branch)
|
# CI queue guard (required before push/merge)
|
||||||
~/.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,7 +112,6 @@ 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=
|
||||||
@@ -145,8 +144,7 @@ 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"
|
||||||
ld_preload_env="$(tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null)" || true
|
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then
|
||||||
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 the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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 the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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 the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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 the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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 the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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 the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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 the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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 the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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 the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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 the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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 the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
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 the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
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=$(jq -r "first(.results[] | select(.name == \"$GROUP\") | .pk) // empty" <<<"$group_response")
|
group_pk=$(echo "$group_response" | jq -r ".results[] | select(.name == \"$GROUP\") | .pk" | head -1)
|
||||||
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_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 ;;
|
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 ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,6 @@ 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
|
||||||
@@ -176,7 +175,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_GIT_IDENTITY MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
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; 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
|
||||||
@@ -184,15 +183,12 @@ 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]:-}
|
||||||
@@ -347,7 +343,6 @@ 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,82 +14,6 @@ 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
|
||||||
@@ -138,19 +62,6 @@ 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"
|
||||||
@@ -160,7 +71,6 @@ 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
|
||||||
@@ -178,7 +88,6 @@ 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"
|
||||||
@@ -191,55 +100,19 @@ 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")
|
||||||
contains_literal "$valid_args" new-session || fail "valid generated projection did not reach tmux"
|
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
||||||
contains_literal "$valid_args" mosaic || fail "fixed mosaic launcher command missing"
|
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
||||||
contains_literal "$valid_args" yolo || fail "fixed yolo launcher command missing"
|
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
|
||||||
contains_literal "$valid_args" pi || fail "roster runtime missing"
|
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
|
||||||
if contains_literal "$valid_args" 'bash -c'; then
|
if echo "$valid_args" | grep -qF '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.
|
||||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
||||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -252,7 +125,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"
|
||||||
contains_literal "$output" 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||||
|
|
||||||
: > "$TMUX_CALLS"
|
: > "$TMUX_CALLS"
|
||||||
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
||||||
@@ -263,7 +136,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"
|
||||||
contains_literal "$output" 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
echo "$output" | grep -qF '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,
|
||||||
@@ -301,8 +174,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"
|
||||||
contains_literal "$output" 'code=unsafe-' || fail "managed ancestor diagnostic missing"
|
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
|
||||||
if contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND'; then
|
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
|
||||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
fail "environment parsing ran before $hazard $ancestor rejection"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -323,9 +196,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"
|
||||||
contains_literal "$output" 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||||
contains_literal "$output" 'sha256=' || fail "shadow diagnostic omitted hash"
|
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||||
if contains_literal "$output" codex; then
|
if echo "$output" | grep -qF 'codex'; then
|
||||||
fail "shadow diagnostic leaked value"
|
fail "shadow diagnostic leaked value"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -341,9 +214,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"
|
||||||
contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||||
contains_literal "$output" 'sha256=' || fail "command diagnostic omitted hash"
|
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
|
||||||
if contains_literal "$output" "$COMMAND_VALUE"; then
|
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
|
||||||
fail "command diagnostic leaked command value"
|
fail "command diagnostic leaked command value"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -357,7 +230,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"
|
||||||
contains_literal "$output" 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
echo "$output" | grep -qF '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
|
||||||
@@ -387,35 +260,25 @@ 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")
|
||||||
contains_line "$pane_args" "HOME=$PANE_TRUSTED_HOME" || \
|
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||||
fail "pane did not restore trusted HOME"
|
fail "pane did not restore trusted HOME"
|
||||||
contains_literal "$pane_args" "HOME=$PANE_STALE_HOME" && \
|
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
|
||||||
fail "pane inherited stale HOME"
|
fail "pane inherited stale HOME"
|
||||||
contains_literal "$pane_args" "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
echo "$pane_args" | grep -qF "$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
|
||||||
contains_literal "$pane_args" "$blocked" && fail "pane inherited $blocked"
|
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
|
||||||
done
|
done
|
||||||
|
|
||||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
|
||||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
|
||||||
|
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")
|
||||||
# Exercise the repository launcher at $START, not the independently installed
|
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||||
# 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"
|
||||||
contains_literal "$pane_environment" "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
echo "$pane_environment" | grep -qF "$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
|
||||||
contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
|
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
||||||
done
|
done
|
||||||
|
|
||||||
write_interaction_generated() {
|
write_interaction_generated() {
|
||||||
@@ -427,7 +290,6 @@ 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
|
||||||
@@ -490,11 +352,7 @@ 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 -t 200001010000.00 "$STALE_HB.native"
|
touch -d '10 seconds ago' "$STALE_HB.native"
|
||||||
# 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
|
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||||
wait_for_sidecar_status "$STALE_HB"
|
wait_for_sidecar_status "$STALE_HB"
|
||||||
|
|
||||||
@@ -516,22 +374,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"
|
||||||
contains_literal "$output" 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
echo "$output" | grep -qF '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"
|
||||||
sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \
|
perl -0pi -e '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")
|
||||||
contains_literal "$interaction_policy_args" new-session && \
|
echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
||||||
fail "interaction pinned-policy rejection created a tmux session"
|
fail "interaction pinned-policy rejection created a tmux session"
|
||||||
contains_literal "$output" 'operator interaction service requires runtime pi' || \
|
echo "$output" | grep -qF '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
|
||||||
@@ -544,10 +402,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")
|
||||||
contains_line "$stop_args" mosaic-test || fail "exact stop did not use the validated generated socket"
|
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
|
||||||
contains_line "$stop_args" kill-session || fail "exact stop did not request session termination"
|
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
|
||||||
contains_line "$stop_args" '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||||
if contains_literal "$stop_args" ambient-socket; then
|
if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
||||||
fail "exact stop trusted an ambient socket"
|
fail "exact stop trusted an ambient socket"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ 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=""
|
BRANCH="main"
|
||||||
TARGET_REPO=""
|
|
||||||
HEAD_SHA=""
|
|
||||||
TIMEOUT_SEC=900
|
TIMEOUT_SEC=900
|
||||||
INTERVAL_SEC=15
|
INTERVAL_SEC=15
|
||||||
PURPOSE="merge"
|
PURPOSE="merge"
|
||||||
@@ -17,12 +15,10 @@ REQUIRE_STATUS=0
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
-B, --branch BRANCH Branch head to inspect (default: main)
|
||||||
-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)
|
||||||
@@ -31,65 +27,63 @@ Options:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0")
|
$(basename "$0")
|
||||||
$(basename "$0") --purpose push -t 600 -i 10
|
$(basename "$0") --purpose push -B main -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() {
|
||||||
# Python source comes from -c so the provider payload remains on stdin.
|
python3 - <<'PY'
|
||||||
# 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("malformed")
|
print("unknown")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
raw_statuses = payload.get("statuses", [])
|
statuses = payload.get("statuses") or []
|
||||||
raw_state = payload.get("state", "")
|
state = (payload.get("state") or "").lower()
|
||||||
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):
|
||||||
print("malformed")
|
continue
|
||||||
raise SystemExit(0)
|
value = (item.get("status") or item.get("state") or "").lower()
|
||||||
raw_value = item.get("status") or item.get("state")
|
if value:
|
||||||
if not isinstance(raw_value, str) or not raw_value:
|
values.append(value)
|
||||||
print("malformed")
|
|
||||||
raise SystemExit(0)
|
|
||||||
values.append(raw_value.lower())
|
|
||||||
|
|
||||||
if any(value in pending_values for value in values) or state in pending_values:
|
if not values and not state:
|
||||||
print("pending")
|
|
||||||
elif any(value in failure_values for value in values) or state in failure_values:
|
|
||||||
print("terminal-failure")
|
|
||||||
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
|
||||||
print("terminal-success")
|
|
||||||
elif not values:
|
|
||||||
print("no-status")
|
print("no-status")
|
||||||
|
elif any(v in pending_values for v in values):
|
||||||
|
print("pending")
|
||||||
|
elif any(v in failure_values for v in values):
|
||||||
|
print("terminal-failure")
|
||||||
|
elif values and all(v in success_values for v in values):
|
||||||
|
print("terminal-success")
|
||||||
else:
|
else:
|
||||||
print("unknown")
|
print("unknown")
|
||||||
'
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
print_pending_contexts() {
|
print_pending_contexts() {
|
||||||
python3 -c '
|
python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -110,61 +104,17 @@ 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 = str(item.get("status") or item.get("state") or "unknown").lower()
|
value = (item.get("status") or item.get("state") or "unknown").lower()
|
||||||
target = item.get("target_url") or item.get("url") or ""
|
target = item.get("target_url") or item.get("url") or ""
|
||||||
if value in pending_values:
|
if value in pending_values:
|
||||||
found = True
|
found = True
|
||||||
suffix = f" ({target})" if target else ""
|
if target:
|
||||||
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
||||||
|
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() {
|
||||||
@@ -178,87 +128,7 @@ github_get_commit_status_json() {
|
|||||||
local owner="$1"
|
local owner="$1"
|
||||||
local repo="$2"
|
local repo="$2"
|
||||||
local sha="$3"
|
local sha="$3"
|
||||||
local work_root status_file checks_file
|
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
||||||
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() {
|
||||||
@@ -304,14 +174,6 @@ 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
|
||||||
@@ -344,89 +206,45 @@ 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
|
|
||||||
|
|
||||||
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
OWNER=$(get_repo_owner)
|
||||||
echo "Error: --purpose must be push or merge." >&2
|
REPO=$(get_repo_name)
|
||||||
exit 1
|
detect_platform > /dev/null
|
||||||
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}"
|
PLATFORM="${PLATFORM:-unknown}"
|
||||||
record_cannot_assert "unsupported-platform"
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
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
|
||||||
record_cannot_assert "github-cli-unavailable"
|
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
||||||
exit $?
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -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=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
|
||||||
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
HOST=$(get_remote_host) || {
|
||||||
record_cannot_assert "remote-host-unresolvable"
|
echo "Error: Could not determine remote host." >&2
|
||||||
exit $?
|
exit 1
|
||||||
fi
|
}
|
||||||
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
TOKEN=$(get_gitea_token "$HOST") || {
|
||||||
record_cannot_assert "credential-unresolvable"
|
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||||
exit $?
|
exit 1
|
||||||
fi
|
}
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
||||||
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
|
||||||
record_cannot_assert "branch-head-unavailable"
|
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||||
exit $?
|
exit 1
|
||||||
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
|
||||||
record_cannot_assert "unsupported-platform"
|
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
||||||
exit $?
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
START_TS=$(date +%s)
|
START_TS=$(date +%s)
|
||||||
@@ -435,20 +253,14 @@ 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: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||||
exit 124
|
exit 124
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
||||||
record_cannot_assert "status-provider-unreachable"
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
||||||
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)
|
||||||
@@ -459,24 +271,21 @@ 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: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
||||||
else
|
exit 1
|
||||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
|
||||||
fi
|
fi
|
||||||
exit 3
|
echo "[ci-queue-wait] no status contexts present; proceeding."
|
||||||
|
exit 0
|
||||||
;;
|
;;
|
||||||
terminal-failure|malformed|unknown)
|
terminal-success|terminal-failure|unknown)
|
||||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
# Queue guard only blocks on pending/running/queued states.
|
||||||
exit 3
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
||||||
exit 3
|
exit 0
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
|
|
||||||
detect_platform() {
|
detect_platform() {
|
||||||
local remote_url
|
local remote_url
|
||||||
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
remote_url=$(git remote get-url origin 2>/dev/null)
|
||||||
# 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
|
||||||
@@ -42,10 +39,7 @@ detect_platform() {
|
|||||||
|
|
||||||
get_repo_info() {
|
get_repo_info() {
|
||||||
local remote_url
|
local remote_url
|
||||||
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
remote_url=$(git remote get-url origin 2>/dev/null)
|
||||||
# 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
|
||||||
@@ -246,21 +240,6 @@ 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,32 +91,13 @@ 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` is NOT a subcommand -- tea 0.11.x lists only
|
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||||
# 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
|
||||||
# Fail closed here too: an unchecked comment lets the issue close without its
|
gitea_issue_comment_api
|
||||||
# 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,7 +156,6 @@ 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,7 +71,6 @@ 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
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# mutate-push-guard.sh -- regenerate the README's mutation table from MEASUREMENT.
|
|
||||||
#
|
|
||||||
# WHY THIS EXISTS. The README carried a hand-written mutation table quoting
|
|
||||||
# "32/32" style results. Those numbers were true when typed and went stale in
|
|
||||||
# silence as the suite grew. A README is what a reader trusts when the tool
|
|
||||||
# misbehaves, so a confidently-wrong one is worse than none. Every number the
|
|
||||||
# README prints about mutation now comes out of this script.
|
|
||||||
#
|
|
||||||
# ============================ WHAT THIS TOOL GOT WRONG ========================
|
|
||||||
# Three defects, all found by review, all of the same shape: A TOOL WHOSE ENTIRE
|
|
||||||
# OUTPUT IS A COVERAGE CLAIM MUST BE HARDER TO FOOL THAN THE CODE IT MEASURES.
|
|
||||||
#
|
|
||||||
# 1. IT REPORTED FULL COVERAGE ON A RED BASELINE. A mutant was "killed" whenever
|
|
||||||
# the suite reported any failure at all, and the baseline was run only at the
|
|
||||||
# END and never required to be green. So ONE pre-existing suite failure --
|
|
||||||
# changing no guard behaviour whatsoever -- satisfied EVERY mutant: 13 killed,
|
|
||||||
# 0 survived, a confident table generated and pasted into the README, exit 0.
|
|
||||||
# Now: the baseline runs FIRST and must be exit-0 with zero failures, and a
|
|
||||||
# kill requires the mutant to break a case THE BASELINE PASSED, recorded BY
|
|
||||||
# NAME. A tally is not evidence; a named delta is.
|
|
||||||
#
|
|
||||||
# 2. IT MUTATED THE REVIEWED SOURCE IN PLACE. Restoration leaned on an EXIT trap.
|
|
||||||
# A TRAP IS CLEANUP, NOT ISOLATION -- SIGKILL cannot run it. An interrupted run
|
|
||||||
# left push-guard.sh mutated in the working tree, and the reviewer's NEXT
|
|
||||||
# suite run silently inherited it. A verification tool that alters its subject
|
|
||||||
# can leave the subject wrong in a way the next measurement believes.
|
|
||||||
# Now: the subject is copied into a temp dir and only the COPY is ever
|
|
||||||
# written to. The source is untouched BY CONSTRUCTION rather than by cleanup,
|
|
||||||
# which is the only version of this that survives kill -9.
|
|
||||||
#
|
|
||||||
# 3. ITS WORK DIR WAS SHARED. Concurrent runs interfered through the suite's
|
|
||||||
# default .work directory. Each run now gets its own.
|
|
||||||
#
|
|
||||||
# (Note the deliberate asymmetry with verify-clean-clone.sh, which forbids cp:
|
|
||||||
# there the copy LAUNDERED the property under measurement, so measuring a copy
|
|
||||||
# was the defect. Here mutation is destructive by design, so copying is what
|
|
||||||
# PROTECTS the subject. The rule is not "never copy" -- it is "know whether the
|
|
||||||
# copy preserves the property you are about to measure.")
|
|
||||||
#
|
|
||||||
# ================== THREE WAYS A MUTATION RUN LIES, AND THE GUARD FOR EACH ====
|
|
||||||
# A. THE ANCHOR NO LONGER MATCHES. The mutant is never applied, the suite is
|
|
||||||
# green, and the report says SURVIVED -- the same word a real coverage gap
|
|
||||||
# gets. Guarded: ANCHOR MISSING is a loud failure.
|
|
||||||
# B. THE ANCHOR MATCHES PROSE. This one landed on the first run: a mutant aimed
|
|
||||||
# at a branch matched inside the usage() heredoc, edited a help string,
|
|
||||||
# changed no behaviour, and duly reported SURVIVED. A documentation edit was
|
|
||||||
# one step from being recorded as an uncovered branch. A MUTATION THAT CANNOT
|
|
||||||
# CHANGE BEHAVIOUR IS NOT A SURVIVING MUTANT, IT IS A NON-MEASUREMENT.
|
|
||||||
# Guarded: anchors resolving inside usage() are refused.
|
|
||||||
# C. THE ANCHOR IS AMBIGUOUS. Two unrelated branches here are both the line
|
|
||||||
# `if (( status != 0 )); then`; a first-match replace would credit the kill
|
|
||||||
# to the wrong branch. Guarded: a match count != 1 refuses rather than guesses.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
while (( $# )); do
|
|
||||||
case "$1" in
|
|
||||||
# --dir exists so this tool can be pointed at a FIXTURE copy and tested.
|
|
||||||
--dir) SRC_DIR="$(cd "$2" && pwd)"; shift 2 ;;
|
|
||||||
*) printf 'usage error: unknown argument: %s\n' "$1" >&2; exit 64 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
SRC_TARGET="$SRC_DIR/push-guard.sh"
|
|
||||||
SRC_SUITE="$SRC_DIR/test-push-guard.sh"
|
|
||||||
for f in "$SRC_TARGET" "$SRC_SUITE"; do
|
|
||||||
[[ -r "$f" ]] || { printf 'REFUSING: cannot read %s\n' "$f" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- ISOLATION, NOT CLEANUP --------------------------------------------------
|
|
||||||
# Everything below writes only inside WORK. The trap is a courtesy for disk
|
|
||||||
# space; correctness does not depend on it running.
|
|
||||||
WORK="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$WORK"' EXIT
|
|
||||||
install -m 755 "$SRC_TARGET" "$WORK/push-guard.sh"
|
|
||||||
install -m 755 "$SRC_SUITE" "$WORK/test-push-guard.sh"
|
|
||||||
TARGET="$WORK/push-guard.sh"
|
|
||||||
SUITE="$WORK/test-push-guard.sh"
|
|
||||||
BAK="$WORK/push-guard.sh.orig"
|
|
||||||
cp "$TARGET" "$BAK"
|
|
||||||
# Per-run work dir: the suite otherwise defaults to a shared .work beside itself,
|
|
||||||
# and two concurrent runs corrupt each other's fixtures.
|
|
||||||
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
|
||||||
|
|
||||||
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
|
||||||
PROSE_LO="$(grep -n -m1 '^usage() {' "$BAK" | cut -d: -f1)"
|
|
||||||
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
|
||||||
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
|
||||||
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# passing_cases <output> -- names of cases that PASSED, one per line
|
|
||||||
passing_cases() { printf '%s\n' "$1" | sed -n 's/^ PASS \[[^]]*\] \(.*\) (exit [0-9]*)$/\1/p'; }
|
|
||||||
tally() { printf '%s\n' "$1" | grep -E 'needles: [0-9]+ passed' | tail -1; }
|
|
||||||
|
|
||||||
# --- THE BASELINE MUST BE GREEN, AND IT IS ESTABLISHED FIRST ------------------
|
|
||||||
printf '=== baseline (must be exit 0 with zero failures) ===\n'
|
|
||||||
BASE_OUT="$("$SUITE" 2>&1)"; BASE_RC=$?
|
|
||||||
BASE_LINE="$(tally "$BASE_OUT")"
|
|
||||||
BASE_FAILED="$(printf '%s\n' "$BASE_LINE" | sed -n 's/.*, \([0-9]*\) failed.*/\1/p')"
|
|
||||||
if (( BASE_RC != 0 )) || [[ -z "$BASE_LINE" || "$BASE_FAILED" != "0" ]]; then
|
|
||||||
printf 'REFUSING: baseline is not green -- exit %s, tally: %s\n' \
|
|
||||||
"$BASE_RC" "${BASE_LINE:-<no tally emitted>}" >&2
|
|
||||||
printf '\nEvery mutant would be scored KILLED by the pre-existing failure, and this\n' >&2
|
|
||||||
printf 'tool would publish a confident coverage table that measured nothing. Fix the\n' >&2
|
|
||||||
printf 'suite first. NO TABLE IS EMITTED.\n' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf ' %s\n' "$BASE_LINE"
|
|
||||||
mapfile -t BASE_PASSING < <(passing_cases "$BASE_OUT")
|
|
||||||
printf ' %d named cases passing at baseline\n' "${#BASE_PASSING[@]}"
|
|
||||||
if (( ${#BASE_PASSING[@]} == 0 )); then
|
|
||||||
printf 'REFUSING: could not parse any case names -- kills could not be attributed.\n' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf ' prose (usage heredoc) is lines %s-%s -- anchors there are refused, not scored\n\n' \
|
|
||||||
"$PROSE_LO" "$PROSE_HI"
|
|
||||||
|
|
||||||
rc_all=0
|
|
||||||
KILLED=0; SURVIVED=0
|
|
||||||
declare -a ROWS=()
|
|
||||||
|
|
||||||
mutate() {
|
|
||||||
local name="$1" find="$2" repl="$3"
|
|
||||||
# Count and locate in python so MULTI-LINE anchors work. They are required:
|
|
||||||
# two unrelated branches in this file are both the single line
|
|
||||||
# `if (( status != 0 )); then`, and a one-line anchor cannot say which one a
|
|
||||||
# result belongs to. Guessing would attribute a kill to the wrong branch.
|
|
||||||
local loc; loc="$(python3 - "$BAK" "$find" <<'LOCPY'
|
|
||||||
import sys
|
|
||||||
s = open(sys.argv[1]).read(); find = sys.argv[2]
|
|
||||||
n = s.count(find)
|
|
||||||
print(n, (s[:s.index(find)].count("\n") + 1) if n else 0)
|
|
||||||
LOCPY
|
|
||||||
)"
|
|
||||||
local n="${loc%% *}" ln="${loc##* }"
|
|
||||||
if (( n == 0 )); then
|
|
||||||
printf ' !! ANCHOR MISSING %-46s NOT APPLIED -- result would be meaningless\n' "$name"
|
|
||||||
rc_all=1; return
|
|
||||||
fi
|
|
||||||
if (( n != 1 )); then
|
|
||||||
printf ' !! ANCHOR AMBIGUOUS %-46s %d matches -- refusing to guess which branch\n' "$name" "$n"
|
|
||||||
rc_all=1; return
|
|
||||||
fi
|
|
||||||
if (( ln >= PROSE_LO && ln <= PROSE_HI )); then
|
|
||||||
printf ' !! ANCHOR IS PROSE %-46s line %d is inside usage() -- not a branch\n' "$name" "$ln"
|
|
||||||
rc_all=1; return
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 - "$BAK" "$TARGET" "$find" "$repl" <<'MUTPY'
|
|
||||||
import sys
|
|
||||||
src, dst, find, repl = sys.argv[1:5]
|
|
||||||
open(dst, "w").write(open(src).read().replace(find, repl, 1))
|
|
||||||
MUTPY
|
|
||||||
local out; out="$("$SUITE" 2>&1)"
|
|
||||||
cp "$BAK" "$TARGET"
|
|
||||||
|
|
||||||
local line; line="$(tally "$out")"
|
|
||||||
if [[ -z "$line" ]]; then
|
|
||||||
printf ' !! NO TALLY %-46s suite produced no needle count\n' "$name"
|
|
||||||
rc_all=1; return
|
|
||||||
fi
|
|
||||||
# A KILL IS A NAMED DELTA, NOT A TALLY. Cases that passed at baseline and no
|
|
||||||
# longer pass are the evidence; anything else (a case that was already
|
|
||||||
# failing, a suite that died early) cannot be credited to this mutant.
|
|
||||||
local now; now="$(passing_cases "$out")"
|
|
||||||
local -a broke=()
|
|
||||||
local c
|
|
||||||
for c in "${BASE_PASSING[@]}"; do
|
|
||||||
grep -qxF -- "$c" <<<"$now" || broke+=("$c")
|
|
||||||
done
|
|
||||||
local total="${#BASE_PASSING[@]}"
|
|
||||||
|
|
||||||
if (( ${#broke[@]} > 0 )); then
|
|
||||||
printf ' KILLED L%-5s %-46s %d/%d fail\n' "$ln" "$name" "${#broke[@]}" "$total"
|
|
||||||
printf ' by: %s\n' "${broke[0]}"
|
|
||||||
(( ${#broke[@]} > 1 )) && printf ' +%d more\n' "$(( ${#broke[@]} - 1 ))"
|
|
||||||
ROWS+=("| \`$name\` (L$ln) | ${#broke[@]}/$total fail | killed |")
|
|
||||||
KILLED=$(( KILLED + 1 ))
|
|
||||||
else
|
|
||||||
printf ' SURVIVED L%-5s %-46s 0/%d fail <-- UNCOVERED BRANCH\n' "$ln" "$name" "$total"
|
|
||||||
ROWS+=("| \`$name\` (L$ln) | 0/$total fail | **SURVIVED** |")
|
|
||||||
SURVIVED=$(( SURVIVED + 1 )); rc_all=1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "=== push-guard mutation run ==="
|
|
||||||
# EVERY ANCHOR BELOW IS VERBATIM SOURCE TEXT OF THE GUARD, so the single quotes
|
|
||||||
# are load-bearing: these strings must reach `mutate` as the CHARACTERS that
|
|
||||||
# appear in push-guard.sh. Expanding them would search for THIS shell's (unset)
|
|
||||||
# $rel, $cmode, $EX_CONFIG and match nothing -- which the anchor guards would
|
|
||||||
# report as ANCHOR MISSING rather than silently, but the intent is still to
|
|
||||||
# forbid expansion. The directive is scoped to this function so it cannot mask a
|
|
||||||
# genuine unintended-literal anywhere else in the file.
|
|
||||||
# shellcheck disable=SC2016
|
|
||||||
run_mutants() {
|
|
||||||
mutate "json decision requirement bypassed" \
|
|
||||||
' if (( ${#JSON_PATHS[@]} == 0 )); then' ' if false; then'
|
|
||||||
mutate "opt-out accepted with no written reason" \
|
|
||||||
'if not isinstance(reason, str) or not reason.strip():' 'if False:'
|
|
||||||
mutate "committed re-read of the opt-out skipped" \
|
|
||||||
' [[ "$CFG_MODE" == "none" ]] || return 0' ' return 0'
|
|
||||||
mutate "untracked config honoured as an opt-out" \
|
|
||||||
' if [[ -z "$rel" ]]; then' ' if false; then'
|
|
||||||
mutate "staged-but-uncommitted opt-out honoured" \
|
|
||||||
' if [[ -z "$cmode" ]]; then' ' if false; then'
|
|
||||||
mutate "committed SYMLINK config honoured" \
|
|
||||||
' if [[ "$cmode" == "120000" ]]; then' ' if false; then'
|
|
||||||
mutate "unparseable committed config ignored" \
|
|
||||||
' if (( cstatus != 0 )); then' ' if false; then'
|
|
||||||
mutate "local-only opt-out (HEAD says ON) honoured" \
|
|
||||||
' if [[ "$cmode_val" != "none" ]]; then' ' if false; then'
|
|
||||||
mutate "empty MERGE exempted" \
|
|
||||||
' if [[ "$all_same" == yes ]]; then' ' if false; then'
|
|
||||||
mutate "empty ROOT exempted" \
|
|
||||||
'if [[ -z "$(git diff-tree --root -r --name-only --no-commit-id HEAD)" ]]; then' \
|
|
||||||
'if false; then'
|
|
||||||
mutate "--since-head ancestry check removed" \
|
|
||||||
'if ! git merge-base --is-ancestor "$since_head" "$head"; then' 'if false; then'
|
|
||||||
# guard's own source text, matched verbatim. Expanding them here would search for
|
|
||||||
# this shell's (empty) $EX_CONFIG instead of the characters in the file.
|
|
||||||
mutate "staged-file enumeration ignores git failure" \
|
|
||||||
"$(printf 'if (( status != 0 )); then\n local msg')" \
|
|
||||||
"$(printf 'if false; then\n local msg')"
|
|
||||||
mutate "malformed config degrades to absent instead of refusing" \
|
|
||||||
"$(printf 'if (( status != 0 )); then\n fail "$EX_CONFIG"')" \
|
|
||||||
"$(printf 'if false; then\n fail "$EX_CONFIG"')"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_mutants
|
|
||||||
|
|
||||||
printf '\nbaseline: %s\n' "$BASE_LINE"
|
|
||||||
printf '%d killed, %d survived\n' "$KILLED" "$SURVIVED"
|
|
||||||
|
|
||||||
printf '\n--- README TABLE (paste verbatim) ---\n'
|
|
||||||
printf '| mutation | suite result | verdict |\n|---|---|---|\n'
|
|
||||||
printf '| *unmodified* | %s | baseline |\n' "$(printf '%s' "$BASE_LINE" | sed 's/push-guard needles: //')"
|
|
||||||
printf '%s\n' "${ROWS[@]}"
|
|
||||||
exit "$rc_all"
|
|
||||||
@@ -219,7 +219,6 @@ 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] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -12,10 +12,8 @@ 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
|
||||||
@@ -27,18 +25,15 @@ 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 --expect-head 0123456789abcdef0123456789abcdef01234567
|
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||||
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -58,30 +53,15 @@ 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
|
||||||
;;
|
;;
|
||||||
@@ -106,49 +86,18 @@ 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 [[ "$DRY_RUN" != true ]]; then
|
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||||
--purpose merge \
|
--purpose merge \
|
||||||
-B "$HEAD_BRANCH" \
|
-B "$BASE_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
|
||||||
@@ -157,442 +106,79 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
write_curl_auth_config() {
|
is_known_tea_empty_identity_failure() {
|
||||||
local mode="$1" credential="$2"
|
local error_file="$1"
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
LAST_GITEA_HTTP_CODE="000"
|
python3 - "$error_file" <<'PY'
|
||||||
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
|
|
||||||
|
|
||||||
with open(sys.argv[1], "rb") as handle:
|
|
||||||
raw = handle.read(65536)
|
|
||||||
try:
|
|
||||||
response = json.loads(raw.decode("utf-8", errors="replace"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
message = "non-JSON response omitted"
|
|
||||||
else:
|
|
||||||
if isinstance(response, dict):
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup_merge_temp_dirs() {
|
|
||||||
local path
|
|
||||||
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
|
||||||
[[ -n "$path" ]] && rm -rf -- "$path"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
trap cleanup_merge_temp_dirs EXIT
|
|
||||||
trap 'exit 130' INT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
|
|
||||||
fetch_gitea_pr_head() {
|
|
||||||
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
|
||||||
local response_file raw_code api_url auth_config curl_rc
|
|
||||||
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
|
|
||||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
|
||||||
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
|
||||||
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
|
||||||
rm -f "$response_file"
|
|
||||||
return 1
|
|
||||||
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 re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
||||||
pull = json.load(handle)
|
error = handle.read()
|
||||||
head = pull.get("head") if isinstance(pull, dict) else None
|
|
||||||
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
|
known_empty_identity = re.search(
|
||||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
|
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
||||||
raise SystemExit(1)
|
error,
|
||||||
print(sha)
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
raise SystemExit(0 if known_empty_identity else 1)
|
||||||
PY
|
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
|
|
||||||
|
|
||||||
if ! page_count=$(python3 - "$page_file" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
|
||||||
page = json.load(handle)
|
|
||||||
if not isinstance(page, list):
|
|
||||||
raise SystemExit(1)
|
|
||||||
print(len(page))
|
|
||||||
PY
|
|
||||||
); 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() {
|
merge_gitea_with_api() {
|
||||||
local host="$1" token attempt_rc
|
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||||
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||||
|
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||||
|
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||||
|
payload='{"Do":"squash"}'
|
||||||
|
|
||||||
if ! token=$(get_gitea_token "$host"); then
|
token=$(get_gitea_token "$host" || true)
|
||||||
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
if [[ -n "$token" ]]; then
|
||||||
return 1
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||||
fi
|
-X POST \
|
||||||
if [[ -z "$token" ]]; then
|
-H "User-Agent: curl/8" \
|
||||||
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
-H "Authorization: token $token" \
|
||||||
return 1
|
-H 'Content-Type: application/json' \
|
||||||
fi
|
-d "$payload" \
|
||||||
if merge_gitea_api_attempt "$host" token "$token"; then
|
"$api_url" || true)
|
||||||
|
if [[ "$raw_code" =~ ^2 ]]; then
|
||||||
|
rm -f "$body_file"
|
||||||
return 0
|
return 0
|
||||||
else
|
|
||||||
attempt_rc=$?
|
|
||||||
fi
|
fi
|
||||||
if [[ "$attempt_rc" -eq 75 ]]; then
|
|
||||||
return 75
|
|
||||||
fi
|
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
|
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
||||||
return 1
|
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
|
||||||
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
|
fi
|
||||||
|
|
||||||
|
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
code, path = sys.argv[1], sys.argv[2]
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||||
|
raw = handle.read(500)
|
||||||
|
data = json.loads(raw) if raw else {}
|
||||||
|
message = data.get("message") or data.get("error") or raw or "empty response"
|
||||||
|
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
|
||||||
|
rm -f "$body_file"
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,10 +188,11 @@ 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
|
||||||
}
|
}
|
||||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||||
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)."
|
if [[ -n "$TEA_LOGIN" ]]; then
|
||||||
|
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 the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (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)."
|
||||||
@@ -615,11 +202,7 @@ fi
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
||||||
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[@]}"
|
||||||
;;
|
;;
|
||||||
@@ -628,9 +211,32 @@ 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
|
||||||
}
|
}
|
||||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||||
# 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,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||||
write_metadata "$METADATA"
|
write_metadata "$METADATA"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
@@ -182,25 +182,6 @@ 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'),
|
||||||
@@ -226,8 +207,6 @@ 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)],
|
||||||
|
|||||||
@@ -109,55 +109,6 @@ else
|
|||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Render the provider's own explanation for a failed request, for appending to
|
|
||||||
# an error message (#1004). Every HTTP arm in this file already has the response
|
|
||||||
# body on disk; without this it was discarded unread at exactly the moment the
|
|
||||||
# caller needed it, which pushes an operator toward re-issuing the request by
|
|
||||||
# hand to find out what the server said. Gitea returns {"message": "..."} on a
|
|
||||||
# refusal; anything unparseable falls back to a truncated raw first line so a
|
|
||||||
# proxy's HTML error page still says something. Prints "" when there is nothing
|
|
||||||
# to add, so callers can interpolate unconditionally.
|
|
||||||
#
|
|
||||||
# Args: $1 = path to the response body file.
|
|
||||||
gitea_error_detail() {
|
|
||||||
local body_file="$1"
|
|
||||||
[[ -s "$body_file" ]] || return 0
|
|
||||||
python3 - "$body_file" <<'PY' 2>/dev/null || true
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
LIMIT = 300
|
|
||||||
try:
|
|
||||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as response:
|
|
||||||
raw = response.read().strip()
|
|
||||||
except OSError:
|
|
||||||
raise SystemExit(0)
|
|
||||||
if not raw:
|
|
||||||
raise SystemExit(0)
|
|
||||||
detail = ""
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
for key in ("message", "error", "errors"):
|
|
||||||
value = parsed.get(key)
|
|
||||||
if isinstance(value, str) and value.strip():
|
|
||||||
detail = value.strip()
|
|
||||||
break
|
|
||||||
if isinstance(value, list) and value:
|
|
||||||
detail = "; ".join(str(item) for item in value).strip()
|
|
||||||
break
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if not detail:
|
|
||||||
detail = raw.splitlines()[0].strip()
|
|
||||||
if not detail:
|
|
||||||
raise SystemExit(0)
|
|
||||||
if len(detail) > LIMIT:
|
|
||||||
detail = detail[:LIMIT] + "..."
|
|
||||||
print(f" — provider said: {detail}")
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
|
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
|
||||||
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
|
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
|
||||||
# write is a direct POST that returns the created comment object, so we learn
|
# write is a direct POST that returns the created comment object, so we learn
|
||||||
@@ -199,7 +150,7 @@ print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$write_status" != "201" ]]; then
|
if [[ "$write_status" != "201" ]]; then
|
||||||
echo "Error: Gitea comment write failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
|
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -228,7 +179,7 @@ PY
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$readback_status" != "200" ]]; then
|
if [[ "$readback_status" != "200" ]]; then
|
||||||
echo "Error: Gitea comment read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
|
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -419,7 +370,7 @@ gitea_authenticated_login() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$status" != "200" ]]; then
|
if [[ "$status" != "200" ]]; then
|
||||||
echo "Error: Gitea authenticated-identity read failed with HTTP $status$(gitea_error_detail "$response_file")" >&2
|
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -456,7 +407,7 @@ gitea_read_pr_head_into() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$status" != "200" ]]; then
|
if [[ "$status" != "200" ]]; then
|
||||||
echo "Error: Gitea PR head read failed with HTTP $status$(gitea_error_detail "$pr_file")" >&2
|
echo "Error: Gitea PR head read failed with HTTP $status" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
python3 - "$pr_file" <<'PY'
|
python3 - "$pr_file" <<'PY'
|
||||||
@@ -546,7 +497,7 @@ print(json.dumps({
|
|||||||
fi
|
fi
|
||||||
# Gitea returns 200 (occasionally 201) with the created review object.
|
# Gitea returns 200 (occasionally 201) with the created review object.
|
||||||
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
|
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
|
||||||
echo "Error: Gitea review submit failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
|
echo "Error: Gitea review submit failed with HTTP $write_status (#865: no durable review created)" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -575,7 +526,7 @@ PY
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$readback_status" != "200" ]]; then
|
if [[ "$readback_status" != "200" ]]; then
|
||||||
echo "Error: Gitea review read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
|
echo "Error: Gitea review read-back failed with HTTP $readback_status" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
# push-guard
|
|
||||||
|
|
||||||
Mechanical closure of one defect: **a verification that passes when the thing it verifies never happened.**
|
|
||||||
|
|
||||||
Three incidents in one evening, three agents, no coordination, same shape:
|
|
||||||
|
|
||||||
| # | Incident | Why the check passed |
|
|
||||||
| --- | ----------------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
||||||
| 1 | `PUSH VERIFIED` reported after nothing was pushed | Commit had aborted, so local HEAD trivially equalled the remote ref |
|
|
||||||
| 2 | An **empty commit** carrying another commit's message was pushed | A push was chained after a _failed_ commit and ran on stale state |
|
|
||||||
| 3 | Conflict markers + invalid JSON committed into 70 generated files | Nothing asserted the generated output still parsed |
|
|
||||||
|
|
||||||
Each is an assertion satisfied by the null case. `push-guard.sh` asserts the **positive** fact instead: a new object exists, the remote _moved_, the content _parses_.
|
|
||||||
|
|
||||||
## Checks
|
|
||||||
|
|
||||||
| Sub-command | Asserts | Exit on failure |
|
|
||||||
| -------------- | -------------------------------------------- | --------------- |
|
|
||||||
| `check-staged` | index has no unmerged paths | `2` |
|
|
||||||
| | no conflict markers in staged content | `2` |
|
|
||||||
| | the conflict scan actually _completed_ | `2` |
|
|
||||||
| | staged JSON under the nominated paths parses | `3` |
|
|
||||||
| | **something is actually staged** | `5` |
|
|
||||||
| | **an explicit JSON decision exists** | `6` |
|
|
||||||
| `push` | HEAD advanced past `--since-head` | `5` |
|
|
||||||
| | HEAD is a non-empty commit | `5` |
|
|
||||||
| | remote **moved**, and now equals local HEAD | `4` |
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
push-guard.sh check-staged --json-path 'data/**/*.json'
|
|
||||||
|
|
||||||
BEFORE=$(git rev-parse HEAD)
|
|
||||||
git commit -m "..."
|
|
||||||
push-guard.sh push --remote origin --branch main --since-head "$BEFORE"
|
|
||||||
```
|
|
||||||
|
|
||||||
`--since-head` is what distinguishes "committed nothing" from "committed something", and capturing the remote ref _before_ pushing is what makes `remote == local` mean anything. Both were missing from the guard that produced incident 1.
|
|
||||||
|
|
||||||
## Design decisions that measurement forced
|
|
||||||
|
|
||||||
These are the interesting part; each one was wrong in the first draft.
|
|
||||||
|
|
||||||
**A bare `=======` is deliberately NOT treated as a conflict marker.** It collides with reStructuredText underlines and ASCII rules. Every genuine conflict git writes also contains `<<<<<<<` and `>>>>>>>`, so requiring those loses no real detection. Measured against a real repository: **zero** false positives across the entire tracked tree.
|
|
||||||
|
|
||||||
**The JSON check is opt-in by path, not on-by-default.** The first draft checked every staged `*.json`, on the reasoning that broader is strictly stronger. Measured against the same repository: **17 of 119** tracked `.json` files fail a strict parse, _all legitimately_ — every `tsconfig*.json` is JSONC (comments are legal there) and the CA templates are Go templates that merely carry a `.json` suffix. An on-by-default check fires on ~14% of the repo's JSON, and a guard that cries wolf gets routed around until the bypass is habitual — at which point the bypass covers the true positives too. Broader was **weaker**.
|
|
||||||
|
|
||||||
**`--json-path` values are normalized to `:(glob)` magic.** Git's default pathspec matching makes `data/**/*.json` require at least one intermediate directory: it matches `data/sub/b.json` and _silently skips_ `data/a.json`. The obvious spelling would have delivered partial coverage with no warning.
|
|
||||||
|
|
||||||
## Found by independent review, after the needles were already green
|
|
||||||
|
|
||||||
An adversarial review (author ≠ reviewer) found two genuine fail-opens that 17 self-written needles, five mutation runs and a repo-wide false-positive measurement had all missed. Both were reproduced before being fixed, and both now have needles.
|
|
||||||
|
|
||||||
**Renamed files were invisible to every content check.** Git detects renames by default (`diff.renames=true` since 2.9), so `git mv` plus a small edit is reported as a single `R` entry — which `--diff-filter=ACM` does not match. Reproduced at 97% similarity: the guard printed `staged content OK` and exited 0 with conflict markers in the staged blob. Fixed with `--no-renames`.
|
|
||||||
|
|
||||||
There is a trap inside this one worth recording. With _only_ the renamed file staged, the guard exits 5 — the nothing-staged check fires because the file list came back empty. That looks like a catch and is pure accident; co-stage one ordinary file and the fail-open is total. The needle deliberately co-stages a clean file so it cannot pass for the wrong reason.
|
|
||||||
|
|
||||||
**`-I` skipped files marked binary in `.gitattributes`,** while the summary still counted them as scanned — a clean pass _and_ a false count. Fixed with `-a`.
|
|
||||||
|
|
||||||
The review also correctly identified one **vacuous control**: the positive `--since-head` case asserted only exit 0, which the push produces anyway, so deleting the entire `--since-head` block left it passing. It now asserts on output.
|
|
||||||
|
|
||||||
Two reported findings did **not** reproduce and were not acted on: `:(glob)` does still match a bare directory pathspec, and my first rename repro failed only because the edit dropped similarity below the detection threshold — a badly built test, not an absent bug. Re-testing properly is what confirmed it.
|
|
||||||
|
|
||||||
## The JSON decision is mandatory (`.push-guard.json`)
|
|
||||||
|
|
||||||
The first release announced `JSON check NOT REQUESTED` and continued. That was _visible_ rather than silent, which is better — but it is still an **absence**, and an absence is not reviewable. Nobody reads a line that has printed correctly ten thousand times. A repo that needed the check and never wired it up stayed unprotected forever, and nothing ever failed.
|
|
||||||
|
|
||||||
The decision is now required, from one of exactly two places:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// nominate generated JSON for parse-checking
|
|
||||||
{"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
|
|
||||||
|
|
||||||
// or opt out — the reason is REQUIRED and is printed on every run
|
|
||||||
{"json_check": "none", "reason": "no generated JSON in this repo"}
|
|
||||||
```
|
|
||||||
|
|
||||||
`--json-path` on the command line also satisfies it. Saying nothing is refused (exit `6`).
|
|
||||||
|
|
||||||
**The asymmetry is deliberate.** Turning the check _on_ is safe from anywhere, so a CLI flag suffices. Turning it _off_ is confined to a committed file, because that is the only form a human can review: you can read a reason in a diff, and you cannot review the fact that nobody typed a flag. An opt-out living in an ad-hoc command line is the old fail-open with extra steps.
|
|
||||||
|
|
||||||
A **malformed** config is refused outright rather than treated as absent — that fallback would mean a typo silently disables the check the file was written to enable. A config that parses but _states nothing_ (`{}`) is refused too: valid JSON that says nothing is the original defect wearing a config file as a disguise.
|
|
||||||
|
|
||||||
**Migration is a hard cutover, on purpose.** The tempting path is "warn for one release, then enforce" — but that warning phase _is_ the degrade-to-a-warning this tool forbids, and it leaves the fail-open open for exactly as long as the warning is ignored, which is indefinitely. Consumers add a config or the guard refuses. It breaks loudly, once. Two pre-existing cases in this repo's own harness were the first to pay that cost, which is the correct place to feel it.
|
|
||||||
|
|
||||||
## Known limitations — stated, not hidden
|
|
||||||
|
|
||||||
- `check-staged` inspects the index. Content added to the working tree _after_ it runs is not covered — it belongs in a `pre-commit` hook, where the window is smallest.
|
|
||||||
- `push` hardcodes `HEAD:refs/heads/$branch`, so it cannot verify a commit built via plumbing on a different base. A `--sha` option would close this; it is deliberately not added without a needle.
|
|
||||||
|
|
||||||
## Test harness
|
|
||||||
|
|
||||||
`test-push-guard.sh` — 46 cases, each check in **both polarities**:
|
|
||||||
|
|
||||||
- **NEEDLE** — a deliberately broken fixture that must trip the guard.
|
|
||||||
- **CONTROL** — a clean fixture that must pass.
|
|
||||||
|
|
||||||
Controls are not decoration. A guard that failed unconditionally would satisfy every needle and look fully covered. Several controls additionally assert on the guard's _output_ (`--out`), because exit 0 cannot distinguish "checked the files and they were fine" from "matched no files and had nothing to check" — two controls in an earlier revision were passing vacuously for exactly that reason.
|
|
||||||
|
|
||||||
### Mutation results — the harness was itself tested by breaking the guard
|
|
||||||
|
|
||||||
**Everything in this section is regenerated by `./mutate-push-guard.sh`, not typed.** The previous version of this table was hand-maintained: it was true when written, went stale as the suite grew, and ended up asserting `unmodified | 32/32 pass` against a 46-case suite. A README is what a reader consults when the tool misbehaves, so a confidently-wrong one is worse than none. Re-run the script and paste; do not edit the numbers.
|
|
||||||
|
|
||||||
| mutation | suite result | verdict |
|
|
||||||
| ---------------------------------------------------------------- | ------------------- | -------- |
|
|
||||||
| _unmodified_ | 46 passed, 0 failed | baseline |
|
|
||||||
| `json decision requirement bypassed` (L482) | 3/43 fail | killed |
|
|
||||||
| `opt-out accepted with no written reason` (L334) | 1/43 fail | killed |
|
|
||||||
| `committed re-read of the opt-out skipped` (L405) | 5/43 fail | killed |
|
|
||||||
| `untracked config honoured as an opt-out` (L409) | 1/43 fail | killed |
|
|
||||||
| `staged-but-uncommitted opt-out honoured` (L425) | 1/43 fail | killed |
|
|
||||||
| `committed SYMLINK config honoured` (L433) | 1/43 fail | killed |
|
|
||||||
| `unparseable committed config ignored` (L444) | 1/43 fail | killed |
|
|
||||||
| `local-only opt-out (HEAD says ON) honoured` (L459) | 1/43 fail | killed |
|
|
||||||
| `empty MERGE exempted` (L704) | 1/43 fail | killed |
|
|
||||||
| `empty ROOT exempted` (L715) | 1/43 fail | killed |
|
|
||||||
| `--since-head ancestry check removed` (L665) | 1/43 fail | killed |
|
|
||||||
| `staged-file enumeration ignores git failure` (L173) | 1/43 fail | killed |
|
|
||||||
| `malformed config degrades to absent instead of refusing` (L375) | 4/43 fail | killed |
|
|
||||||
|
|
||||||
13 mutants, 0 survived.
|
|
||||||
|
|
||||||
**Why the denominator is 43 while the suite reports 46.** The generator attributes kills only to cases it can parse by name, which is the `PASS [KIND] <name> (exit N)` form. Of the 46 passing assertions, 45 print `PASS` and 43 of those match that form. The three excluded lines are:
|
|
||||||
|
|
||||||
```
|
|
||||||
PASS [CONTROL] guard runs without emitting any interpreter warning
|
|
||||||
PASS [NEEDLE ] the warning detector fires on a known warning string
|
|
||||||
ok [e9-fixture ] fixture is a merge (3 fields) with tree identical to both parents
|
|
||||||
```
|
|
||||||
|
|
||||||
The two `z1` warning assertions assert on a whole _class_ of output rather than an exit code, so they carry no `(exit N)`; `e9-fixture` is a fixture precondition and prints in the `ok` form. All three still run and still gate the suite — they are excluded from _attribution_, not from _execution_.
|
|
||||||
|
|
||||||
An earlier revision of this paragraph named the excluded set as `w2-fixture`, `e9-fixture` and `g1-fixture`. **That was written from memory instead of from the output, and two of the three names were wrong:** `w2-fixture` belongs to `test-verify-clean-clone.sh` and `g1-fixture` to `test-mutate-push-guard.sh`, so neither runs in this suite at all and neither could contribute to its tally. A reader auditing the denominator would have gone looking for them in the wrong files. It is recorded rather than quietly corrected because a confidently-wrong provenance inside the section that exists to make a generated number auditable is the same defect as everything else on this page: **a claim that reads as measured and is not.** The set above was produced by running the suite and applying the generator's own parser to its output.
|
|
||||||
|
|
||||||
Earlier mutants, run at the suite size of the day and kept as history rather than as a live claim: fail-open (9/16), always-fail (16/16 — controls catch it), revert `:(glob)` normalization (3/16, caught **only** by the `--out` assertions), drop the remote-did-not-move assertion (1/16), restore blanket `|| true` on the scan (1/17), revert `--no-renames` (1/19), revert `-a` to `-I` (1/19), delete the `--since-head` block (2/19, incl. the control that _was_ vacuous), stray-warning emission (1/32).
|
|
||||||
|
|
||||||
A guard nobody has watched fail is not a guard. The same applies to the harness: the `:(glob)` mutant would have passed silently without the output assertions, so the assertions are load-bearing rather than ornamental.
|
|
||||||
|
|
||||||
### Two branches were uncovered, and writing this table is what found them
|
|
||||||
|
|
||||||
Building the generator turned up two mutants that survived a **46-case suite reporting 46/46 green**: `staged-but-uncommitted opt-out honoured` and `unparseable committed config ignored`. Neither branch had any case at all. Both are now needled, which is why they read _killed_ above.
|
|
||||||
|
|
||||||
Both survivors share a shape worth naming: the mutant still exits `6`, because control falls through to a _sibling_ refusal that rejects for a different reason. **An exit-code-only assertion would have been satisfied by the wrong branch.** The new cases anchor on the distinguishing clause instead.
|
|
||||||
|
|
||||||
The same audit found a live instance of that defect already in the suite. Three separate branches print the headline `OPT-OUT IS NOT REVIEWABLE` — untracked, staged-not-committed, and symlink — and the untracked needle was anchored on the shared headline. Delete the untracked branch and control reaches a sibling printing those same words, so **the needle would not fail; it would re-point.** A substring anchor does not fail when its subject is removed. It is now anchored on `is not tracked in git`.
|
|
||||||
|
|
||||||
### The generator refuses three ways a mutation run can lie
|
|
||||||
|
|
||||||
1. **The anchor no longer matches.** The mutant is never applied, the suite is green, and a naive report says _survived_ — the same word a real coverage gap gets. `ANCHOR MISSING` is a loud failure instead.
|
|
||||||
2. **The anchor matches prose.** This one landed on the first run: a mutant aimed at the refuse-on-missing-config branch matched inside the `usage()` heredoc, edited a help string, changed no behaviour, and duly reported _survived_. A documentation edit was one step from being recorded as an uncovered branch. **A mutation that cannot change behaviour is not a surviving mutant, it is a non-measurement** — and a non-measurement reported as a result is the same defect as the vacuous test the harness exists to hunt. Anchors resolving inside `usage()` are now refused, and the heredoc's bounds are located at runtime rather than hardcoded.
|
|
||||||
3. **The anchor is ambiguous.** Two unrelated branches here are both the single line `if (( status != 0 )); then`; a first-match replace would silently attribute the kill to whichever came first. Multi-line anchors are supported and a match count `!= 1` refuses rather than guesses.
|
|
||||||
|
|
||||||
**A blanket `|| true` on the scan was a fail-open in the guard's own error handling.** `git grep` exits `1` for "no match" but `>=2` for a real failure. `|| true` collapsed the two, so a malformed pathspec or unreadable index would have been reported as "ok, no conflict markers" — a clean pass from a scan that never ran. The status is now discriminated, and a PATH-shim fault-injection needle proves the refusal.
|
|
||||||
|
|
||||||
**A `<<'PY'` heredoc inside `$( )` made bash print a warning on every run — and 30 needles plus a clean linter all missed it.** The config parser started life as an inline heredoc inside a command substitution, so every invocation emitted `warning: command substitution: 1 unterminated here-document` to stderr. It executed correctly, every needle stayed green, and shellcheck reported nothing. The harness missed it because `expect` asserts _substrings it was told to look for_ — and nobody tells you to look for output you did not know existed. It surfaced only when the guard was run against a real repository.
|
|
||||||
|
|
||||||
The fix moves the parser to a top-level constant. The lesson is encoded as case `z1`, which asserts the **absence of a whole output class** rather than the presence of an expected string, and is paired with a needle proving the detector can fire. A tool built to refuse quiet failures was quietly polluting stderr for its entire existence.
|
|
||||||
|
|
||||||
**The `-E` flag on `git grep` is load-bearing and was caught by a needle, not by review.** `git grep` defaults to _basic_ regex, in which `(`, `|` and `{7}` are literal characters. Without `-E` the patterns match nothing and the check reports a clean pass over a file full of conflict markers — the exact defect this tool exists to prevent, shipped inside the tool itself.
|
|
||||||
|
|
||||||
### Then the review turned on the harness, and found three more
|
|
||||||
|
|
||||||
A second independent review ran everything from a fresh clone and reproduced three defects — **all of them in the tools written to prevent defects.** None was in `push-guard.sh`.
|
|
||||||
|
|
||||||
**1. The clean-clone verifier could not verify the tree that ships it.** `verify-clean-clone.sh` resolved the repository top level correctly but then passed **bare basenames** to `git ls-tree`, which is a root-relative pathspec. These files really live at `packages/mosaic/framework/tools/git/`, so in place every artifact came back `NOT TRACKED at HEAD` and the verifier exited 1 without ever running. The tool built to stop packaging false-greens was unusable against its own packaging.
|
|
||||||
|
|
||||||
Its suite could not see it, because **every fixture installed the artifacts at the fixture repository root.** 6/6 green proved flat-layout operation and said nothing about the deployed path. That is the third time on this tool that a control validated a _model_ instead of the _subject_: v1 of the verifier measured a `cp`'d scratch repo, and then v2's own tests measured a layout that does not exist. **A fixture is a claim about the world; an untested fixture is an unreviewed one.** The committed prefix now comes from `git rev-parse --show-prefix` and is threaded through `ls-tree`, the cloned `stat`, and the suite's working directory; `w6-nested` builds the real nested layout, `w6-prefix` asserts the verifier _reports_ that prefix (so a green `w6` cannot mean the prefix was harmlessly ignored), and `w7-nested-mode` proves the mode needle still bites down there.
|
|
||||||
|
|
||||||
**2. Any pre-existing suite failure satisfied every mutant.** The generator called a mutant `KILLED` whenever `failed > 0`, and never required a green baseline. Inject one always-failing case that changes no guard behaviour whatsoever and the run reports _13 killed, 0 survived_, emits the table above, and exits 0. **A tally is not evidence; a named delta is.** The generator now refuses outright unless the unmodified baseline is exit-0 with zero failures — and emits no table when it refuses — then scores each mutant by the _named cases_ that stopped passing, printing the first one (`by: <case>`) beside every kill.
|
|
||||||
|
|
||||||
**3. An interrupted run stranded a mutated `push-guard.sh` in the reviewed tree.** Restoration leaned on an `EXIT` trap. **A trap is cleanup, not isolation, and SIGKILL cannot run it.** It happened to the reviewer twice and contaminated the following suite run until the clone was discarded. Isolation is now by construction: the guard and suite are `install`ed into a temp dir and every mutation is applied to _that_ copy, so the reviewed file is never opened for writing at all.
|
|
||||||
|
|
||||||
Note the deliberate asymmetry with `verify-clean-clone.sh`, which forbids `cp` anywhere in the file. The rule is not "never copy" — it is **know whether the copy preserves the property you are about to measure.** `cp` launders mode, so the verifier must not copy; mutation is destructive by design, so the generator must.
|
|
||||||
|
|
||||||
`test-mutate-push-guard.sh` (8 cases) now covers all three: `g1-*` proves a red baseline is refused with no table, `g2-*` is the positive control plus an assertion that kills are attributed by name, and `g3-*` kills the generator mid-mutation and asserts the subject is byte-identical afterwards.
|
|
||||||
|
|
||||||
That last one was **vacuous on its first attempt.** `timeout -s KILL 3` looked convincing and proved nothing: at three seconds the generator is still running its baseline, so no mutation has been applied and the subject is trivially unchanged — for the _unfixed_ in-place generator too, which I confirmed by rebuilding it and running it. The kill is now driven from inside the run (the fixture's suite counts its own invocations and kills the generator on the second, when mutant #1 is applied), and `g3-needle-bites` puts the reconstructed pre-fix mechanism through the identical kill to prove it _does_ strand a mutated file. A control written to close a blocker was itself a member of the vacuous family.
|
|
||||||
|
|
||||||
Two smaller things fell out of building those cases, both worth recording because both read as the opposite of what they were:
|
|
||||||
|
|
||||||
- **`grep -q` under `pipefail` turns a successful match into a failed assertion.** `grep -q` exits at the first match, the producer dies of SIGPIPE, and `pipefail` reports 141. This cost a red `w6-prefix` against a verifier that was printing the right prefix all along. Capture into a variable and test the variable.
|
|
||||||
- **`$PPID` inside `$( )` is the subshell, not the caller.** Killing it merely ends the command substitution; the parent carries on and exits 0. The fixture uses `kill -9 0` (the process group) with the generator launched under `setsid --wait`.
|
|
||||||
|
|
||||||
**Linting is measured at default severity, and the earlier claim was not.** "shellcheck clean on all five" was published on the strength of `shellcheck -S warning`, which exited 0 — while the default severity exited 1 with twelve `SC2016` findings. A filtered measurement reported as an unfiltered claim is the same shape as everything else on this page. Those literals genuinely must not expand, so `run_mutants()` carries one scoped, documented `SC2016` suppression; all six files are now clean at **default** severity. (A documented "this literal is intentionally unexpanded" is a different thing from a comment asserting a safety property nobody rechecks.)
|
|
||||||
|
|
||||||
**Not independently reproduced here:** blocker 1's original repro ran against the real PR checkout, and this session has no credential for that remote. The `w6`/`w7` fixtures replicate the layout at the exact deployed prefix instead, which is a reconstruction, not the original observation. Stated rather than glossed.
|
|
||||||
|
|
||||||
## Proposed framework path
|
|
||||||
|
|
||||||
```
|
|
||||||
framework/tools/git/push-guard.sh # the guard
|
|
||||||
framework/tools/git/test-push-guard.sh # 46 needles and controls
|
|
||||||
framework/tools/git/mutate-push-guard.sh # regenerates the mutation table above
|
|
||||||
framework/tools/git/test-mutate-push-guard.sh # 8 needles for the generator
|
|
||||||
framework/tools/git/verify-clean-clone.sh # proves the COMMITTED artifact runs
|
|
||||||
framework/tools/git/test-verify-clean-clone.sh # 9 needles for the verifier
|
|
||||||
```
|
|
||||||
|
|
||||||
Matches the existing `tools/git/test-*.sh` convention. Dependencies: bash 4.4+, git, python3 — `python3` is already an accepted dependency of `ci-queue-wait.sh`.
|
|
||||||
|
|
||||||
**All six must be committed mode `100755`.** They were once delivered `100644`, so a clone exited `126 Permission denied` for everyone who was not the author; `verify-clean-clone.sh` exists to make that unshippable and asserts the mode from `git ls-tree` of the source commit, never from the filesystem.
|
|
||||||
|
|
||||||
Operator-agnostic: no hostnames, credentials, remotes, or operator-specific paths. Clean under the framework-PR firewall.
|
|
||||||
@@ -1,793 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# push-guard.sh - Mechanical guards against verifications that PASS when the
|
|
||||||
# thing they verify never happened.
|
|
||||||
#
|
|
||||||
# WHY THIS EXISTS
|
|
||||||
# ---------------
|
|
||||||
# Three independent incidents, one shape:
|
|
||||||
# 1. An agent's "PUSH VERIFIED" step compared local HEAD to the remote ref and
|
|
||||||
# reported success. The commit had ABORTED, so local trivially equalled
|
|
||||||
# remote. The guard could not distinguish "pushed" from "committed nothing".
|
|
||||||
# 2. An agent chained a push after a FAILED commit, ran on stale state, and
|
|
||||||
# pushed an EMPTY commit carrying an unrelated commit's message.
|
|
||||||
# 3. An agent committed unresolved conflict markers into 70 generated files and
|
|
||||||
# pushed invalid JSON to a default branch.
|
|
||||||
#
|
|
||||||
# All three are the same defect: a check whose success condition is satisfied by
|
|
||||||
# the null case. This script asserts the POSITIVE fact (a new object exists, the
|
|
||||||
# remote MOVED, the content parses) rather than the absence of an error.
|
|
||||||
#
|
|
||||||
# DESIGN RULES (do not relax these)
|
|
||||||
# * Every check exits NON-ZERO and names what failed. There is deliberately no
|
|
||||||
# --warn-only / --soft flag: a guard that can degrade to a warning is the
|
|
||||||
# fail-open we are trying to remove.
|
|
||||||
# * Checks are fail-CLOSED. `set -euo pipefail` means an unexpected git or
|
|
||||||
# network error aborts non-zero rather than falling through to "OK".
|
|
||||||
# * Nothing is skipped silently. A check with no applicable inputs says so on
|
|
||||||
# stdout instead of contributing a quiet green.
|
|
||||||
#
|
|
||||||
# EXIT CODES
|
|
||||||
# 0 all requested checks passed
|
|
||||||
# 2 conflict markers present, or the index has unmerged paths
|
|
||||||
# 3 staged JSON does not parse
|
|
||||||
# 4 push verification failed (remote did not move, or moved elsewhere)
|
|
||||||
# 5 nothing staged / empty commit — the null case, refused
|
|
||||||
# 6 NO JSON DECISION — no --json-path and no usable .push-guard.json
|
|
||||||
# 64 usage error
|
|
||||||
#
|
|
||||||
# Dependencies: bash 4.4+, git, python3.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
readonly EX_OK=0
|
|
||||||
readonly EX_CONFLICT=2
|
|
||||||
readonly EX_JSON=3
|
|
||||||
readonly EX_PUSH=4
|
|
||||||
readonly EX_NULL=5
|
|
||||||
readonly EX_CONFIG=6
|
|
||||||
readonly EX_USAGE=64
|
|
||||||
|
|
||||||
# Repo-level configuration. Its ABSENCE is refused, not defaulted — see
|
|
||||||
# resolve_json_config() for why.
|
|
||||||
readonly CONFIG_FILE=".push-guard.json"
|
|
||||||
|
|
||||||
# Conflict-marker detection.
|
|
||||||
#
|
|
||||||
# We match `<<<<<<<`, `>>>>>>>` and the diff3 `|||||||` marker, each anchored at
|
|
||||||
# column 0 and required to be followed by a space or end-of-line (real markers
|
|
||||||
# are exactly seven characters plus an optional ref label).
|
|
||||||
#
|
|
||||||
# We deliberately do NOT match a bare `=======` line. It is the one marker that
|
|
||||||
# collides with ordinary prose — reStructuredText section underlines and ASCII
|
|
||||||
# rules use it constantly — and a guard that fires on documentation gets switched
|
|
||||||
# off, which costs more than the miss. Every genuine conflict git writes contains
|
|
||||||
# `<<<<<<<` and `>>>>>>>` as well, so requiring those loses no real detection.
|
|
||||||
readonly MARKER_PATTERNS=(
|
|
||||||
-e '^<<<<<<<( |$)'
|
|
||||||
-e '^>>>>>>>( |$)'
|
|
||||||
-e '^[|]{7}( |$)'
|
|
||||||
)
|
|
||||||
|
|
||||||
log() { printf '%s\n' "$*"; }
|
|
||||||
fail() {
|
|
||||||
local code="$1"; shift
|
|
||||||
printf '\n' >&2
|
|
||||||
printf 'PUSH-GUARD FAILED: %s\n' "$1" >&2
|
|
||||||
shift
|
|
||||||
local line
|
|
||||||
for line in "$@"; do printf ' %s\n' "$line" >&2; done
|
|
||||||
printf '\n' >&2
|
|
||||||
exit "$code"
|
|
||||||
}
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage:
|
|
||||||
push-guard.sh check-staged [--json-path <pathspec>]...
|
|
||||||
[--allow-invalid-json <pathspec>]...
|
|
||||||
push-guard.sh push --remote <remote> --branch <branch> [--since-head <sha>]
|
|
||||||
[-- <extra git push args>...]
|
|
||||||
push-guard.sh --help
|
|
||||||
|
|
||||||
Subcommands:
|
|
||||||
check-staged Run pre-commit content guards against the STAGED index:
|
|
||||||
(a) no unmerged index paths, no conflict markers
|
|
||||||
(c) staged JSON under the nominated paths parses
|
|
||||||
Refuses to pass when nothing is staged (exit 5).
|
|
||||||
Check (c) requires an EXPLICIT decision — either --json-path,
|
|
||||||
or .push-guard.json. Saying nothing is refused (exit 6).
|
|
||||||
|
|
||||||
push Perform a push and prove it HAPPENED:
|
|
||||||
- HEAD is a non-empty commit (differs from its parent)
|
|
||||||
- with --since-head: HEAD advanced past that sha
|
|
||||||
- the remote ref MOVED, and now equals local HEAD
|
|
||||||
Capturing the remote ref BEFORE the push is what makes
|
|
||||||
"remote == local" meaningful; without it the assertion is
|
|
||||||
satisfied by having pushed nothing.
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--json-path <pathspec> Git pathspec of generated/serialized JSON to
|
|
||||||
parse-check, e.g. 'data/**/*.json'. Repeatable.
|
|
||||||
Opt-in on purpose: many real .json files are
|
|
||||||
JSONC (tsconfig) or templates and do NOT parse
|
|
||||||
strictly. Nominate the generated ones. Satisfies
|
|
||||||
the decision requirement on its own; overrides a
|
|
||||||
config opt-out (loudly).
|
|
||||||
--allow-invalid-json <pathspec> Exempt a path from the JSON parse check
|
|
||||||
(for deliberate malformed-input fixtures).
|
|
||||||
Exemptions are printed, never silent.
|
|
||||||
--since-head <sha> HEAD before your commit step. Asserts a new
|
|
||||||
commit object was actually created.
|
|
||||||
--remote <name> Remote to push to.
|
|
||||||
--branch <name> Branch to push.
|
|
||||||
|
|
||||||
Repo config (.push-guard.json, at the repository root):
|
|
||||||
{"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
|
|
||||||
— nominate generated JSON for parse-checking.
|
|
||||||
{"json_check": "none", "reason": "no generated JSON in this repo"}
|
|
||||||
— opt out. The reason is REQUIRED and is printed on every run.
|
|
||||||
|
|
||||||
An opt-out is only accepted from this file, never from a command-line flag: a
|
|
||||||
committed reason can be read in a diff, an absent flag cannot be reviewed at all.
|
|
||||||
An invalid config is refused outright rather than treated as absent.
|
|
||||||
|
|
||||||
There is no flag to downgrade a failure to a warning. That is intentional.
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
require_repo() {
|
|
||||||
git rev-parse --show-toplevel >/dev/null 2>&1 \
|
|
||||||
|| fail "$EX_USAGE" "not inside a git repository"
|
|
||||||
cd "$(git rev-parse --show-toplevel)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- check-staged
|
|
||||||
|
|
||||||
# staged_files_z <array-name> [pathspec...]
|
|
||||||
#
|
|
||||||
# THE FAIL-OPEN THIS FUNCTION EXISTS TO REMOVE:
|
|
||||||
# mapfile -d '' -t files < <(git diff ...)
|
|
||||||
# observes MAPFILE's exit status, never the producer's. When git diff dies -- a
|
|
||||||
# bad pathspec, a corrupt index -- mapfile cheerfully reads zero bytes and
|
|
||||||
# succeeds, and the caller then reports that silence as "nothing to check".
|
|
||||||
#
|
|
||||||
# `set -o pipefail` does NOT cover this. pipefail applies to PIPELINES; a process
|
|
||||||
# substitution is an asynchronous child whose status is never collected. That is
|
|
||||||
# the precise reason every `cmd | cmd` in this file is safe and both `< <(cmd)`
|
|
||||||
# constructs were not -- a distinction worth stating, because "we set pipefail"
|
|
||||||
# reads like whole-file coverage and is not.
|
|
||||||
#
|
|
||||||
# Both callers now route through here, so the category is gone rather than the
|
|
||||||
# two known instances being patched.
|
|
||||||
staged_files_z() {
|
|
||||||
local -n _out="$1"; shift
|
|
||||||
local tmp err status=0
|
|
||||||
tmp="$(mktemp)"; err="$(mktemp)"
|
|
||||||
if (( $# )); then
|
|
||||||
git diff --cached --name-only --diff-filter=ACM --no-renames -z \
|
|
||||||
-- "$@" >"$tmp" 2>"$err" || status=$?
|
|
||||||
else
|
|
||||||
git diff --cached --name-only --diff-filter=ACM --no-renames -z \
|
|
||||||
>"$tmp" 2>"$err" || status=$?
|
|
||||||
fi
|
|
||||||
if (( status != 0 )); then
|
|
||||||
local msg; msg="$(tr '\n' ' ' <"$err")"
|
|
||||||
rm -f "$tmp" "$err"
|
|
||||||
fail "$EX_CONFIG" \
|
|
||||||
"CANNOT ENUMERATE STAGED FILES — git diff exited $status" \
|
|
||||||
"git said: ${msg:-(no message)}" \
|
|
||||||
"" \
|
|
||||||
"Refusing to report a result. An enumeration that failed returns no" \
|
|
||||||
"files, which is indistinguishable from a clean tree — reporting that" \
|
|
||||||
"as OK would be a silent pass on an unexamined index."
|
|
||||||
fi
|
|
||||||
_out=()
|
|
||||||
mapfile -d '' -t _out <"$tmp"
|
|
||||||
rm -f "$tmp" "$err"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_unmerged() {
|
|
||||||
local unmerged
|
|
||||||
unmerged="$(git ls-files --unmerged | awk '{print $4}' | sort -u)"
|
|
||||||
if [[ -n "$unmerged" ]]; then
|
|
||||||
mapfile -t paths <<<"$unmerged"
|
|
||||||
fail "$EX_CONFLICT" \
|
|
||||||
"the index has UNMERGED paths — a merge/rebase is still in progress" \
|
|
||||||
"${paths[@]}" \
|
|
||||||
"" \
|
|
||||||
"Resolve the conflict and 'git add' the result. Do not commit past this."
|
|
||||||
fi
|
|
||||||
log " ok index has no unmerged paths"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_conflict_markers() {
|
|
||||||
local -a files=("$@")
|
|
||||||
local hits
|
|
||||||
# git grep --cached searches the INDEX (what will actually be committed),
|
|
||||||
# not the working tree.
|
|
||||||
#
|
|
||||||
# -a (treat every blob as text), NOT -I (skip binary). -I honours git's
|
|
||||||
# binary classification, which includes an explicit `.gitattributes` `binary`
|
|
||||||
# attribute. A repo that marks a path binary would have its staged conflict
|
|
||||||
# markers silently skipped while the summary still counted the file as
|
|
||||||
# scanned — a clean pass AND a false count. Verified reproducible. The cost
|
|
||||||
# of -a is that a genuine binary blob could theoretically emit a noisy match
|
|
||||||
# line; a false alarm is recoverable, a silent miss is not.
|
|
||||||
# -E is load-bearing: git grep defaults to BASIC regex, in which '(', '|'
|
|
||||||
# and '{7}' are literal characters. Without it these patterns match nothing
|
|
||||||
# and the check reports a clean pass over a file full of markers. That
|
|
||||||
# failure was caught by the needle, not by review.
|
|
||||||
#
|
|
||||||
# Exit status is discriminated, NOT swallowed. git grep returns 1 for "no
|
|
||||||
# match" and >=2 for a real error (bad pathspec, unreadable index). A blanket
|
|
||||||
# '|| true' would turn a broken scan into "ok, no conflict markers" — the
|
|
||||||
# same fail-open in the guard's own error handling.
|
|
||||||
local status=0
|
|
||||||
hits="$(git grep --cached -a -n -E "${MARKER_PATTERNS[@]}" -- "${files[@]}")" || status=$?
|
|
||||||
if (( status > 1 )); then
|
|
||||||
fail "$EX_CONFLICT" \
|
|
||||||
"git grep FAILED (exit $status) — the conflict scan did not run" \
|
|
||||||
"Refusing to report a clean result from a scan that did not complete."
|
|
||||||
fi
|
|
||||||
if [[ -n "$hits" ]]; then
|
|
||||||
mapfile -t lines <<<"$hits"
|
|
||||||
fail "$EX_CONFLICT" \
|
|
||||||
"staged content contains unresolved CONFLICT MARKERS" \
|
|
||||||
"${lines[@]}" \
|
|
||||||
"" \
|
|
||||||
"These are staged and would be committed verbatim."
|
|
||||||
fi
|
|
||||||
log " ok no conflict markers in ${#files[@]} staged file(s)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# JSON parse check.
|
|
||||||
#
|
|
||||||
# SCOPE IS OPT-IN, AND THAT IS A MEASURED DECISION, NOT LAZINESS.
|
|
||||||
# The first draft checked every staged *.json. Run against a real repository that
|
|
||||||
# turned out to be 17 of 119 tracked .json files failing a strict parse — all of
|
|
||||||
# them legitimate: every tsconfig*.json is JSONC (comments are legal there) and
|
|
||||||
# the CA templates are Go templates that merely carry a .json suffix. An
|
|
||||||
# on-by-default check would have fired on ~14% of this repo's JSON, and a guard
|
|
||||||
# that cries wolf gets routed around until the bypass is habitual — which is
|
|
||||||
# worse than no guard, because the bypass then also covers the true positives.
|
|
||||||
#
|
|
||||||
# So the caller nominates the generated/serialized paths this check is FOR, as
|
|
||||||
# git pathspecs (git, not bash, expands them — '**' works correctly).
|
|
||||||
#
|
|
||||||
# WHAT SAYING NOTHING NOW COSTS YOU
|
|
||||||
# ---------------------------------
|
|
||||||
# The first release announced "JSON check NOT REQUESTED" and continued. That was
|
|
||||||
# visible rather than silent, which is better — but it is still an ABSENCE, and
|
|
||||||
# an absence is not reviewable. Nobody reads a line that has printed correctly
|
|
||||||
# ten thousand times. A repo that needed the check and never wired it up stayed
|
|
||||||
# unprotected forever, and nothing ever failed.
|
|
||||||
#
|
|
||||||
# The decision is now MANDATORY and must come from one of two places:
|
|
||||||
# * turning the check ON — --json-path on the command line, or "json_paths"
|
|
||||||
# in .push-guard.json
|
|
||||||
# * turning the check OFF — ONLY "json_check": "none" in .push-guard.json,
|
|
||||||
# which REQUIRES a non-empty "reason"
|
|
||||||
# Saying nothing at all is refused (exit 6).
|
|
||||||
#
|
|
||||||
# The asymmetry is deliberate. Turning a check on is safe from anywhere. Turning
|
|
||||||
# one OFF is confined to a committed file because that is the only form a human
|
|
||||||
# can review: you can read a reason in a diff, and you cannot review the fact
|
|
||||||
# that nobody typed a flag. An opt-out that lives in an ad-hoc command line is
|
|
||||||
# just the old fail-open with extra steps.
|
|
||||||
# The config parser, held as a string rather than an inline heredoc.
|
|
||||||
#
|
|
||||||
# A `<<'PY'` heredoc INSIDE a $( ) command substitution makes bash emit
|
|
||||||
# warning: command substitution: 1 unterminated here-document
|
|
||||||
# on every single run. It still executed, every needle stayed green and the
|
|
||||||
# static linter stayed clean — the warning goes to stderr and broke no
|
|
||||||
# assertion. It surfaced only when the guard was run against a real
|
|
||||||
# repository. A tool built to refuse quiet output was quietly polluting
|
|
||||||
# stderr; needle 'z1' now asserts the guard emits no warnings at all.
|
|
||||||
#
|
|
||||||
# `read -d ''` returns non-zero at EOF without finding a NUL, which is the
|
|
||||||
# NORMAL path when slurping a heredoc — hence `|| true`. That is the one
|
|
||||||
# shape where it does not mask a real error, unlike the `git grep || true`
|
|
||||||
# this codebase already removed once.
|
|
||||||
IFS='' read -r -d '' CONFIG_PARSER <<'PY' || true
|
|
||||||
import json, sys
|
|
||||||
|
|
||||||
path = sys.argv[1]
|
|
||||||
try:
|
|
||||||
with open(path) as fh:
|
|
||||||
cfg = json.load(fh)
|
|
||||||
except Exception as exc:
|
|
||||||
sys.stderr.write("does not parse: %s" % exc)
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
if not isinstance(cfg, dict):
|
|
||||||
sys.stderr.write("must contain a JSON object, got %s" % type(cfg).__name__)
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
def clean_list(name, value):
|
|
||||||
if not isinstance(value, list):
|
|
||||||
sys.stderr.write('"%s" must be an array' % name)
|
|
||||||
sys.exit(2)
|
|
||||||
for item in value:
|
|
||||||
if not isinstance(item, str) or not item.strip():
|
|
||||||
sys.stderr.write('"%s" must contain only non-empty strings' % name)
|
|
||||||
sys.exit(2)
|
|
||||||
# Tabs/newlines would be mangled by the tab-delimited handoff below.
|
|
||||||
# Reject them explicitly rather than silently truncating a pathspec.
|
|
||||||
if "\t" in item or "\n" in item:
|
|
||||||
sys.stderr.write('"%s" entry contains a tab or newline: %r' % (name, item))
|
|
||||||
sys.exit(2)
|
|
||||||
return value
|
|
||||||
|
|
||||||
mode = cfg.get("json_check")
|
|
||||||
paths = cfg.get("json_paths")
|
|
||||||
allow = cfg.get("allow_invalid_json", [])
|
|
||||||
|
|
||||||
if mode is not None and mode != "none":
|
|
||||||
sys.stderr.write('"json_check" must be "none" if present, got %r' % (mode,))
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
if mode == "none":
|
|
||||||
if paths:
|
|
||||||
sys.stderr.write('"json_check": "none" and "json_paths" are mutually exclusive')
|
|
||||||
sys.exit(2)
|
|
||||||
reason = cfg.get("reason")
|
|
||||||
if not isinstance(reason, str) or not reason.strip():
|
|
||||||
sys.stderr.write('"json_check": "none" REQUIRES a non-empty "reason"')
|
|
||||||
sys.exit(2)
|
|
||||||
print("MODE\tnone")
|
|
||||||
print("REASON\t%s" % reason.strip().replace("\t", " ").replace("\n", " "))
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
if paths is None:
|
|
||||||
sys.stderr.write(
|
|
||||||
'states no decision — needs "json_paths", '
|
|
||||||
'or "json_check": "none" with a "reason"'
|
|
||||||
)
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
clean_list("json_paths", paths)
|
|
||||||
if not paths:
|
|
||||||
sys.stderr.write('"json_paths" must not be empty')
|
|
||||||
sys.exit(2)
|
|
||||||
clean_list("allow_invalid_json", allow)
|
|
||||||
|
|
||||||
print("MODE\tcheck")
|
|
||||||
for item in paths:
|
|
||||||
print("JPATH\t%s" % item)
|
|
||||||
for item in allow:
|
|
||||||
print("ALLOW\t%s" % item)
|
|
||||||
PY
|
|
||||||
|
|
||||||
resolve_json_config() {
|
|
||||||
local cfg out status=0
|
|
||||||
cfg="$(git rev-parse --show-toplevel)/$CONFIG_FILE"
|
|
||||||
|
|
||||||
if [[ ! -f "$cfg" ]]; then
|
|
||||||
CFG_MODE="absent"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A malformed config must REFUSE, never degrade to "treat as absent". That
|
|
||||||
# fallback would rebuild precisely the fail-open this gate closes: a typo in
|
|
||||||
# the config would silently disable the check it was written to enable.
|
|
||||||
out="$(python3 -c "$CONFIG_PARSER" "$cfg" 2>&1)" || status=$?
|
|
||||||
|
|
||||||
if (( status != 0 )); then
|
|
||||||
fail "$EX_CONFIG" \
|
|
||||||
"$CONFIG_FILE is INVALID — $out" \
|
|
||||||
"" \
|
|
||||||
"Refusing to run. A config that does not parse is not the same as no" \
|
|
||||||
"config: treating it as absent would silently disable the very check" \
|
|
||||||
"this file was written to enable."
|
|
||||||
fi
|
|
||||||
|
|
||||||
local key val
|
|
||||||
while IFS=$'\t' read -r key val; do
|
|
||||||
case "$key" in
|
|
||||||
MODE) CFG_MODE="$val" ;;
|
|
||||||
REASON) CFG_REASON="$val" ;;
|
|
||||||
JPATH) CFG_PATHS+=("$val") ;;
|
|
||||||
ALLOW) CFG_ALLOW+=("$val") ;;
|
|
||||||
esac
|
|
||||||
done <<<"$out"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# THE ASYMMETRY, ENFORCED RATHER THAN INTENDED.
|
|
||||||
# ON may come from anywhere: turning a check on needs no review.
|
|
||||||
# OFF must come from a COMMITTED object, because the entire argument for
|
|
||||||
# requiring a written reason was that a reason is reviewable in a diff
|
|
||||||
# while an absence is not. An opt-out honoured from an untracked
|
|
||||||
# working-tree file is not reviewable either -- it is an ad-hoc local
|
|
||||||
# bypass wearing a config file's clothes, and it defeats the design.
|
|
||||||
# So the OFF decision is re-read from HEAD and the working tree gets no
|
|
||||||
# vote in it. A committed ON flipped to OFF locally is likewise refused.
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
[[ "$CFG_MODE" == "none" ]] || return 0
|
|
||||||
|
|
||||||
local rel cmode cblob cout cstatus=0
|
|
||||||
rel="$(git ls-files --full-name --error-unmatch -- "$cfg" 2>/dev/null)" || rel=""
|
|
||||||
if [[ -z "$rel" ]]; then
|
|
||||||
fail "$EX_CONFIG" \
|
|
||||||
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is not tracked in git" \
|
|
||||||
"recorded reason was: ${CFG_REASON:-(none)}" \
|
|
||||||
"" \
|
|
||||||
"The opt-out requires a written reason precisely so it shows up in a" \
|
|
||||||
"diff. An untracked file shows up in nobody's review, so honouring it" \
|
|
||||||
"would rebuild the unreviewable bypass this design exists to prevent." \
|
|
||||||
"" \
|
|
||||||
"Commit $CONFIG_FILE, then run again. Turning the check ON needs no" \
|
|
||||||
"commit; only turning it OFF does."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A COMMITTED SYMLINK (mode 120000) is a mutable target: the reviewed blob
|
|
||||||
# is a path, and what it points at can change without any diff at all.
|
|
||||||
cmode="$(git ls-tree HEAD -- "$rel" 2>/dev/null | awk '{print $1}')"
|
|
||||||
if [[ -z "$cmode" ]]; then
|
|
||||||
fail "$EX_CONFIG" \
|
|
||||||
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is staged but not yet in HEAD" \
|
|
||||||
"recorded reason was: ${CFG_REASON:-(none)}" \
|
|
||||||
"" \
|
|
||||||
"A staged-but-uncommitted opt-out has not been through review either." \
|
|
||||||
"Commit it first."
|
|
||||||
fi
|
|
||||||
if [[ "$cmode" == "120000" ]]; then
|
|
||||||
fail "$EX_CONFIG" \
|
|
||||||
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is a committed SYMLINK" \
|
|
||||||
"" \
|
|
||||||
"The reviewed object would be a path, not a decision: what it points" \
|
|
||||||
"at can be changed later without producing any diff. Commit the" \
|
|
||||||
"config as a regular file."
|
|
||||||
fi
|
|
||||||
|
|
||||||
cblob="$(git show "HEAD:$rel" 2>/dev/null)" || cblob=""
|
|
||||||
cout="$(printf '%s' "$cblob" | python3 -c "$CONFIG_PARSER" /dev/stdin 2>&1)" || cstatus=$?
|
|
||||||
if (( cstatus != 0 )); then
|
|
||||||
fail "$EX_CONFIG" \
|
|
||||||
"COMMITTED $CONFIG_FILE is INVALID — $cout" \
|
|
||||||
"" \
|
|
||||||
"The working tree opts out, but the committed version does not parse," \
|
|
||||||
"so there is no reviewable decision to honour."
|
|
||||||
fi
|
|
||||||
local cmode_val="" creason=""
|
|
||||||
while IFS=$'\t' read -r key val; do
|
|
||||||
case "$key" in
|
|
||||||
MODE) cmode_val="$val" ;;
|
|
||||||
REASON) creason="$val" ;;
|
|
||||||
esac
|
|
||||||
done <<<"$cout"
|
|
||||||
|
|
||||||
if [[ "$cmode_val" != "none" ]]; then
|
|
||||||
fail "$EX_CONFIG" \
|
|
||||||
"LOCAL-ONLY OPT-OUT — the working tree turns the JSON check OFF but" \
|
|
||||||
"HEAD does not (committed decision: $cmode_val)" \
|
|
||||||
"working-tree reason: ${CFG_REASON:-(none)}" \
|
|
||||||
"" \
|
|
||||||
"An uncommitted edit that disables a committed check is exactly the" \
|
|
||||||
"unreviewable bypass this guard refuses. Commit the change if it is" \
|
|
||||||
"real; revert it if it was a local convenience."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Print the COMMITTED reason, never the working tree's: the reason anyone
|
|
||||||
# can actually review is the one in the object.
|
|
||||||
CFG_REASON="$creason"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_staged_json() {
|
|
||||||
local -a checked=() skipped=()
|
|
||||||
local f err
|
|
||||||
local -a pathspec=()
|
|
||||||
|
|
||||||
resolve_json_config
|
|
||||||
|
|
||||||
if (( ${#JSON_PATHS[@]} == 0 )); then
|
|
||||||
case "$CFG_MODE" in
|
|
||||||
absent)
|
|
||||||
fail "$EX_CONFIG" \
|
|
||||||
"NO JSON DECISION — no --json-path, and no $CONFIG_FILE" \
|
|
||||||
"" \
|
|
||||||
"This guard will not run without an explicit decision about" \
|
|
||||||
"staged-JSON validation. Create $CONFIG_FILE with EITHER:" \
|
|
||||||
"" \
|
|
||||||
' {"json_paths": ["data/**/*.json"]}' \
|
|
||||||
"" \
|
|
||||||
"or, if this repo genuinely has no generated JSON:" \
|
|
||||||
"" \
|
|
||||||
' {"json_check": "none", "reason": "<why>"}' \
|
|
||||||
"" \
|
|
||||||
"The reason is mandatory so the opt-out is recorded and" \
|
|
||||||
"reviewable in the diff, instead of being an absence nobody sees."
|
|
||||||
;;
|
|
||||||
none)
|
|
||||||
log " -- JSON check OPTED OUT in $CONFIG_FILE — recorded reason: $CFG_REASON"
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
check)
|
|
||||||
JSON_PATHS=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
|
|
||||||
ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
|
|
||||||
log " .. JSON paths from $CONFIG_FILE: ${JSON_PATHS[*]}"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
# An explicit --json-path turns the check ON, so it satisfies the decision
|
|
||||||
# requirement by itself. If the config opted out, the nomination WINS and
|
|
||||||
# says so loudly — resolving a contradiction toward more checking is the
|
|
||||||
# only safe direction, but silently ignoring a committed opt-out would
|
|
||||||
# hide a real disagreement.
|
|
||||||
case "$CFG_MODE" in
|
|
||||||
none)
|
|
||||||
log " !! $CONFIG_FILE opts out of the JSON check, but --json-path was"
|
|
||||||
log " !! given — honouring the nomination and checking anyway."
|
|
||||||
;;
|
|
||||||
check)
|
|
||||||
JSON_PATHS+=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
|
|
||||||
ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Normalize to :(glob) magic. WITHOUT it, git's default pathspec matching
|
|
||||||
# makes 'data/**/*.json' require at least one intermediate directory, so it
|
|
||||||
# matches data/sub/b.json but SILENTLY SKIPS data/a.json. The obvious
|
|
||||||
# spelling would then deliver partial coverage with no warning — a
|
|
||||||
# quiet-underscan, which is the same family of defect as a quiet pass.
|
|
||||||
# Pathspecs that already carry explicit magic (leading ':') are left alone.
|
|
||||||
for f in "${JSON_PATHS[@]}"; do
|
|
||||||
[[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(glob)$f")
|
|
||||||
done
|
|
||||||
for f in ${ALLOW_INVALID_JSON[@]+"${ALLOW_INVALID_JSON[@]}"}; do
|
|
||||||
[[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(exclude,glob)$f")
|
|
||||||
skipped+=("$f")
|
|
||||||
done
|
|
||||||
|
|
||||||
local -a files=()
|
|
||||||
staged_files_z files "${pathspec[@]}"
|
|
||||||
|
|
||||||
for f in ${files[@]+"${files[@]}"}; do
|
|
||||||
[[ "$f" == *.json ]] || continue
|
|
||||||
|
|
||||||
if ! err="$(git show ":$f" | python3 -c '
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
json.load(sys.stdin)
|
|
||||||
except Exception as exc:
|
|
||||||
sys.stderr.write(str(exc))
|
|
||||||
sys.exit(1)
|
|
||||||
' 2>&1)"; then
|
|
||||||
fail "$EX_JSON" \
|
|
||||||
"staged JSON does not parse: $f" \
|
|
||||||
"$err" \
|
|
||||||
"" \
|
|
||||||
"A serialization error from a generator is a STOP, not something to commit past."
|
|
||||||
fi
|
|
||||||
checked+=("$f")
|
|
||||||
done
|
|
||||||
|
|
||||||
for f in ${skipped[@]+"${skipped[@]}"}; do
|
|
||||||
log " -- JSON check EXEMPTED by --allow-invalid-json: $f"
|
|
||||||
done
|
|
||||||
if (( ${#checked[@]} == 0 )); then
|
|
||||||
log " -- no staged .json under ${JSON_PATHS[*]} — JSON check not applicable"
|
|
||||||
else
|
|
||||||
log " ok ${#checked[@]} staged .json file(s) under ${JSON_PATHS[*]} parse"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_check_staged() {
|
|
||||||
require_repo
|
|
||||||
log "push-guard: checking staged content"
|
|
||||||
|
|
||||||
check_unmerged
|
|
||||||
|
|
||||||
# --no-renames is load-bearing, NOT cosmetic. Git detects renames by default
|
|
||||||
# (diff.renames=true since 2.9), so a `git mv` plus a small edit is reported
|
|
||||||
# as ONE 'R' entry — which --diff-filter=ACM DOES NOT MATCH. The renamed file
|
|
||||||
# becomes invisible to every content check: staged conflict markers sail
|
|
||||||
# through and the guard prints "staged content OK". Verified reproducible at
|
|
||||||
# 97% similarity with an ordinary co-staged file present. --no-renames
|
|
||||||
# decomposes each rename back into D + A so the new path is always seen.
|
|
||||||
# (Adding R to the filter also works, but --name-only -z emits two paths for
|
|
||||||
# a rename and is ambiguous to parse — this removes the category instead.)
|
|
||||||
#
|
|
||||||
# SECOND INSTANCE OF THE SAME DEFECT, found by sweeping for the construct
|
|
||||||
# rather than fixing only the one that was reported. This one failed CLOSED
|
|
||||||
# (zero files hit "NOTHING IS STAGED"), so it was never a security hole --
|
|
||||||
# but it reported a confident WRONG DIAGNOSIS, sending anyone debugging it
|
|
||||||
# at their index instead of at the failing git invocation.
|
|
||||||
local -a files=()
|
|
||||||
staged_files_z files
|
|
||||||
if (( ${#files[@]} == 0 )); then
|
|
||||||
fail "$EX_NULL" \
|
|
||||||
"NOTHING IS STAGED" \
|
|
||||||
"About to commit, but the index is identical to HEAD." \
|
|
||||||
"" \
|
|
||||||
"This is the null case: a commit here is empty, and any later" \
|
|
||||||
"'verified' step would be asserting against content that does not exist."
|
|
||||||
fi
|
|
||||||
|
|
||||||
check_conflict_markers "${files[@]}"
|
|
||||||
check_staged_json "${files[@]}"
|
|
||||||
|
|
||||||
log "push-guard: staged content OK"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------ push
|
|
||||||
|
|
||||||
remote_sha() {
|
|
||||||
# Empty output means the branch does not exist on the remote yet.
|
|
||||||
# A network/auth failure makes ls-remote non-zero, which set -e turns into an
|
|
||||||
# abort — the guard never treats an unreachable remote as "unchanged".
|
|
||||||
git ls-remote "$1" "refs/heads/$2" | awk 'NR==1{print $1}'
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_push() {
|
|
||||||
require_repo
|
|
||||||
local remote="" branch="" since_head=""
|
|
||||||
local -a extra=()
|
|
||||||
|
|
||||||
while (( $# )); do
|
|
||||||
case "$1" in
|
|
||||||
--remote) remote="${2:-}"; shift 2 ;;
|
|
||||||
--branch) branch="${2:-}"; shift 2 ;;
|
|
||||||
--since-head) since_head="${2:-}"; shift 2 ;;
|
|
||||||
--) shift; extra=("$@"); break ;;
|
|
||||||
*) fail "$EX_USAGE" "unknown argument to push: $1" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
[[ -n "$remote" && -n "$branch" ]] \
|
|
||||||
|| fail "$EX_USAGE" "push requires --remote and --branch"
|
|
||||||
|
|
||||||
local head
|
|
||||||
head="$(git rev-parse HEAD)"
|
|
||||||
log "push-guard: verifying push of $head -> $remote/$branch"
|
|
||||||
|
|
||||||
# (1) Did a new commit actually get created?
|
|
||||||
if [[ -n "$since_head" ]]; then
|
|
||||||
git rev-parse --verify --quiet "${since_head}^{commit}" >/dev/null \
|
|
||||||
|| fail "$EX_USAGE" \
|
|
||||||
"--since-head is not a commit in this repository: $since_head" \
|
|
||||||
"" \
|
|
||||||
"Cannot verify advancement against a start point that does not exist."
|
|
||||||
|
|
||||||
if [[ "$head" == "$since_head" ]]; then
|
|
||||||
fail "$EX_NULL" \
|
|
||||||
"HEAD DID NOT ADVANCE — no commit was created" \
|
|
||||||
"HEAD is still $head" \
|
|
||||||
"" \
|
|
||||||
"Your commit step failed and execution continued on stale state." \
|
|
||||||
"Pushing now would publish something you did not just build."
|
|
||||||
fi
|
|
||||||
# INEQUALITY IS NOT ADVANCEMENT. "different from where I started" is
|
|
||||||
# satisfied by checking out any unrelated commit -- including one that
|
|
||||||
# existed long before this session -- which would let pre-existing work
|
|
||||||
# be published as something just built. The claim being made is that new
|
|
||||||
# commits were created ON TOP OF the recorded start point, and only
|
|
||||||
# ancestry states that.
|
|
||||||
if ! git merge-base --is-ancestor "$since_head" "$head"; then
|
|
||||||
fail "$EX_NULL" \
|
|
||||||
"HEAD IS NOT A DESCENDANT of the recorded start point" \
|
|
||||||
"start: $since_head" \
|
|
||||||
"head : $head" \
|
|
||||||
"" \
|
|
||||||
"HEAD differs from the start point but does not build on it, so" \
|
|
||||||
"this is a checkout of other history rather than work you just" \
|
|
||||||
"created. Being different is not the same as having advanced."
|
|
||||||
fi
|
|
||||||
log " ok HEAD advanced ${since_head:0:9} -> ${head:0:9} (descendant)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# (2) Is that commit non-empty?
|
|
||||||
# EXEMPTING BY PARENT COUNT WAS A FAIL-OPEN. An empty root commit sailed
|
|
||||||
# through "emptiness check not applicable" and was then reported as a
|
|
||||||
# CONFIRMED PUSH -- directly contradicting the non-empty requirement this
|
|
||||||
# step exists to enforce. Root and merge commits do have well-defined
|
|
||||||
# emptiness; the original code declined to define it, which is not the same
|
|
||||||
# as it being undefined.
|
|
||||||
local parents
|
|
||||||
parents="$(git rev-list --parents -n 1 HEAD | wc -w)"
|
|
||||||
if (( parents == 2 )); then # sha + exactly one parent
|
|
||||||
if git diff --quiet HEAD^ HEAD; then
|
|
||||||
fail "$EX_NULL" \
|
|
||||||
"HEAD IS AN EMPTY COMMIT — it changes nothing against its parent" \
|
|
||||||
"commit $head" \
|
|
||||||
"" \
|
|
||||||
"An empty commit carrying a real message is indistinguishable" \
|
|
||||||
"from real work in the log. Refusing to push it."
|
|
||||||
fi
|
|
||||||
log " ok HEAD is a non-empty commit"
|
|
||||||
elif (( parents > 2 )); then
|
|
||||||
# A merge is empty when its tree matches EVERY parent: it then carries
|
|
||||||
# no content of its own and no integration either.
|
|
||||||
local p all_same=yes
|
|
||||||
for p in $(git rev-list --parents -n 1 HEAD | cut -d' ' -f2-); do
|
|
||||||
git diff --quiet "$p" HEAD || { all_same=no; break; }
|
|
||||||
done
|
|
||||||
if [[ "$all_same" == yes ]]; then
|
|
||||||
fail "$EX_NULL" \
|
|
||||||
"HEAD IS AN EMPTY MERGE — its tree is identical to every parent" \
|
|
||||||
"commit $head" \
|
|
||||||
"" \
|
|
||||||
"It integrates nothing and introduces nothing. Refusing to push it."
|
|
||||||
fi
|
|
||||||
log " ok HEAD is a non-empty merge commit"
|
|
||||||
else
|
|
||||||
# A root commit has no parent, so emptiness is measured against the
|
|
||||||
# empty tree: it is empty when it adds no paths at all.
|
|
||||||
if [[ -z "$(git diff-tree --root -r --name-only --no-commit-id HEAD)" ]]; then
|
|
||||||
fail "$EX_NULL" \
|
|
||||||
"HEAD IS AN EMPTY ROOT COMMIT — it introduces no files" \
|
|
||||||
"commit $head" \
|
|
||||||
"" \
|
|
||||||
"A root commit is empty when it adds nothing against the empty" \
|
|
||||||
"tree. Refusing to push it."
|
|
||||||
fi
|
|
||||||
log " ok HEAD is a non-empty root commit"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# (3) Capture the remote BEFORE. This is the step whose absence made the
|
|
||||||
# original "PUSH VERIFIED" a false positive.
|
|
||||||
local before after
|
|
||||||
before="$(remote_sha "$remote" "$branch")"
|
|
||||||
log " .. remote before: ${before:-<branch does not exist>}"
|
|
||||||
|
|
||||||
git push "$remote" "HEAD:refs/heads/$branch" ${extra[@]+"${extra[@]}"}
|
|
||||||
|
|
||||||
after="$(remote_sha "$remote" "$branch")"
|
|
||||||
log " .. remote after: ${after:-<absent>}"
|
|
||||||
|
|
||||||
# (4) The remote must now equal local HEAD...
|
|
||||||
if [[ "$after" != "$head" ]]; then
|
|
||||||
fail "$EX_PUSH" \
|
|
||||||
"REMOTE DOES NOT MATCH LOCAL HEAD after push" \
|
|
||||||
"local HEAD: $head" \
|
|
||||||
"remote $branch: ${after:-<absent>}" \
|
|
||||||
"" \
|
|
||||||
"The push did not land what you are holding."
|
|
||||||
fi
|
|
||||||
# (5) ...AND it must have MOVED to get there. Without this, a push that
|
|
||||||
# transferred nothing passes step (4) trivially.
|
|
||||||
if [[ "$after" == "$before" ]]; then
|
|
||||||
fail "$EX_PUSH" \
|
|
||||||
"REMOTE DID NOT MOVE — nothing was actually pushed" \
|
|
||||||
"remote was already at $after before this push ran" \
|
|
||||||
"" \
|
|
||||||
"'remote == local' is satisfied by having pushed nothing. That is" \
|
|
||||||
"the false positive this guard exists to catch: the work you think" \
|
|
||||||
"you just published was already there, or was never committed."
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "push-guard: PUSH CONFIRMED ${before:0:9}${before:+ }-> ${after:0:9}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------ main
|
|
||||||
|
|
||||||
ALLOW_INVALID_JSON=()
|
|
||||||
JSON_PATHS=()
|
|
||||||
|
|
||||||
CFG_MODE="absent"
|
|
||||||
CFG_REASON=""
|
|
||||||
CFG_PATHS=()
|
|
||||||
CFG_ALLOW=()
|
|
||||||
|
|
||||||
main() {
|
|
||||||
(( $# )) || { usage; exit "$EX_USAGE"; }
|
|
||||||
local sub="$1"; shift
|
|
||||||
case "$sub" in
|
|
||||||
check-staged)
|
|
||||||
while (( $# )); do
|
|
||||||
case "$1" in
|
|
||||||
--json-path) JSON_PATHS+=("${2:-}"); shift 2 ;;
|
|
||||||
--allow-invalid-json) ALLOW_INVALID_JSON+=("${2:-}"); shift 2 ;;
|
|
||||||
*) fail "$EX_USAGE" "unknown argument to check-staged: $1" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
cmd_check_staged
|
|
||||||
;;
|
|
||||||
push) cmd_push "$@" ;;
|
|
||||||
-h|--help) usage ;;
|
|
||||||
*) usage; exit "$EX_USAGE" ;;
|
|
||||||
esac
|
|
||||||
# Explicit: the only way to reach here is with every requested check passed.
|
|
||||||
exit "$EX_OK"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -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) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
||||||
|
|
||||||
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","statuses":[{"status":"success"}]}' ;;
|
terminal-success) code=200; body='{"state":"success"}' ;;
|
||||||
*)
|
*)
|
||||||
echo "curl stub: unknown mode=$mode" >&2
|
echo "curl stub: unknown mode=$mode" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -91,7 +91,6 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -132,26 +131,19 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
|||||||
fail=1
|
fail=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
|
||||||
set +e
|
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" -ne 0 ]]; then
|
if [[ "$status_c" -eq 0 ]]; then
|
||||||
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
|
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
||||||
echo "$out_c" >&2
|
|
||||||
fail=1
|
|
||||||
elif [[ "$out_c" != *"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
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
|
|
||||||
|
|
||||||
set -u
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
|
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
|
||||||
STUB_DIR="$WORK_DIR/stubs"
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
|
||||||
git -C "$REPO_DIR" init -q
|
|
||||||
git -C "$REPO_DIR" checkout -q -b fix/github-checks
|
|
||||||
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
|
|
||||||
|
|
||||||
cat > "$STUB_DIR/gh" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
endpoint=""
|
|
||||||
for arg in "$@"; do
|
|
||||||
[[ "$arg" == repos/* ]] && endpoint="$arg"
|
|
||||||
done
|
|
||||||
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
|
|
||||||
case "$endpoint" in
|
|
||||||
repos/acme/widgets/branches/fix/github-checks)
|
|
||||||
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
|
|
||||||
;;
|
|
||||||
repos/acme/widgets/commits/*/statuses?per_page=100)
|
|
||||||
printf '%s\n' '[[]]'
|
|
||||||
;;
|
|
||||||
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
|
|
||||||
case "${MOSAIC_GH_CHECK_MODE:?}" in
|
|
||||||
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
|
|
||||||
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
|
|
||||||
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
|
|
||||||
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
|
|
||||||
*) exit 2 ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
SH
|
|
||||||
chmod +x "$STUB_DIR/gh"
|
|
||||||
|
|
||||||
run_guard() {
|
|
||||||
local mode="$1"
|
|
||||||
(
|
|
||||||
cd "$REPO_DIR" || exit
|
|
||||||
export PATH="$STUB_DIR:$PATH"
|
|
||||||
export MOSAIC_GH_CHECK_MODE="$mode"
|
|
||||||
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
|
||||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
failures=0
|
|
||||||
assert_case() {
|
|
||||||
local mode="$1" expected_rc="$2" expected_state="$3" output rc
|
|
||||||
set +e
|
|
||||||
output=$(run_guard "$mode" 2>&1)
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
|
||||||
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
|
|
||||||
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
if [[ "$output" != *"state=$expected_state"* ]]; then
|
|
||||||
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
|
|
||||||
printf '%s\n' "$output" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
set -e
|
|
||||||
: > "$WORK_DIR/gh-calls.log"
|
|
||||||
assert_case success zero terminal-success
|
|
||||||
assert_case pending nonzero pending
|
|
||||||
assert_case failure nonzero terminal-failure
|
|
||||||
assert_case late-failure nonzero terminal-failure
|
|
||||||
|
|
||||||
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
|
|
||||||
echo "FAIL: expected every case to query all Checks API pages" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$failures" -ne 0 ]]; then
|
|
||||||
echo "GitHub check-runs regression failed ($failures assertions)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
|
|
||||||
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
|
|
||||||
|
|
||||||
set -u
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
|
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
|
||||||
STUB_DIR="$WORK_DIR/stubs"
|
|
||||||
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
|
||||||
STATUS_OBSERVED="$WORK_DIR/status-observed"
|
|
||||||
CLOCK_LOG="$WORK_DIR/clock.log"
|
|
||||||
WATCHDOG_PYTHON="/usr/bin/python3"
|
|
||||||
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
|
|
||||||
WATCHDOG_TIMEOUT_SEC=5
|
|
||||||
WATCHDOG_EXIT=90
|
|
||||||
FEATURE_BRANCH="fix/rm-03-fixture"
|
|
||||||
|
|
||||||
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
|
|
||||||
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
|
||||||
cat > "$WATCHDOG_SCRIPT" <<'PY'
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
raise SystemExit(2)
|
|
||||||
|
|
||||||
timeout_seconds = float(sys.argv[1])
|
|
||||||
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
|
|
||||||
try:
|
|
||||||
return_code = process.wait(timeout=timeout_seconds)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
try:
|
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass
|
|
||||||
process.wait()
|
|
||||||
print(
|
|
||||||
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
|
|
||||||
"before completing its intended path",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise SystemExit(90)
|
|
||||||
|
|
||||||
if return_code < 0:
|
|
||||||
raise SystemExit(128 - return_code)
|
|
||||||
raise SystemExit(return_code)
|
|
||||||
PY
|
|
||||||
git -C "$REPO_DIR" init -q
|
|
||||||
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
|
||||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
|
||||||
|
|
||||||
cat > "$STUB_DIR/curl" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
url=""
|
|
||||||
has_write_out=0
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
-w) has_write_out=1 ;;
|
|
||||||
http://*|https://*) url="$arg" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
|
||||||
|
|
||||||
case "$url" in
|
|
||||||
*/branches/*)
|
|
||||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
|
|
||||||
while :; do :; done
|
|
||||||
fi
|
|
||||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
|
||||||
exit 7
|
|
||||||
fi
|
|
||||||
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
|
||||||
if [[ "$has_write_out" -eq 1 ]]; then
|
|
||||||
printf '%s\n200' "$body"
|
|
||||||
else
|
|
||||||
printf '%s' "$body"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*/status)
|
|
||||||
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
|
|
||||||
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
|
||||||
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
|
||||||
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
|
||||||
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
|
|
||||||
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
|
|
||||||
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
|
|
||||||
malformed) printf '%s' 'not-json' ;;
|
|
||||||
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
|
||||||
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
|
||||||
large-success)
|
|
||||||
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
|
||||||
;;
|
|
||||||
unreachable) exit 7 ;;
|
|
||||||
*) echo "unknown status mode" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
SH
|
|
||||||
|
|
||||||
cat > "$STUB_DIR/date" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
|
|
||||||
echo "unexpected date invocation: $*" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
|
|
||||||
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
|
||||||
printf '1002\n'
|
|
||||||
else
|
|
||||||
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
|
||||||
printf '1000\n'
|
|
||||||
fi
|
|
||||||
SH
|
|
||||||
|
|
||||||
cat > "$STUB_DIR/sleep" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
|
||||||
SH
|
|
||||||
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
|
|
||||||
|
|
||||||
run_guard() {
|
|
||||||
local status_mode="$1"
|
|
||||||
local audit_log="${2:-$AUDIT_LOG}"
|
|
||||||
shift 2 || true
|
|
||||||
(
|
|
||||||
cd "$REPO_DIR" || exit
|
|
||||||
export PATH="$STUB_DIR:$PATH"
|
|
||||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
|
||||||
if [[ "$status_mode" == "credential-unresolvable" ]]; then
|
|
||||||
export HOME="$WORK_DIR/empty-home"
|
|
||||||
mkdir -p "$HOME"
|
|
||||||
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
|
|
||||||
export MOSAIC_STUB_STATUS_MODE=success
|
|
||||||
else
|
|
||||||
export GITEA_TOKEN=stub-token
|
|
||||||
export GITEA_URL=https://git.example.test
|
|
||||||
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
|
||||||
fi
|
|
||||||
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
|
|
||||||
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
|
||||||
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
|
|
||||||
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
|
|
||||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
|
||||||
# Provider observation is the synchronization event. The one-second
|
|
||||||
# timeout is subject semantics under virtual time, never a wall wait.
|
|
||||||
# The absolute Python runtime uses an internal monotonic wait and kills
|
|
||||||
# the subject's isolated process group. Neither operation can resolve
|
|
||||||
# to the virtual date/sleep stubs at the front of PATH.
|
|
||||||
local subject_rc
|
|
||||||
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
|
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
|
|
||||||
subject_rc=0
|
|
||||||
else
|
|
||||||
subject_rc=$?
|
|
||||||
fi
|
|
||||||
return "$subject_rc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
failures=0
|
|
||||||
assert_provider_observed() {
|
|
||||||
local name="$1" require_expiration="${2:-0}"
|
|
||||||
if [[ ! -e "$STATUS_OBSERVED" ]]; then
|
|
||||||
echo "FAIL $name: status provider was not observed" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
|
|
||||||
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
if [[ "$require_expiration" -eq 1 ]]; then
|
|
||||||
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
|
|
||||||
echo "FAIL $name: pending path did not expire after provider observation" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
run_assertion() {
|
|
||||||
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
|
||||||
local output rc
|
|
||||||
shift 4
|
|
||||||
set +e
|
|
||||||
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
|
|
||||||
case "$expected_rc" in
|
|
||||||
zero)
|
|
||||||
if [[ "$rc" -ne 0 ]]; then
|
|
||||||
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
nonzero)
|
|
||||||
if [[ "$rc" -eq 0 ]]; then
|
|
||||||
echo "FAIL $name: expected rc!=0, got rc=0" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
not126)
|
|
||||||
if [[ "$rc" -eq 126 ]]; then
|
|
||||||
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if [[ "$output" != *"$required_text"* ]]; then
|
|
||||||
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
|
|
||||||
printf '%s\n' "$output" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
if [[ "$status_mode" != "credential-unresolvable" ]]; then
|
|
||||||
if [[ "$status_mode" == "pending" ]]; then
|
|
||||||
assert_provider_observed "$name" 1
|
|
||||||
else
|
|
||||||
assert_provider_observed "$name"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
set -e
|
|
||||||
: > "$WORK_DIR/urls.log"
|
|
||||||
run_assertion success zero success 'state=terminal-success'
|
|
||||||
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
|
|
||||||
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
|
|
||||||
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
|
|
||||||
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
|
|
||||||
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
|
|
||||||
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
|
||||||
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
|
||||||
run_assertion large-payload not126 large-success 'state=terminal-success'
|
|
||||||
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
|
||||||
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
|
||||||
|
|
||||||
# Positive liveness control: a subject mutant hangs before the branch lookup
|
|
||||||
# can reach the status provider. Only the independent real-clock watchdog may
|
|
||||||
# terminate it, and its failure must be distinct from subject timeout rc=124.
|
|
||||||
set +e
|
|
||||||
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
|
|
||||||
watchdog_rc=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
|
|
||||||
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
|
|
||||||
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
|
|
||||||
printf '%s\n' "$watchdog_output" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
if [[ -e "$STATUS_OBSERVED" ]]; then
|
|
||||||
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
|
||||||
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
|
|
||||||
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
|
|
||||||
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
|
||||||
set +e
|
|
||||||
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
|
|
||||||
merge_unreachable_rc=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
|
|
||||||
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
|
||||||
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
assert_provider_observed merge-provider-unreachable
|
|
||||||
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
|
||||||
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
|
||||||
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A feature-branch push with no -B must inspect the checked-out feature branch.
|
|
||||||
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
|
|
||||||
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
|
|
||||||
exact_sha=0123456789abcdef0123456789abcdef01234567
|
|
||||||
: > "$WORK_DIR/urls.log"
|
|
||||||
run_assertion exact-fork-head zero success 'state=terminal-success' \
|
|
||||||
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
|
|
||||||
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
|
|
||||||
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
|
|
||||||
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
|
|
||||||
audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
|
||||||
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
|
|
||||||
set +e
|
|
||||||
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
|
|
||||||
unsupported_rc=$?
|
|
||||||
set -e
|
|
||||||
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
|
|
||||||
if [[ "$unsupported_rc" -ne 0 ]]; then
|
|
||||||
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
|
|
||||||
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
|
||||||
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
|
|
||||||
echo "FAIL unsupported-platform: expected an additional audit record" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A degraded pass is forbidden if the audit receipt cannot be written.
|
|
||||||
mkdir -p "$WORK_DIR/not-a-directory"
|
|
||||||
printf 'file' > "$WORK_DIR/not-a-directory/parent"
|
|
||||||
set +e
|
|
||||||
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
|
|
||||||
audit_failure_rc=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$audit_failure_rc" -eq 0 ]]; then
|
|
||||||
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
if [[ "$audit_failure_output" != *"audit"* ]]; then
|
|
||||||
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
|
||||||
failures=$((failures + 1))
|
|
||||||
fi
|
|
||||||
assert_provider_observed audit-unavailable
|
|
||||||
|
|
||||||
if [[ "$failures" -ne 0 ]]; then
|
|
||||||
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
#!/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,10 +280,7 @@ print("201")
|
|||||||
print(json.dumps(record))
|
print(json.dumps(record))
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
response_status="${result%%$'\n'*}"
|
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||||
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
|
||||||
@@ -302,10 +299,7 @@ else:
|
|||||||
print(json.dumps(match))
|
print(json.dumps(match))
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
response_status="${result%%$'\n'*}"
|
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||||
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() { grep -qx "$2" <<<"$1"; }
|
contains() { printf '%s\n' "$1" | grep -qx "$2"; }
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixed (current) script behavior
|
# Fixed (current) script behavior
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# test-mutate-push-guard.sh -- needles for the mutation generator.
|
|
||||||
#
|
|
||||||
# The generator's entire output is a COVERAGE CLAIM, and the README publishes it.
|
|
||||||
# That makes it the most dangerous file here: when it is wrong it does not fail,
|
|
||||||
# it reassures. Two of its three defects were found by review rather than by any
|
|
||||||
# test, so these are the cases that had to exist.
|
|
||||||
#
|
|
||||||
# g1 a RED BASELINE must be refused before any mutant runs. Previously ONE
|
|
||||||
# pre-existing suite failure -- changing no guard behaviour at all --
|
|
||||||
# satisfied every mutant: 13 killed, 0 survived, table emitted, exit 0.
|
|
||||||
# g3 an INTERRUPTED run must leave the subject byte-identical. Restoration
|
|
||||||
# used to lean on an EXIT trap, and A TRAP IS CLEANUP, NOT ISOLATION:
|
|
||||||
# SIGKILL cannot run it, so an interrupted run stranded a mutated
|
|
||||||
# push-guard.sh that the next suite run silently inherited.
|
|
||||||
# g2 is the positive control. Without it every case here could be passing
|
|
||||||
# because the generator refuses unconditionally, which is a wall, not a gate.
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
GEN="$HERE/mutate-push-guard.sh"
|
|
||||||
PASS=0; FAIL=0
|
|
||||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
ok() { printf ' ok [%-16s] %s\n' "$1" "${2:-}"; PASS=$(( PASS + 1 )); }
|
|
||||||
bad() { printf ' FAIL [%-16s] %s\n' "$1" "$2"; FAIL=$(( FAIL + 1 )); }
|
|
||||||
|
|
||||||
# fixture <name> -- a directory holding an independent copy of guard + suite
|
|
||||||
fixture() {
|
|
||||||
local d="$TMP/$1"; mkdir -p "$d"
|
|
||||||
install -m 755 "$HERE/push-guard.sh" "$d/push-guard.sh"
|
|
||||||
install -m 755 "$HERE/test-push-guard.sh" "$d/test-push-guard.sh"
|
|
||||||
printf '%s\n' "$d"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "== g1: a RED BASELINE must be refused, with NO table emitted =="
|
|
||||||
# The injected case asserts exit 99 from `true`. It fails always, and it changes
|
|
||||||
# NO guard behaviour -- which is the whole point: a defect anywhere in the suite
|
|
||||||
# used to be enough to certify every branch as covered.
|
|
||||||
g1="$(fixture red)"
|
|
||||||
python3 - "$g1/test-push-guard.sh" <<'PY'
|
|
||||||
import sys
|
|
||||||
p = sys.argv[1]; s = open(p).read()
|
|
||||||
anchor = 'mkdir -p "$WORK_DIR"\n'
|
|
||||||
assert s.count(anchor) == 1, "injection anchor not unique -- fixture would not be the red baseline"
|
|
||||||
s = s.replace(anchor, anchor + '\nexpect NEEDLE 99 "injected always-failing case" -- true\n', 1)
|
|
||||||
open(p, "w").write(s)
|
|
||||||
PY
|
|
||||||
# Prove the fixture really IS red before asserting the generator notices, or g1
|
|
||||||
# could pass against a green suite and test nothing.
|
|
||||||
if "$g1/test-push-guard.sh" >/dev/null 2>&1; then
|
|
||||||
bad g1-fixture "injected suite still passes -- fixture is not a red baseline"
|
|
||||||
else
|
|
||||||
ok g1-fixture "fixture suite is red, as required"
|
|
||||||
fi
|
|
||||||
out="$("$GEN" --dir "$g1" 2>&1)"; rc=$?
|
|
||||||
if (( rc != 0 )) && [[ "$out" == *"REFUSING: baseline is not green"* ]]; then
|
|
||||||
ok g1-refused "exit $rc, refused before mutating"
|
|
||||||
else
|
|
||||||
bad g1-refused "wanted nonzero + refusal; got rc=$rc"
|
|
||||||
fi
|
|
||||||
if [[ "$out" != *"README TABLE"* && "$out" != *"killed,"* ]]; then
|
|
||||||
ok g1-no-table "no coverage table emitted from a red baseline"
|
|
||||||
else
|
|
||||||
bad g1-no-table "a table or kill count was published despite the red baseline"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== g3: an INTERRUPTED run must not alter the subject =="
|
|
||||||
# THE KILL MUST LAND INSIDE A MUTATION WINDOW OR THIS CASE PROVES NOTHING.
|
|
||||||
# First attempt used `timeout -s KILL 3`. At three seconds the generator is still
|
|
||||||
# running its BASELINE, so no mutation has been applied yet and the subject is
|
|
||||||
# trivially unchanged -- for the ORIGINAL in-place generator too, which I
|
|
||||||
# confirmed by running it. The control passed for a reason unrelated to the fix:
|
|
||||||
# a vacuous control, in the control written for blocker 3.
|
|
||||||
#
|
|
||||||
# So the kill is now driven from INSIDE the run. The fixture's suite counts its
|
|
||||||
# own invocations and SIGKILLs the generator on the second one -- invocation 1 is
|
|
||||||
# the baseline, invocation 2 happens with mutant #1 APPLIED. That is exactly the
|
|
||||||
# window where an in-place generator strands a mutated subject.
|
|
||||||
instrument_kill_at_second_run() {
|
|
||||||
python3 - "$1" <<'PY'
|
|
||||||
import sys
|
|
||||||
p = sys.argv[1]; s = open(p).read()
|
|
||||||
anchor = 'PASS=0\nFAIL=0\n'
|
|
||||||
assert s.count(anchor) == 1, "instrumentation anchor not unique"
|
|
||||||
inject = anchor + '''
|
|
||||||
if [[ -n "${G3_COUNTER:-}" ]]; then
|
|
||||||
n=$(( $(cat "$G3_COUNTER" 2>/dev/null || echo 0) + 1 ))
|
|
||||||
printf '%s' "$n" > "$G3_COUNTER"
|
|
||||||
# Invocation 2 = first mutant applied. Kill the generator where it hurts.
|
|
||||||
# `kill -9 0` targets the whole PROCESS GROUP, not $PPID: the generator runs
|
|
||||||
# the suite inside $( ), which forks, so $PPID is that subshell and killing
|
|
||||||
# it merely ends the command substitution -- the generator carries on and
|
|
||||||
# exits 0. The caller puts the generator in its OWN group via setsid, so the
|
|
||||||
# group is exactly the generator and its children, and this harness is not
|
|
||||||
# in it.
|
|
||||||
(( n == 2 )) && kill -9 0
|
|
||||||
fi
|
|
||||||
'''
|
|
||||||
open(p, "w").write(s.replace(anchor, inject, 1))
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
# run_killed <dir> -> echoes "<rc> <before> <after>"
|
|
||||||
run_killed() {
|
|
||||||
local d="$1" gen="$2" before after rc
|
|
||||||
instrument_kill_at_second_run "$d/test-push-guard.sh"
|
|
||||||
before="$(sha256sum "$d/push-guard.sh" | cut -d' ' -f1)"
|
|
||||||
G3_COUNTER="$d/.count" setsid --wait "$gen" --dir "$d" >/dev/null 2>&1; rc=$?
|
|
||||||
after="$(sha256sum "$d/push-guard.sh" | cut -d' ' -f1)"
|
|
||||||
printf '%s %s %s\n' "$rc" "$before" "$after"
|
|
||||||
}
|
|
||||||
|
|
||||||
g3="$(fixture killed)"
|
|
||||||
read -r krc before after <<<"$(run_killed "$g3" "$GEN")"
|
|
||||||
if (( krc != 0 )); then
|
|
||||||
ok g3-was-killed "generator died mid-mutation (exit $krc)"
|
|
||||||
else
|
|
||||||
bad g3-was-killed "generator exited 0 -- the kill never landed, so the check below is vacuous"
|
|
||||||
fi
|
|
||||||
if [[ "$before" == "$after" ]]; then
|
|
||||||
ok g3-subject-intact "push-guard.sh byte-identical after the kill"
|
|
||||||
else
|
|
||||||
bad g3-subject-intact "SUBJECT MUTATED AND STRANDED: $before -> $after"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# PROVE THE NEEDLE BITES. Reconstruct the pre-fix mechanism -- mutate the source
|
|
||||||
# in place, restore from an EXIT trap -- and put it through the identical kill.
|
|
||||||
# If this does NOT strand a mutated file, g3-subject-intact is measuring nothing
|
|
||||||
# and the isolation fix is unevidenced.
|
|
||||||
g3o="$(fixture killed-inplace)"
|
|
||||||
python3 - "$GEN" "$g3o/gen-inplace.sh" <<'PY'
|
|
||||||
import sys
|
|
||||||
s = open(sys.argv[1]).read()
|
|
||||||
old = '''install -m 755 "$SRC_TARGET" "$WORK/push-guard.sh"
|
|
||||||
install -m 755 "$SRC_SUITE" "$WORK/test-push-guard.sh"
|
|
||||||
TARGET="$WORK/push-guard.sh"
|
|
||||||
SUITE="$WORK/test-push-guard.sh"
|
|
||||||
BAK="$WORK/push-guard.sh.orig"
|
|
||||||
cp "$TARGET" "$BAK"'''
|
|
||||||
new = '''TARGET="$SRC_TARGET"
|
|
||||||
SUITE="$SRC_SUITE"
|
|
||||||
BAK="$WORK/push-guard.sh.orig"
|
|
||||||
cp "$TARGET" "$BAK"
|
|
||||||
trap 'cp "$BAK" "$TARGET"; rm -rf "$WORK"' EXIT'''
|
|
||||||
assert s.count(old) == 1, "cannot reconstruct the in-place mechanism -- bite proof would be fake"
|
|
||||||
open(sys.argv[2], "w").write(s.replace(old, new, 1))
|
|
||||||
PY
|
|
||||||
chmod +x "$g3o/gen-inplace.sh"
|
|
||||||
read -r _orc obefore oafter <<<"$(run_killed "$g3o" "$g3o/gen-inplace.sh")"
|
|
||||||
if [[ "$obefore" != "$oafter" ]]; then
|
|
||||||
ok g3-needle-bites "in-place generator strands a mutated subject, as it must"
|
|
||||||
else
|
|
||||||
bad g3-needle-bites "the OLD mechanism also left the subject intact -- g3 is vacuous"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== g2: POSITIVE CONTROL -- a clean subject must produce a full green run =="
|
|
||||||
g2="$(fixture clean)"
|
|
||||||
out2="$("$GEN" --dir "$g2" 2>&1)"; rc2=$?
|
|
||||||
if (( rc2 == 0 )) && [[ "$out2" == *"0 survived"* ]]; then
|
|
||||||
ok g2-control "$(printf '%s' "$out2" | grep -E '^[0-9]+ killed')"
|
|
||||||
else
|
|
||||||
bad g2-control "wanted exit 0 with 0 survived; got rc=$rc2"
|
|
||||||
fi
|
|
||||||
# A kill must be attributed to a NAMED case, not to a bare tally -- that is the
|
|
||||||
# fix for blocker 2 and it needs its own assertion.
|
|
||||||
if [[ "$out2" == *" by: "* ]]; then
|
|
||||||
ok g2-attributed "kills name the case they broke"
|
|
||||||
else
|
|
||||||
bad g2-attributed "no per-mutant case attribution in the output"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
|
|
||||||
(( FAIL == 0 ))
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
|
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -51,23 +51,22 @@ for arg in "$@"; do
|
|||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$prev" == "data" ]]; then
|
if [[ "$prev" == "-d" ]]; then
|
||||||
post_data="$arg"
|
post_data="$arg"
|
||||||
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
|
|
||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$prev" == "config" ]]; then
|
if [[ "$arg" == "-o" ]]; then
|
||||||
[[ "$arg" == "-" ]] && cat >/dev/null
|
prev="-o"
|
||||||
prev=""
|
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
case "$arg" in
|
if [[ "$arg" == "-d" ]]; then
|
||||||
-o) prev="-o" ;;
|
prev="-d"
|
||||||
-d|--data|--data-binary) prev="data" ;;
|
continue
|
||||||
-K|--config) prev="config" ;;
|
fi
|
||||||
-w) write_code=true ;;
|
if [[ "$arg" == "-w" ]]; then
|
||||||
esac
|
write_code=true
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
emit_response() {
|
emit_response() {
|
||||||
local body="$1"
|
local body="$1"
|
||||||
@@ -80,24 +79,15 @@ 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","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||||
exit 0
|
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
|
||||||
POST_DATA="$post_data" python3 - <<'PY'
|
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
||||||
import json
|
echo "unexpected merge payload: $post_data" >&2
|
||||||
import os
|
exit 96
|
||||||
payload = json.loads(os.environ["POST_DATA"])
|
fi
|
||||||
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
|
||||||
@@ -117,8 +107,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 > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&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
|
||||||
@@ -137,6 +127,38 @@ 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
|
||||||
@@ -155,8 +177,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 > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$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
|
||||||
@@ -193,7 +215,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 > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
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
|
||||||
@@ -218,7 +240,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 > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
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
|
||||||
@@ -238,4 +260,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "pr-merge.sh Gitea exact-head API regression passed"
|
echo "pr-merge.sh Gitea fallback regression passed"
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
|
|
||||||
# The commit whose CI was guarded must be the commit the provider atomically merges.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
|
|
||||||
SHA=0123456789abcdef0123456789abcdef01234567
|
|
||||||
|
|
||||||
make_fixture() {
|
|
||||||
local name="$1" remote="$2"
|
|
||||||
local root="$WORK_DIR/$name"
|
|
||||||
local tools="$root/tools/git"
|
|
||||||
mkdir -p "$tools" "$root/repo"
|
|
||||||
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
|
|
||||||
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
|
||||||
git -C "$root/repo" init -q
|
|
||||||
git -C "$root/repo" remote add origin "$remote"
|
|
||||||
cat > "$tools/pr-metadata.sh" <<SH
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
|
||||||
SH
|
|
||||||
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
exit 0
|
|
||||||
SH
|
|
||||||
chmod +x "$tools"/*.sh
|
|
||||||
}
|
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
make_fixture gitea https://git.example.test/acme/widgets.git
|
|
||||||
make_fixture github https://github.com/acme/widgets.git
|
|
||||||
|
|
||||||
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
payload=""
|
|
||||||
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)"
|
|
||||||
@@ -1,541 +0,0 @@
|
|||||||
#!/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)"
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# RM-03: pr-merge must guard the PR head branch, not its main base branch.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-queue-branch}"
|
|
||||||
FIXTURE_DIR="$WORK_DIR/tools/git"
|
|
||||||
CALL_LOG="$WORK_DIR/queue-call.log"
|
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
mkdir -p "$FIXTURE_DIR"
|
|
||||||
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
|
||||||
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
|
||||||
|
|
||||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
|
||||||
SH
|
|
||||||
|
|
||||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
|
||||||
exit 42
|
|
||||||
SH
|
|
||||||
chmod +x "$FIXTURE_DIR"/*.sh
|
|
||||||
|
|
||||||
set +e
|
|
||||||
(
|
|
||||||
cd "$WORK_DIR"
|
|
||||||
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
|
||||||
"$FIXTURE_DIR/pr-merge.sh" -n 123
|
|
||||||
) >/dev/null 2>&1
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [[ "$rc" -ne 42 ]]; then
|
|
||||||
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ ! -s "$CALL_LOG" ]]; then
|
|
||||||
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! grep -q -- '-B fix/rm-03-fixture' "$CALL_LOG"; then
|
|
||||||
echo "FAIL: merge queue guard did not receive PR head branch" >&2
|
|
||||||
cat "$CALL_LOG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if grep -q -- '-B main' "$CALL_LOG"; then
|
|
||||||
echo "FAIL: merge queue guard still received the main base branch" >&2
|
|
||||||
cat "$CALL_LOG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
|
|
||||||
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
|
|
||||||
cat "$CALL_LOG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! grep -q -- '--sha 0123456789abcdef0123456789abcdef01234567' "$CALL_LOG"; then
|
|
||||||
echo "FAIL: merge queue guard did not receive the exact PR head SHA" >&2
|
|
||||||
cat "$CALL_LOG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "pr-merge queue branch/repository/SHA regression passed"
|
|
||||||
@@ -58,9 +58,6 @@ CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
|||||||
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
||||||
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
||||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||||
# Sandboxed HOME so nothing under the real $HOME (notably the per-slot Gitea token
|
|
||||||
# store at ~/.config/mosaic/secrets/gitea-tokens/) is reachable from the wrapper.
|
|
||||||
HOME_DIR="$WORK_DIR/home"
|
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
@@ -81,18 +78,9 @@ OVERRIDE_TOKEN="override-token-placeholder"
|
|||||||
CROSS_HOST_LOGIN="foreign-host-reviewer"
|
CROSS_HOST_LOGIN="foreign-host-reviewer"
|
||||||
CROSS_HOST_TOKEN="cross-host-token-placeholder"
|
CROSS_HOST_TOKEN="cross-host-token-placeholder"
|
||||||
|
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH" "$HOME_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH"
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
# HERMETICITY: get_gitea_token() step 0 resolves a per-agent identity from
|
|
||||||
# `git config --get mosaic.gitIdentity`, which on a provisioned agent seat is set
|
|
||||||
# GLOBALLY and therefore leaks into this fresh repo. It then reads a REAL per-slot
|
|
||||||
# token from $HOME and returns it WITHOUT ever consulting MOSAIC_CREDENTIALS_FILE,
|
|
||||||
# so the fixture credentials below are silently ignored and the suite runs against
|
|
||||||
# production credentials. An empty repo-local value shadows the global one and reads
|
|
||||||
# back as empty at rc=0, restoring the shared-credential path this suite intends to
|
|
||||||
# exercise. Paired with the sandboxed HOME in run_review().
|
|
||||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
|
||||||
|
|
||||||
# tea config: the override login carries its own token here. The default login
|
# tea config: the override login carries its own token here. The default login
|
||||||
# name ("mosaicstack") is deliberately absent, so the no-override default path
|
# name ("mosaicstack") is deliberately absent, so the no-override default path
|
||||||
@@ -225,10 +213,7 @@ 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"
|
||||||
response_status="${result%%$'\n'*}"
|
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||||
response_body=""
|
|
||||||
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
|
||||||
write_response "$response_status" "$response_body"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mode="${PR_REVIEW_TEST_MODE:-}"
|
mode="${PR_REVIEW_TEST_MODE:-}"
|
||||||
@@ -281,24 +266,6 @@ submitted = json.loads(os.environ["PR_REVIEW_PAYLOAD"])
|
|||||||
with open(state_path, encoding="utf-8") as handle:
|
with open(state_path, encoding="utf-8") as handle:
|
||||||
reviews = json.load(handle)
|
reviews = json.load(handle)
|
||||||
|
|
||||||
# review-refused-422 (#1004): the server REFUSES the submit outright with a
|
|
||||||
# definite, correct, machine-readable reason in the body — the shape Gitea
|
|
||||||
# returns when the acting credential authored the PR. Nothing is created. The
|
|
||||||
# wrapper must surface what the server said and must NOT relabel this as the
|
|
||||||
# #865 silent-no-op defect class, which is precisely what it is not.
|
|
||||||
if mode == "review-refused-422":
|
|
||||||
print("422")
|
|
||||||
print(json.dumps({"message": "Cannot approve your own pull request"}))
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
# review-refused-html (#1004): a non-JSON error body, as a fronting proxy or
|
|
||||||
# gateway emits. The detail extraction must degrade to the first raw line rather
|
|
||||||
# than silently dropping the only explanation available.
|
|
||||||
if mode == "review-refused-html":
|
|
||||||
print("502")
|
|
||||||
print("<html><head><title>502 Bad Gateway</title></head>\n<body>nginx</body></html>")
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
# no-op-concurrent-review: the wrapper's own submit is SUPPRESSED (200, no
|
# no-op-concurrent-review: the wrapper's own submit is SUPPRESSED (200, no
|
||||||
# created object) even though a concurrent same-identity, same-state review at
|
# created object) even though a concurrent same-identity, same-state review at
|
||||||
# the same head already exists. Nothing is persisted; no created id to verify.
|
# the same head already exists. Nothing is persisted; no created id to verify.
|
||||||
@@ -550,8 +517,6 @@ run_review() {
|
|||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
TMPDIR="$TMP_SCRATCH" \
|
TMPDIR="$TMP_SCRATCH" \
|
||||||
HOME="$HOME_DIR" \
|
|
||||||
MOSAIC_GIT_IDENTITY="" \
|
|
||||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
PR_REVIEW_TEA_LOG="$TEA_LOG" \
|
PR_REVIEW_TEA_LOG="$TEA_LOG" \
|
||||||
@@ -975,44 +940,4 @@ if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
|
|||||||
fi
|
fi
|
||||||
assert_no_temp_leak "review-body-null"
|
assert_no_temp_leak "review-body-null"
|
||||||
|
|
||||||
# Case 19 (#1004): an outright server REFUSAL must report the provider's own
|
|
||||||
# reason and must NOT be relabelled as the #865 silent-no-op defect class. The
|
|
||||||
# old arm hardcoded "(#865: no durable review created)" for EVERY non-2xx, so a
|
|
||||||
# 422/403/404 — all of them definite, correct refusals the server explained in
|
|
||||||
# the discarded body — arrived at the caller wearing the name of the one defect
|
|
||||||
# they are not. That misdirection is what makes an operator re-issue the request
|
|
||||||
# by hand against the live object to find out what actually happened.
|
|
||||||
if run_review review-refused-422 approve; then
|
|
||||||
echo "FAIL: approve reported success when the server refused the submit" >&2
|
|
||||||
cat "$OUTPUT_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
grep -q 'HTTP 422' "$OUTPUT_FILE"
|
|
||||||
if ! grep -q 'Cannot approve your own pull request' "$OUTPUT_FILE"; then
|
|
||||||
echo "FAIL: the provider's stated reason was discarded (#1004)" >&2
|
|
||||||
cat "$OUTPUT_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if grep -q '#865: no durable review created' "$OUTPUT_FILE"; then
|
|
||||||
echo "FAIL: a server refusal was misattributed to the #865 defect class (#1004)" >&2
|
|
||||||
cat "$OUTPUT_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
assert_no_temp_leak "review-refused-422"
|
|
||||||
|
|
||||||
# Case 20 (#1004): a non-JSON error body (a fronting proxy's HTML page) must
|
|
||||||
# still yield something the caller can act on, rather than a bare status code.
|
|
||||||
if run_review review-refused-html approve; then
|
|
||||||
echo "FAIL: approve reported success on a 502" >&2
|
|
||||||
cat "$OUTPUT_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
grep -q 'HTTP 502' "$OUTPUT_FILE"
|
|
||||||
if ! grep -q '502 Bad Gateway' "$OUTPUT_FILE"; then
|
|
||||||
echo "FAIL: a non-JSON error body was dropped instead of degrading to its first line (#1004)" >&2
|
|
||||||
cat "$OUTPUT_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
assert_no_temp_leak "review-refused-html"
|
|
||||||
|
|
||||||
echo "pr-review.sh REST review + comment create/read-back regression passed"
|
echo "pr-review.sh REST review + comment create/read-back regression passed"
|
||||||
|
|||||||
@@ -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)"
|
||||||
grep -q -- '-r, --repo' <<<"$HELP_TEXT"
|
echo "$HELP_TEXT" | grep -q -- '-r, --repo'
|
||||||
grep -q -- '-H, --host' <<<"$HELP_TEXT"
|
echo "$HELP_TEXT" | grep -q -- '-H, --host'
|
||||||
|
|
||||||
# --- 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.
|
||||||
|
|||||||
@@ -1,659 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# test-push-guard.sh - Needle harness for push-guard.sh.
|
|
||||||
#
|
|
||||||
# Every check gets BOTH polarities:
|
|
||||||
# NEEDLE a deliberately-broken fixture that MUST trip the guard.
|
|
||||||
# CONTROL a clean fixture that MUST pass.
|
|
||||||
#
|
|
||||||
# The controls are not decoration. A guard that failed unconditionally would
|
|
||||||
# satisfy every needle and look fully covered — which is the same class of defect
|
|
||||||
# (an assertion satisfied by the null case) that push-guard.sh exists to prevent.
|
|
||||||
# A needle set without controls cannot tell "working guard" from "broken guard".
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
GUARD="$SCRIPT_DIR/push-guard.sh"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$SCRIPT_DIR/.work}"
|
|
||||||
|
|
||||||
PASS=0
|
|
||||||
FAIL=0
|
|
||||||
|
|
||||||
# Fresh repo + bare "remote" for each case, so no case can inherit another's state.
|
|
||||||
new_repo() {
|
|
||||||
local name="$1"
|
|
||||||
local dir="$WORK_DIR/$name"
|
|
||||||
rm -rf "$dir"
|
|
||||||
mkdir -p "$dir"
|
|
||||||
git init -q -b main "$dir/repo"
|
|
||||||
git init -q --bare "$dir/remote.git"
|
|
||||||
git -C "$dir/repo" remote add origin "$dir/remote.git"
|
|
||||||
git -C "$dir/repo" config user.name "Needle Harness"
|
|
||||||
git -C "$dir/repo" config user.email "[email protected]"
|
|
||||||
printf 'seed\n' > "$dir/repo/seed.txt"
|
|
||||||
git -C "$dir/repo" add seed.txt
|
|
||||||
git -C "$dir/repo" commit -q -m "seed"
|
|
||||||
printf '%s' "$dir/repo"
|
|
||||||
}
|
|
||||||
|
|
||||||
# write_config <repo> <json-text>
|
|
||||||
#
|
|
||||||
# THIS HELPER USED TO WRITE THE CONFIG UNTRACKED, and the comment that lived here
|
|
||||||
# justified it ("does not need to be staged"). That made every opt-out control in
|
|
||||||
# this file assert the FORBIDDEN provenance and pass — a control that does not
|
|
||||||
# merely test nothing, but tests the OPPOSITE of the requirement and goes green.
|
|
||||||
# The whole design is: ON from anywhere, OFF only from a committed reviewable
|
|
||||||
# artifact. A harness that opts out from an untracked file was proving the bypass
|
|
||||||
# worked. It now COMMITS, so the opt-out controls exercise the supported path.
|
|
||||||
write_config() {
|
|
||||||
printf '%s\n' "$2" > "$1/.push-guard.json"
|
|
||||||
git -C "$1" add .push-guard.json
|
|
||||||
git -C "$1" commit -q -m "push-guard config"
|
|
||||||
}
|
|
||||||
|
|
||||||
# write_config_untracked <repo> <json-text>
|
|
||||||
# Deliberately NOT committed — used only where the untracked config is the thing
|
|
||||||
# under test and the expected outcome is a REFUSAL.
|
|
||||||
write_config_untracked() {
|
|
||||||
printf '%s\n' "$2" > "$1/.push-guard.json"
|
|
||||||
}
|
|
||||||
|
|
||||||
# expect <kind> <expected_exit> <description> [--out <substring>] -- <command...>
|
|
||||||
#
|
|
||||||
# --out asserts a substring of the guard's own output. It exists because exit 0
|
|
||||||
# alone cannot distinguish "checked the files and they were fine" from "matched
|
|
||||||
# no files and had nothing to check". Two controls in an earlier revision of this
|
|
||||||
# harness were passing vacuously for exactly that reason — the pathspec silently
|
|
||||||
# matched nothing. A control that cannot fail is not a control.
|
|
||||||
expect() {
|
|
||||||
local kind="$1" want="$2" desc="$3"; shift 3
|
|
||||||
local need_out=""
|
|
||||||
while (( $# )); do
|
|
||||||
case "$1" in
|
|
||||||
--out) need_out="$2"; shift 2 ;;
|
|
||||||
--) shift; break ;;
|
|
||||||
*) break ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
local got=0 out
|
|
||||||
out="$("$@" 2>&1)" || got=$?
|
|
||||||
|
|
||||||
local why=""
|
|
||||||
[[ "$got" == "$want" ]] || why="wanted exit $want, got $got"
|
|
||||||
if [[ -z "$why" && -n "$need_out" && "$out" != *"$need_out"* ]]; then
|
|
||||||
why="exit $got as expected, but output never said: $need_out"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$why" ]]; then
|
|
||||||
printf ' PASS [%-7s] %s (exit %s)\n' "$kind" "$desc" "$got"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
else
|
|
||||||
printf ' FAIL [%-7s] %s — %s\n' "$kind" "$desc" "$why"
|
|
||||||
printf '%s\n' "$out" | sed 's/^/ | /'
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
mkdir -p "$WORK_DIR"
|
|
||||||
|
|
||||||
echo "=== (a) conflict markers / unmerged index ==="
|
|
||||||
|
|
||||||
# NEEDLE: staged content carrying real conflict markers.
|
|
||||||
R="$(new_repo a1)"
|
|
||||||
cat > "$R/conflicted.txt" <<'EOF'
|
|
||||||
intro line
|
|
||||||
<<<<<<< HEAD
|
|
||||||
ours
|
|
||||||
=======
|
|
||||||
theirs
|
|
||||||
>>>>>>> feature/x
|
|
||||||
tail line
|
|
||||||
EOF
|
|
||||||
git -C "$R" add conflicted.txt
|
|
||||||
expect NEEDLE 2 "staged conflict markers are refused" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# NEEDLE: a genuine unmerged index (merge in progress).
|
|
||||||
R="$(new_repo a2)"
|
|
||||||
git -C "$R" checkout -q -b other
|
|
||||||
printf 'from-other\n' > "$R/clash.txt"
|
|
||||||
git -C "$R" add clash.txt && git -C "$R" commit -q -m other
|
|
||||||
git -C "$R" checkout -q main
|
|
||||||
printf 'from-main\n' > "$R/clash.txt"
|
|
||||||
git -C "$R" add clash.txt && git -C "$R" commit -q -m main
|
|
||||||
git -C "$R" merge other -q >/dev/null 2>&1 || true
|
|
||||||
expect NEEDLE 2 "unmerged index paths are refused" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# CONTROL: ordinary staged content passes.
|
|
||||||
# The config is REQUIRED as of the decision gate: this fixture has no generated
|
|
||||||
# JSON, so it records that fact with a reason. This is the migration cost of the
|
|
||||||
# hard cutover, paid here first — these two controls were the only pre-existing
|
|
||||||
# cases that reached the JSON stage without nominating a path, and they are
|
|
||||||
# exactly the "repo that never wired it up" the gate now refuses.
|
|
||||||
R="$(new_repo a3)"
|
|
||||||
write_config "$R" '{"json_check": "none", "reason": "fixture has no generated JSON"}'
|
|
||||||
printf 'just some code\n' > "$R/clean.txt"
|
|
||||||
git -C "$R" add clean.txt
|
|
||||||
expect CONTROL 0 "clean staged content passes (1 file actually scanned)" \
|
|
||||||
--out "no conflict markers in 1 staged file(s)" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# CONTROL (false-positive guard): prose using ======= as an underline must NOT trip.
|
|
||||||
# This is why the marker pattern deliberately ignores a bare '======='.
|
|
||||||
R="$(new_repo a4)"
|
|
||||||
write_config "$R" '{"json_check": "none", "reason": "fixture has no generated JSON"}'
|
|
||||||
cat > "$R/README.rst" <<'EOF'
|
|
||||||
Section Title
|
|
||||||
=============
|
|
||||||
|
|
||||||
Body text.
|
|
||||||
|
|
||||||
Another Heading
|
|
||||||
=======
|
|
||||||
EOF
|
|
||||||
git -C "$R" add README.rst
|
|
||||||
expect CONTROL 0 "prose using ======= underlines does not trip the guard" \
|
|
||||||
--out "no conflict markers in 1 staged file(s)" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# NEEDLE (fault injection): if the scan itself ERRORS, the guard must refuse
|
|
||||||
# rather than report a clean result. git grep exits 1 for "no match" but >=2 for
|
|
||||||
# a real failure; a blanket '|| true' would collapse the two. We inject the
|
|
||||||
# failure with a PATH shim rather than trying to corrupt an index.
|
|
||||||
R="$(new_repo a5)"
|
|
||||||
SHIM="$WORK_DIR/a5/bin"
|
|
||||||
mkdir -p "$SHIM"
|
|
||||||
REAL_GIT="$(command -v git)"
|
|
||||||
cat > "$SHIM/git" <<SH
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
if [[ "\$1" == "grep" ]]; then exit 2; fi
|
|
||||||
exec "$REAL_GIT" "\$@"
|
|
||||||
SH
|
|
||||||
chmod +x "$SHIM/git"
|
|
||||||
printf 'ordinary content\n' > "$R/thing.txt"
|
|
||||||
git -C "$R" add thing.txt
|
|
||||||
expect NEEDLE 2 "a conflict scan that ERRORS is refused, not reported clean" \
|
|
||||||
--out "did not run" -- \
|
|
||||||
bash -c "cd '$R' && export PATH=\"$SHIM:\$PATH\" && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# NEEDLE: RENAME + MODIFY. Git reports a `git mv` plus a small edit as a single
|
|
||||||
# 'R' entry, which --diff-filter=ACM does not match, making the file invisible to
|
|
||||||
# every content check. A clean file is co-staged deliberately: without it the
|
|
||||||
# file list is empty and the nothing-staged guard fires by ACCIDENT, which would
|
|
||||||
# make this needle pass for the wrong reason and hide the real defect.
|
|
||||||
R="$(new_repo a6)"
|
|
||||||
mkdir -p "$R/data"
|
|
||||||
seq 1 200 | sed 's/^/line /' > "$R/data/canon.txt"
|
|
||||||
printf 'original\n' > "$R/other.txt"
|
|
||||||
git -C "$R" add -A && git -C "$R" commit -q -m seed
|
|
||||||
git -C "$R" mv data/canon.txt data/canon2.txt
|
|
||||||
printf '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b\n' >> "$R/data/canon2.txt"
|
|
||||||
printf 'an ordinary innocent change\n' > "$R/other.txt"
|
|
||||||
git -C "$R" add -A
|
|
||||||
expect NEEDLE 2 "conflict markers in a RENAMED file are refused (rename+modify)" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# NEEDLE: a path marked binary in .gitattributes. `git grep -I` honours that
|
|
||||||
# classification and skips the blob entirely while the summary still counts the
|
|
||||||
# file as scanned — a clean pass AND a false count. Hence -a, not -I.
|
|
||||||
R="$(new_repo a7)"
|
|
||||||
printf 'notes.txt binary\n' > "$R/.gitattributes"
|
|
||||||
printf 'x\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b\ny\n' > "$R/notes.txt"
|
|
||||||
git -C "$R" add -A
|
|
||||||
expect NEEDLE 2 "conflict markers in a .gitattributes-binary file are refused" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
echo "=== (c) staged JSON parses ==="
|
|
||||||
|
|
||||||
# NEEDLE: malformed JSON, the shape a broken generator emits.
|
|
||||||
R="$(new_repo c1)"
|
|
||||||
mkdir -p "$R/data"
|
|
||||||
printf '{"a": 1,\n' > "$R/data/broken.json"
|
|
||||||
git -C "$R" add data/broken.json
|
|
||||||
expect NEEDLE 3 "staged unparseable JSON under --json-path is refused" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
|
||||||
|
|
||||||
# NEEDLE: conflict markers inside a generated JSON file — the 70-file incident.
|
|
||||||
R="$(new_repo c2)"
|
|
||||||
mkdir -p "$R/data"
|
|
||||||
cat > "$R/data/canon.json" <<'EOF'
|
|
||||||
{
|
|
||||||
<<<<<<< HEAD
|
|
||||||
"v": 1
|
|
||||||
=======
|
|
||||||
"v": 2
|
|
||||||
>>>>>>> main
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
git -C "$R" add data/canon.json
|
|
||||||
expect NEEDLE 2 "conflict markers in generated JSON are refused" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# CONTROL: valid JSON passes.
|
|
||||||
R="$(new_repo c3)"
|
|
||||||
mkdir -p "$R/data"
|
|
||||||
printf '{"a": 1, "b": [2, 3]}\n' > "$R/data/good.json"
|
|
||||||
git -C "$R" add data/good.json
|
|
||||||
expect CONTROL 0 "valid staged JSON passes (and was actually parsed)" \
|
|
||||||
--out "1 staged .json file(s)" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
|
||||||
|
|
||||||
# CONTROL: an explicit exemption works (and is reported, never silent).
|
|
||||||
R="$(new_repo c4)"
|
|
||||||
mkdir -p "$R/fixtures"
|
|
||||||
printf 'not json at all' > "$R/fixtures/malformed.json"
|
|
||||||
git -C "$R" add fixtures/malformed.json
|
|
||||||
expect CONTROL 0 "deliberate malformed fixture can be exempted (exemption announced)" \
|
|
||||||
--out "EXEMPTED by --allow-invalid-json" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'fixtures/*' --allow-invalid-json 'fixtures/*'"
|
|
||||||
|
|
||||||
# CONTROL (false-positive regression): JSONC is the common real-world case.
|
|
||||||
# tsconfig.json legally carries comments and does NOT parse strictly. Measured on
|
|
||||||
# a real repo: 17 of 119 tracked .json files are like this. An on-by-default
|
|
||||||
# check would fire on all of them; scoping by --json-path is what prevents it.
|
|
||||||
R="$(new_repo c5)"
|
|
||||||
cat > "$R/tsconfig.json" <<'EOF'
|
|
||||||
{
|
|
||||||
// JSONC: comments are legal here and strict json.load() rejects them.
|
|
||||||
"compilerOptions": { "strict": true }
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
mkdir -p "$R/data"
|
|
||||||
printf '{"generated": true}\n' > "$R/data/view.json"
|
|
||||||
git -C "$R" add tsconfig.json data/view.json
|
|
||||||
expect CONTROL 0 "JSONC outside --json-path does not trip the JSON check" \
|
|
||||||
--out "1 staged .json file(s)" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
|
||||||
|
|
||||||
# NEEDLE: the same tsconfig DOES fail if someone wrongly nominates it, proving
|
|
||||||
# the check is genuinely running and the control above is not a vacuous pass.
|
|
||||||
expect NEEDLE 3 "the same JSONC file fails when explicitly nominated" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'tsconfig.json'"
|
|
||||||
|
|
||||||
echo "=== (d) the JSON decision is MANDATORY (.push-guard.json) ==="
|
|
||||||
|
|
||||||
# NEEDLE: the fail-open this gate closes. No --json-path, no config: the previous
|
|
||||||
# release printed "JSON check NOT REQUESTED" and exited 0, leaving the repo
|
|
||||||
# unprotected forever with nothing ever failing.
|
|
||||||
R="$(new_repo d1)"
|
|
||||||
printf '{"a": 1}\n' > "$R/thing.json"
|
|
||||||
git -C "$R" add thing.json
|
|
||||||
expect NEEDLE 6 "no --json-path and no config is REFUSED (the closed fail-open)" \
|
|
||||||
--out "NO JSON DECISION" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# NEEDLE: config nominates paths, and the check genuinely runs off it.
|
|
||||||
R="$(new_repo d2)"
|
|
||||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
|
||||||
mkdir -p "$R/data"
|
|
||||||
printf '{"a": 1,\n' > "$R/data/broken.json"
|
|
||||||
git -C "$R" add data/broken.json
|
|
||||||
expect NEEDLE 3 "broken JSON is caught via config-supplied json_paths" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# NEEDLE: opt-out WITHOUT a reason. An unexplained opt-out is the absence again,
|
|
||||||
# merely relocated into a file.
|
|
||||||
R="$(new_repo d3)"
|
|
||||||
write_config "$R" '{"json_check": "none"}'
|
|
||||||
printf 'code\n' > "$R/x.txt"
|
|
||||||
git -C "$R" add x.txt
|
|
||||||
expect NEEDLE 6 "json_check:none without a reason is refused" \
|
|
||||||
--out "REQUIRES a non-empty" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# NEEDLE: malformed config must REFUSE, never fall back to "treat as absent".
|
|
||||||
# That fallback would mean a typo silently disables the check the file enables.
|
|
||||||
R="$(new_repo d4)"
|
|
||||||
write_config "$R" '{"json_paths": ["data/**/*.json",'
|
|
||||||
printf 'code\n' > "$R/x.txt"
|
|
||||||
git -C "$R" add x.txt
|
|
||||||
expect NEEDLE 6 "an unparseable config is refused, not treated as absent" \
|
|
||||||
--out "INVALID" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# NEEDLE: a config that parses but states NO decision. Distinct from malformed —
|
|
||||||
# this one is valid JSON and still says nothing, which is the original defect
|
|
||||||
# wearing a config file as a disguise.
|
|
||||||
R="$(new_repo d5)"
|
|
||||||
write_config "$R" '{}'
|
|
||||||
printf 'code\n' > "$R/x.txt"
|
|
||||||
git -C "$R" add x.txt
|
|
||||||
expect NEEDLE 6 "a valid config that states no decision is refused" \
|
|
||||||
--out "states no decision" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# NEEDLE: a bogus json_check value must not be read as an opt-out.
|
|
||||||
R="$(new_repo d6)"
|
|
||||||
write_config "$R" '{"json_check": "off", "reason": "typo for none"}'
|
|
||||||
printf 'code\n' > "$R/x.txt"
|
|
||||||
git -C "$R" add x.txt
|
|
||||||
expect NEEDLE 6 'json_check with an unrecognised value is refused' \
|
|
||||||
--out 'must be "none"' -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# CONTROL: config nominates paths, JSON is clean.
|
|
||||||
# --out is REQUIRED. Exit 0 alone cannot distinguish "read the config, resolved
|
|
||||||
# the paths, parsed the files" from "matched nothing and had nothing to do" —
|
|
||||||
# the vacuous-control trap that already caught this harness once.
|
|
||||||
R="$(new_repo d7)"
|
|
||||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
|
||||||
mkdir -p "$R/data"
|
|
||||||
printf '{"generated": true}\n' > "$R/data/view.json"
|
|
||||||
git -C "$R" add data/view.json
|
|
||||||
expect CONTROL 0 "config-supplied json_paths pass and are reported as parsed" \
|
|
||||||
--out "1 staged .json file(s)" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# CONTROL: a recorded opt-out passes AND the reason is echoed. Asserting on the
|
|
||||||
# reason is the whole point — an opt-out nobody can see is what we just removed.
|
|
||||||
R="$(new_repo d8)"
|
|
||||||
write_config "$R" '{"json_check": "none", "reason": "no generated JSON in this repo"}'
|
|
||||||
printf 'code\n' > "$R/x.txt"
|
|
||||||
git -C "$R" add x.txt
|
|
||||||
expect CONTROL 0 "a recorded opt-out passes and PRINTS its reason" \
|
|
||||||
--out "recorded reason: no generated JSON in this repo" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# CONTROL: config-supplied exemptions still work through the config path.
|
|
||||||
R="$(new_repo d9)"
|
|
||||||
write_config "$R" '{"json_paths": ["fixtures/*"], "allow_invalid_json": ["fixtures/*"]}'
|
|
||||||
mkdir -p "$R/fixtures"
|
|
||||||
printf 'not json at all' > "$R/fixtures/malformed.json"
|
|
||||||
git -C "$R" add fixtures/malformed.json
|
|
||||||
expect CONTROL 0 "config-supplied allow_invalid_json exempts (and announces it)" \
|
|
||||||
--out "EXEMPTED by --allow-invalid-json" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# CONTROL: --json-path alone still satisfies the decision, with no config at all.
|
|
||||||
# This is what keeps every pre-existing caller working: nominating a path IS an
|
|
||||||
# explicit decision. Only saying nothing is refused.
|
|
||||||
R="$(new_repo d10)"
|
|
||||||
mkdir -p "$R/data"
|
|
||||||
printf '{"a": 1}\n' > "$R/data/good.json"
|
|
||||||
git -C "$R" add data/good.json
|
|
||||||
expect CONTROL 0 "--json-path alone satisfies the decision with no config present" \
|
|
||||||
--out "1 staged .json file(s)" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
|
||||||
|
|
||||||
# NEEDLE: a CLI nomination must WIN over a config opt-out, loudly. Resolving a
|
|
||||||
# contradiction toward more checking is the only safe direction, but doing it
|
|
||||||
# silently would hide a genuine disagreement between caller and repo.
|
|
||||||
R="$(new_repo d11)"
|
|
||||||
write_config "$R" '{"json_check": "none", "reason": "claims no generated JSON"}'
|
|
||||||
mkdir -p "$R/data"
|
|
||||||
printf '{"a": 1,\n' > "$R/data/broken.json"
|
|
||||||
git -C "$R" add data/broken.json
|
|
||||||
expect NEEDLE 3 "--json-path overrides a config opt-out and still catches bad JSON" \
|
|
||||||
--out "honouring the nomination" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
|
||||||
|
|
||||||
echo "=== (z) the guard itself emits no stray output ==="
|
|
||||||
|
|
||||||
# CONTROL: the guard must not print interpreter warnings.
|
|
||||||
#
|
|
||||||
# This case exists because a real one shipped: the config parser was an inline
|
|
||||||
# `<<'PY'` heredoc inside a $( ) command substitution, so bash printed
|
|
||||||
# warning: command substitution: 1 unterminated here-document
|
|
||||||
# on EVERY run. Thirty needles and a clean shellcheck all missed it — the
|
|
||||||
# warning went to stderr, and no assertion in this harness looked at output it
|
|
||||||
# had not already been told to expect. It was found only by running the guard
|
|
||||||
# against a real repository.
|
|
||||||
#
|
|
||||||
# The lesson is narrow and worth encoding: asserting on what you EXPECT to see
|
|
||||||
# cannot detect what you never thought to look for. This asserts on the absence
|
|
||||||
# of a whole output class instead.
|
|
||||||
R="$(new_repo z1)"
|
|
||||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
|
||||||
mkdir -p "$R/data"
|
|
||||||
printf '{"a": 1}\n' > "$R/data/view.json"
|
|
||||||
git -C "$R" add data/view.json
|
|
||||||
z_out="$(cd "$R" && "$GUARD" check-staged 2>&1)"
|
|
||||||
if grep -qiE 'warning:|unterminated|command substitution' <<<"$z_out"; then
|
|
||||||
printf ' FAIL [%-7s] %s\n' "CONTROL" "guard emits interpreter warnings"
|
|
||||||
printf '%s\n' "$z_out" | sed 's/^/ | /'
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
else
|
|
||||||
printf ' PASS [%-7s] %s\n' "CONTROL" "guard runs without emitting any interpreter warning"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# NEEDLE for the case above: prove the detector can actually fire, otherwise it
|
|
||||||
# is one more assertion that passes because it looked at nothing.
|
|
||||||
if grep -qiE 'warning:|unterminated|command substitution' \
|
|
||||||
<<<"bash: warning: command substitution: 1 unterminated here-document"; then
|
|
||||||
printf ' PASS [%-7s] %s\n' "NEEDLE" "the warning detector fires on a known warning string"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
else
|
|
||||||
printf ' FAIL [%-7s] %s\n' "NEEDLE" "the warning detector is dead — it cannot fire"
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== null case: nothing staged ==="
|
|
||||||
|
|
||||||
# NEEDLE: the index equals HEAD. Committing here produces the empty commit.
|
|
||||||
R="$(new_repo n1)"
|
|
||||||
expect NEEDLE 5 "empty index is refused before a commit happens" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
echo "=== (b) push actually happened ==="
|
|
||||||
|
|
||||||
# CONTROL: a real push that moves the remote.
|
|
||||||
R="$(new_repo b1)"
|
|
||||||
printf 'work\n' > "$R/work.txt"
|
|
||||||
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
|
|
||||||
expect CONTROL 0 "a push that moves the remote is confirmed" \
|
|
||||||
--out "PUSH CONFIRMED" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
|
|
||||||
|
|
||||||
# NEEDLE: THE ORIGINAL FALSE POSITIVE. Remote already equals local HEAD, so the
|
|
||||||
# naive 'remote == local' assertion succeeds while nothing is transferred.
|
|
||||||
R="$(new_repo b2)"
|
|
||||||
printf 'work\n' > "$R/work.txt"
|
|
||||||
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
|
|
||||||
git -C "$R" push -q origin HEAD:refs/heads/main
|
|
||||||
expect NEEDLE 4 "second push transferring nothing is refused (remote did not move)" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
|
|
||||||
|
|
||||||
# NEEDLE: the commit step aborted, so HEAD never advanced.
|
|
||||||
R="$(new_repo b3)"
|
|
||||||
HEAD_BEFORE="$(git -C "$R" rev-parse HEAD)"
|
|
||||||
expect NEEDLE 5 "push after an aborted commit is refused (HEAD did not advance)" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main --since-head '$HEAD_BEFORE'"
|
|
||||||
|
|
||||||
# NEEDLE: an empty commit carrying a real message.
|
|
||||||
R="$(new_repo b4)"
|
|
||||||
git -C "$R" commit -q --allow-empty -m "feat: important-sounding message"
|
|
||||||
expect NEEDLE 5 "pushing an empty commit is refused" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
|
|
||||||
|
|
||||||
# CONTROL: --since-head is satisfied when a real commit was made.
|
|
||||||
R="$(new_repo b5)"
|
|
||||||
HEAD_BEFORE="$(git -C "$R" rev-parse HEAD)"
|
|
||||||
printf 'work\n' > "$R/work.txt"
|
|
||||||
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
|
|
||||||
# --out is required here. Without it this control is VACUOUS: deleting the whole
|
|
||||||
# --since-head validation block would still yield exit 0, because the empty-commit
|
|
||||||
# and remote-moved checks independently succeed. It would prove nothing about the
|
|
||||||
# code path it names.
|
|
||||||
expect CONTROL 0 "--since-head passes when a real commit was created" \
|
|
||||||
--out "HEAD advanced" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main --since-head '$HEAD_BEFORE'"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Needles for the five blockers rev-974 found from a clean checkout. Every one
|
|
||||||
# of these was reproduced before it was fixed; none is a hypothesis.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "-- blocker 2: the enumerating producer's exit status --"
|
|
||||||
# FAULT INJECTION. mapfile < <(git diff) reported MAPFILE's status, so a git diff
|
|
||||||
# that died returned zero files and the guard published that silence as clean.
|
|
||||||
R="$(new_repo e1)"
|
|
||||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
|
||||||
git -C "$R" add -f data/bad.json
|
|
||||||
expect NEEDLE 6 "a failed file enumeration REFUSES instead of reporting clean" \
|
|
||||||
--out "CANNOT ENUMERATE STAGED FILES" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path ':(this-magic-does-not-exist)data/*.json'"
|
|
||||||
# CONTROL: the same malformed file with a WORKING pathspec must still be caught,
|
|
||||||
# so the needle above is not passing merely because everything now refuses.
|
|
||||||
expect CONTROL 3 "a working pathspec still catches the malformed JSON" \
|
|
||||||
--out "data/bad.json" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/*.json'"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "-- blocker 3: OFF must come from a committed, reviewable object --"
|
|
||||||
R="$(new_repo e2)"
|
|
||||||
write_config_untracked "$R" '{"json_check": "none", "reason": "local unreviewed bypass"}'
|
|
||||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
|
||||||
git -C "$R" add -f data/bad.json
|
|
||||||
# The anchor is the DISTINGUISHING clause, not the shared headline. Three
|
|
||||||
# separate branches print "OPT-OUT IS NOT REVIEWABLE" — untracked, staged-not-
|
|
||||||
# committed, and symlink. Anchoring on the headline means this needle stays green
|
|
||||||
# if the untracked branch is deleted and control falls through to a SIBLING that
|
|
||||||
# prints the same words: a substring anchor does not fail when its subject is
|
|
||||||
# removed, IT RE-POINTS. Anchor on the clause only this branch can produce.
|
|
||||||
expect NEEDLE 6 "an UNTRACKED opt-out is refused as unreviewable" \
|
|
||||||
--out "is not tracked in git" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# STAGED BUT NOT COMMITTED. Found by mutation, not by review: `if [[ -z "$cmode" ]]`
|
|
||||||
# survived `if false` against a 44/44 green suite, because no case ever built this
|
|
||||||
# state. Note the mutant still exits 6 — control falls to the LOCAL-ONLY branch
|
|
||||||
# below and refuses for a different reason. An exit-code-only assertion here would
|
|
||||||
# be satisfied by the wrong branch, which is why --out carries the distinguishing
|
|
||||||
# clause. `git add` puts the file in the index, so ls-files matches while HEAD
|
|
||||||
# does not: staged review is not review.
|
|
||||||
R="$(new_repo e2b)"
|
|
||||||
write_config_untracked "$R" '{"json_check": "none", "reason": "staged, never committed"}'
|
|
||||||
git -C "$R" add .push-guard.json
|
|
||||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
|
||||||
git -C "$R" add -f data/bad.json
|
|
||||||
expect NEEDLE 6 "a STAGED-but-uncommitted opt-out is refused" \
|
|
||||||
--out "staged but not yet in HEAD" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# COMMITTED CONFIG DOES NOT PARSE while the working tree opts out cleanly. Also a
|
|
||||||
# mutation survivor. The working-tree config is valid, so the early config check
|
|
||||||
# passes and we reach the re-read; the committed object is what fails. Without
|
|
||||||
# this branch an opt-out could be honoured on the strength of a HEAD blob nobody
|
|
||||||
# can actually read a decision out of.
|
|
||||||
R="$(new_repo e2c)"
|
|
||||||
write_config "$R" '{ this is not json'
|
|
||||||
printf '%s\n' '{"json_check": "none", "reason": "valid here, broken in HEAD"}' > "$R/.push-guard.json"
|
|
||||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
|
||||||
git -C "$R" add -f data/bad.json
|
|
||||||
expect NEEDLE 6 "an UNPARSEABLE committed config cannot authorise an opt-out" \
|
|
||||||
--out "is INVALID" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# A committed ON silently flipped OFF in the working tree is the same bypass.
|
|
||||||
R="$(new_repo e3)"
|
|
||||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
|
||||||
printf '%s\n' '{"json_check": "none", "reason": "local convenience"}' > "$R/.push-guard.json"
|
|
||||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
|
||||||
git -C "$R" add -f data/bad.json
|
|
||||||
expect NEEDLE 6 "a committed ON flipped OFF in the working tree is refused" \
|
|
||||||
--out "LOCAL-ONLY OPT-OUT" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# A committed SYMLINK is a mutable target: the reviewed blob is a path, and what
|
|
||||||
# it points at can change with no diff at all.
|
|
||||||
R="$(new_repo e4)"
|
|
||||||
printf '%s\n' '{"json_check": "none", "reason": "via symlink"}' > "$R/real-config.json"
|
|
||||||
ln -s real-config.json "$R/.push-guard.json"
|
|
||||||
git -C "$R" add real-config.json .push-guard.json
|
|
||||||
git -C "$R" commit -q -m "symlinked config"
|
|
||||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
|
||||||
git -C "$R" add -f data/bad.json
|
|
||||||
expect NEEDLE 6 "a committed SYMLINK config is refused as a mutable target" \
|
|
||||||
--out "committed SYMLINK" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
# CONTROL: the supported path still works. Without this the whole opt-out feature
|
|
||||||
# could be dead and every needle above would still pass — a gate that can never
|
|
||||||
# say yes is not a gate.
|
|
||||||
R="$(new_repo e5)"
|
|
||||||
write_config "$R" '{"json_check": "none", "reason": "committed and reviewable"}'
|
|
||||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
|
||||||
git -C "$R" add -f data/bad.json
|
|
||||||
expect CONTROL 0 "a COMMITTED opt-out is honoured and prints its committed reason" \
|
|
||||||
--out "recorded reason: committed and reviewable" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "-- blocker 4: advancement is ancestry, not inequality --"
|
|
||||||
R="$(new_repo e6)"
|
|
||||||
git -C "$R" checkout -qb other
|
|
||||||
printf 'o\n' > "$R/o.txt"; git -C "$R" add o.txt; git -C "$R" commit -q -m o
|
|
||||||
OTHER="$(git -C "$R" rev-parse HEAD)"
|
|
||||||
git -C "$R" checkout -q main 2>/dev/null || git -C "$R" checkout -q master
|
|
||||||
printf 'm\n' > "$R/m.txt"; git -C "$R" add m.txt; git -C "$R" commit -q -m m
|
|
||||||
MAINH="$(git -C "$R" rev-parse HEAD)"
|
|
||||||
git -C "$R" checkout -q "$OTHER"
|
|
||||||
expect NEEDLE 5 "a checkout of pre-existing diverged history is not 'advancement'" \
|
|
||||||
--out "NOT A DESCENDANT" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch other --since-head '$MAINH'"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "-- blocker 5: root and merge commits have defined emptiness --"
|
|
||||||
R="$(new_repo e7)"
|
|
||||||
git -C "$R" checkout -q --orphan fresh
|
|
||||||
git -C "$R" rm -q -rf . >/dev/null 2>&1 || true
|
|
||||||
git -C "$R" commit -q --allow-empty -m "empty root"
|
|
||||||
expect NEEDLE 5 "an EMPTY ROOT commit is refused, not exempted" \
|
|
||||||
--out "EMPTY ROOT COMMIT" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch fresh"
|
|
||||||
|
|
||||||
# CONTROL: a real root commit must still push, or the fix is just a new wall.
|
|
||||||
R="$(new_repo e8)"
|
|
||||||
git -C "$R" checkout -q --orphan fresh2
|
|
||||||
git -C "$R" rm -q -rf . >/dev/null 2>&1 || true
|
|
||||||
printf 'real\n' > "$R/real.txt"; git -C "$R" add real.txt
|
|
||||||
git -C "$R" commit -q -m "real root"
|
|
||||||
expect CONTROL 0 "a NON-empty root commit still pushes" \
|
|
||||||
--out "non-empty root commit" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch fresh2"
|
|
||||||
|
|
||||||
# THE MERGE BRANCH HAD NO NEEDLE AT ALL. It was defined, hand-verified, and
|
|
||||||
# shipped -- and deleting the refusal left the suite at 41/41 green. Defining
|
|
||||||
# semantics is not testing them, and an untested branch is indistinguishable
|
|
||||||
# from an absent one to everyone downstream.
|
|
||||||
# EMPTY MERGE: two branches that each change nothing, merged. The merge tree is
|
|
||||||
# then identical to EVERY parent -- it integrates nothing and introduces nothing.
|
|
||||||
R="$(new_repo e9)"
|
|
||||||
git -C "$R" checkout -q -b mx
|
|
||||||
git -C "$R" commit -q --allow-empty -m "x: no tree change"
|
|
||||||
git -C "$R" checkout -q -b my HEAD~1 2>/dev/null || git -C "$R" checkout -q -b my
|
|
||||||
git -C "$R" commit -q --allow-empty -m "y: no tree change"
|
|
||||||
git -C "$R" checkout -q mx
|
|
||||||
git -C "$R" merge -q --no-ff --no-edit my
|
|
||||||
# PROVE THE FIXTURE IS ACTUALLY AN EMPTY MERGE before asserting on it, or the
|
|
||||||
# needle passes for the wrong reason on a repo that never made a merge at all.
|
|
||||||
np="$(git -C "$R" rev-list --parents -n 1 HEAD | wc -w)"
|
|
||||||
if (( np > 2 )) && git -C "$R" diff --quiet HEAD^1 HEAD && git -C "$R" diff --quiet HEAD^2 HEAD; then
|
|
||||||
printf ' ok [%-14s] fixture is a merge (%d fields) with tree identical to both parents\n' "e9-fixture" "$np"; PASS=$(( PASS + 1 ))
|
|
||||||
else
|
|
||||||
printf ' FAIL [%-14s] fixture is not an empty merge -- needle would be vacuous\n' "e9-fixture"; FAIL=$(( FAIL + 1 ))
|
|
||||||
fi
|
|
||||||
expect NEEDLE 5 "an EMPTY MERGE is refused, not exempted" \
|
|
||||||
--out "EMPTY MERGE" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch mx"
|
|
||||||
|
|
||||||
# CONTROL: a merge that really integrates must still push. Both sides add a
|
|
||||||
# distinct file, so the merge tree differs from BOTH parents.
|
|
||||||
R="$(new_repo e10)"
|
|
||||||
git -C "$R" checkout -q -b nx
|
|
||||||
printf 'from x\n' > "$R/x.txt"; git -C "$R" add x.txt; git -C "$R" commit -q -m "x adds a file"
|
|
||||||
git -C "$R" checkout -q -b ny HEAD~1
|
|
||||||
printf 'from y\n' > "$R/y.txt"; git -C "$R" add y.txt; git -C "$R" commit -q -m "y adds a file"
|
|
||||||
git -C "$R" checkout -q nx
|
|
||||||
git -C "$R" merge -q --no-ff --no-edit ny
|
|
||||||
expect CONTROL 0 "a NON-empty merge commit still pushes" \
|
|
||||||
--out "non-empty merge commit" -- \
|
|
||||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch nx"
|
|
||||||
|
|
||||||
echo
|
|
||||||
printf 'push-guard needles: %d passed, %d failed\n' "$PASS" "$FAIL"
|
|
||||||
(( FAIL == 0 )) || exit 1
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# test-verify-clean-clone.sh -- needles for the verifier itself.
|
|
||||||
#
|
|
||||||
# v1 of the verifier passed while the real repository recorded 100644, because it
|
|
||||||
# asserted a SCRATCH repo built by cp. There was no needle that could have caught
|
|
||||||
# that: every case ran against a tree whose modes came off my filesystem.
|
|
||||||
# So the load-bearing needle here is w2 -- SOURCE GIT MODE 100644, DISK MODE 755.
|
|
||||||
# That is the exact contaminated state, and v1 exits 0 on it while v2 must refuse.
|
|
||||||
# A verifier without this needle is the same shape as the defect it verifies.
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
VERIFY="$HERE/verify-clean-clone.sh"
|
|
||||||
ARTIFACTS=(push-guard.sh test-push-guard.sh verify-clean-clone.sh mutate-push-guard.sh
|
|
||||||
test-mutate-push-guard.sh
|
|
||||||
test-verify-clean-clone.sh)
|
|
||||||
|
|
||||||
# THE REAL DEPLOYED LAYOUT. Every fixture in the first version of this file
|
|
||||||
# installed the artifacts at the fixture ROOT, so 6/6 green proved flat-layout
|
|
||||||
# operation and said NOTHING about the path these files actually ship at. The
|
|
||||||
# verifier was unusable in place and the suite could not see it, because THE
|
|
||||||
# FIXTURE ENCODED A LAYOUT THAT DOES NOT EXIST. A fixture is a claim about the
|
|
||||||
# world; an untested fixture is an unreviewed one.
|
|
||||||
readonly REAL_PREFIX="packages/mosaic/framework/tools/git"
|
|
||||||
|
|
||||||
PASS=0; FAIL=0
|
|
||||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
# Build a source repo whose COMMITTED modes are 100755 and whose disk modes are
|
|
||||||
# executable -- the honest state.
|
|
||||||
# mkrepo <repo-root> [subdir]
|
|
||||||
# With a subdir the artifacts are committed at repo/<subdir>/, which is how they
|
|
||||||
# really ship. Echoes the directory the artifacts landed in -- that is what gets
|
|
||||||
# passed as --repo, so the caller never has to reconstruct the path.
|
|
||||||
mkrepo() {
|
|
||||||
local d="$1" sub="${2:-}"
|
|
||||||
local dest="$d${sub:+/$sub}"
|
|
||||||
mkdir -p "$dest"
|
|
||||||
local f
|
|
||||||
for f in "${ARTIFACTS[@]}"; do
|
|
||||||
install -m 755 "$HERE/$f" "$dest/$f"
|
|
||||||
done
|
|
||||||
git -C "$d" init -q .
|
|
||||||
git -C "$d" config user.email "[email protected]"
|
|
||||||
git -C "$d" config user.name "Verifier Needles"
|
|
||||||
git -C "$d" add -A
|
|
||||||
git -C "$d" commit -q -m "artifacts"
|
|
||||||
printf '%s\n' "$dest"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_case() {
|
|
||||||
local name="$1" want_rc="$2" want_txt="$3" repo="$4"
|
|
||||||
local out rc
|
|
||||||
out="$("$VERIFY" --repo "$repo" 2>&1)"; rc=$?
|
|
||||||
if (( rc == want_rc )) && [[ "$out" == *"$want_txt"* ]]; then
|
|
||||||
printf ' ok [%-14s]\n' "$name"; PASS=$(( PASS + 1 ))
|
|
||||||
else
|
|
||||||
printf ' FAIL [%-14s] wanted rc=%d containing %q; got rc=%d\n%s\n' \
|
|
||||||
"$name" "$want_rc" "$want_txt" "$rc" "$out"; FAIL=$(( FAIL + 1 ))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "== POSITIVE CONTROL: an honestly-committed artifact must PASS =="
|
|
||||||
# Without this, every case below could be passing because the verifier always
|
|
||||||
# refuses -- a wall, not a gate.
|
|
||||||
mkrepo "$TMP/good" >/dev/null
|
|
||||||
run_case w1-honest 0 "the COMMITTED artifact ran and passed" "$TMP/good"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== THE DEPLOYED LAYOUT: artifacts NESTED, not at the repository root =="
|
|
||||||
# The blocker: ls-tree was given a bare basename, which is a ROOT-relative
|
|
||||||
# pathspec, so in the real tree every artifact came back NOT TRACKED and the
|
|
||||||
# verifier refused to run at all. This control fails against that version and
|
|
||||||
# passes only when the committed PREFIX is threaded through ls-tree, the cloned
|
|
||||||
# stat, and the suite's working directory.
|
|
||||||
w6dir="$(mkrepo "$TMP/nested" "$REAL_PREFIX")"
|
|
||||||
run_case w6-nested 0 "the COMMITTED artifact ran and passed" "$w6dir"
|
|
||||||
# ...and prove the run actually happened DOWN THERE rather than at the root, or
|
|
||||||
# a passing w6 would only mean the prefix was ignored harmlessly.
|
|
||||||
#
|
|
||||||
# Captured into a variable rather than piped into `grep -q` ON PURPOSE. grep -q
|
|
||||||
# exits at the FIRST match, the verifier then dies of SIGPIPE, and under
|
|
||||||
# `pipefail` the pipeline reports that 141 -- so A SUCCESSFUL MATCH READS AS A
|
|
||||||
# FAILED ASSERTION. This cost me a red w6-prefix against a verifier that was
|
|
||||||
# printing the right prefix all along. Same family as the pipefail/process-
|
|
||||||
# substitution note already on record: the exit status being consulted is not
|
|
||||||
# the status of the thing being asserted.
|
|
||||||
w6out="$("$VERIFY" --repo "$w6dir" 2>&1)"
|
|
||||||
if [[ "$w6out" == *"prefix $REAL_PREFIX/"* ]]; then
|
|
||||||
printf ' ok [%-14s] verifier reported prefix %s/\n' "w6-prefix" "$REAL_PREFIX"; PASS=$(( PASS + 1 ))
|
|
||||||
else
|
|
||||||
printf ' FAIL [%-14s] verifier did not report the nested prefix -- it may have\n' "w6-prefix"
|
|
||||||
printf ' passed by looking at the root, which is the blocker unfixed\n'; FAIL=$(( FAIL + 1 ))
|
|
||||||
fi
|
|
||||||
# And the mode needle must ALSO bite in the nested layout: a prefix threaded
|
|
||||||
# into the clone but not into ls-tree would silently stop checking modes.
|
|
||||||
w7dir="$(mkrepo "$TMP/nested-laundered" "$REAL_PREFIX")"
|
|
||||||
git -C "$TMP/nested-laundered" update-index --chmod=-x "$REAL_PREFIX/push-guard.sh"
|
|
||||||
git -C "$TMP/nested-laundered" commit -q -m "drop exec bit in git only"
|
|
||||||
run_case w7-nested-mode 1 "committed 100644, needs 100755" "$w7dir"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== THE B1 NEEDLE: source git mode 100644, DISK MODE STILL 755 =="
|
|
||||||
# This is the reviewer's exact reproduction. v1 of the verifier exits 0 here.
|
|
||||||
mkrepo "$TMP/laundered" >/dev/null
|
|
||||||
git -C "$TMP/laundered" update-index --chmod=-x push-guard.sh test-push-guard.sh
|
|
||||||
git -C "$TMP/laundered" commit -q -m "drop exec bit in git only"
|
|
||||||
# PROVE THE FIXTURE IS THE CONTAMINATED STATE, not merely a broken repo: git must
|
|
||||||
# say 100644 while the filesystem still says executable. If the disk bit were
|
|
||||||
# gone too, the needle would be testing something easier than the real defect.
|
|
||||||
src_mode="$(git -C "$TMP/laundered" ls-tree HEAD -- push-guard.sh | awk '{print $1}')"
|
|
||||||
disk_mode="$(stat -c '%a' "$TMP/laundered/push-guard.sh")"
|
|
||||||
if [[ "$src_mode" == "100644" && "$disk_mode" == "755" ]]; then
|
|
||||||
printf ' ok [%-14s] fixture is git=%s disk=%s\n' "w2-fixture" "$src_mode" "$disk_mode"; PASS=$(( PASS + 1 ))
|
|
||||||
else
|
|
||||||
printf ' FAIL [%-14s] fixture wrong: git=%s disk=%s -- needle would not test the defect\n' \
|
|
||||||
"w2-fixture" "$src_mode" "$disk_mode"; FAIL=$(( FAIL + 1 ))
|
|
||||||
fi
|
|
||||||
run_case w2-laundered 1 "committed 100644, needs 100755" "$TMP/laundered"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== the artifact must be COMMITTED, or there is no mode to verify =="
|
|
||||||
mkrepo "$TMP/untracked" >/dev/null
|
|
||||||
git -C "$TMP/untracked" rm -q --cached push-guard.sh
|
|
||||||
git -C "$TMP/untracked" commit -q -m "untrack the guard"
|
|
||||||
run_case w3-untracked 1 "NOT TRACKED" "$TMP/untracked"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== a committed SYMLINK is a path, not the reviewed code =="
|
|
||||||
mkrepo "$TMP/symlink" >/dev/null
|
|
||||||
( cd "$TMP/symlink" && rm -f push-guard.sh && ln -s /dev/null push-guard.sh \
|
|
||||||
&& git add push-guard.sh && git commit -q -m "symlink the guard" )
|
|
||||||
run_case w4-symlink 1 "SYMLINK" "$TMP/symlink"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== not a repository at all: REFUSE, never pass =="
|
|
||||||
mkdir -p "$TMP/bare"
|
|
||||||
for f in "${ARTIFACTS[@]}"; do install -m 755 "$HERE/$f" "$TMP/bare/$f"; done
|
|
||||||
run_case w5-norepo 1 "not inside a git repository" "$TMP/bare"
|
|
||||||
|
|
||||||
echo
|
|
||||||
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
|
|
||||||
(( FAIL == 0 ))
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# verify-clean-clone.sh -- prove the COMMITTED artifact runs, from a clean clone.
|
|
||||||
#
|
|
||||||
# ================== THIS FILE IS THE SECOND VERSION. THE FIRST HAD B1. ========
|
|
||||||
# B1 was: the delivered scripts were committed mode 100644, so the artifact
|
|
||||||
# returned 126 Permission denied for anyone who cloned it. Two of us ran 32/32
|
|
||||||
# because our local working copies had the exec bit set by hand at creation.
|
|
||||||
#
|
|
||||||
# I wrote v1 of this file to prevent exactly that. V1 COPIED THE WORKING-TREE
|
|
||||||
# FILES INTO A SCRATCH REPOSITORY AND ASSERTED THE SCRATCH REPOSITORY'S INDEX.
|
|
||||||
# cp preserves the local exec bit, so `git add` in the scratch repo recorded
|
|
||||||
# 100755 NO MATTER WHAT THE REAL REPOSITORY STORED. Reviewer reproduction:
|
|
||||||
# git update-index --chmod=-x push-guard.sh test-push-guard.sh
|
|
||||||
# ./verify-clean-clone.sh -> EXIT 0, "CLEAN CLONE: suite ran and passed"
|
|
||||||
# while the real index read 100644 -- the precise contaminated state B1 was.
|
|
||||||
#
|
|
||||||
# THE CONTROL FOR THE DEFECT INHERITED THE DEFECT, BECAUSE IT MEASURED A COPY
|
|
||||||
# INSTEAD OF THE SUBJECT. cp LAUNDERS MODE.
|
|
||||||
#
|
|
||||||
# Both of v1's mechanisms were right -- assert the INDEX not the disk, execute
|
|
||||||
# DIRECTLY not via `bash script`. They were applied to the wrong repository.
|
|
||||||
# So v2 changes what is measured, not how:
|
|
||||||
# * mode is read from the SOURCE repository's COMMITTED TREE (git ls-tree),
|
|
||||||
# which no local chmod can influence;
|
|
||||||
# * the tree under test is produced by CLONING THAT COMMIT, so every mode
|
|
||||||
# comes out of the object store rather than off my filesystem.
|
|
||||||
# There is no cp anywhere in this file, and that is deliberate.
|
|
||||||
#
|
|
||||||
# It also REFUSES rather than passes when the artifacts are untracked: an
|
|
||||||
# artifact that is not committed has no mode to verify, and a verifier that
|
|
||||||
# silently succeeds on nothing is the vacuous-absence failure all over again.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
readonly EX_FAIL=1
|
|
||||||
readonly EX_USAGE=64
|
|
||||||
|
|
||||||
REPO=""
|
|
||||||
REV="HEAD"
|
|
||||||
|
|
||||||
die() { printf 'usage error: %s\n' "$1" >&2; exit "$EX_USAGE"; }
|
|
||||||
|
|
||||||
while (( $# )); do
|
|
||||||
case "$1" in
|
|
||||||
--repo) REPO="${2:-}"; shift 2 ;;
|
|
||||||
--rev) REV="${2:-}"; shift 2 ;;
|
|
||||||
*) die "unknown argument: $1" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
[[ -n "$REPO" ]] || REPO="$HERE"
|
|
||||||
|
|
||||||
ARTIFACTS=(push-guard.sh test-push-guard.sh verify-clean-clone.sh mutate-push-guard.sh
|
|
||||||
test-mutate-push-guard.sh
|
|
||||||
test-verify-clean-clone.sh)
|
|
||||||
SUITE="test-push-guard.sh"
|
|
||||||
|
|
||||||
# --- the subject must be a real repository, or there is nothing to verify -----
|
|
||||||
if ! ROOT="$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null)"; then
|
|
||||||
printf 'REFUSING: %s is not inside a git repository.\n' "$REPO" >&2
|
|
||||||
printf 'This verifier checks the COMMITTED mode of the artifact. An uncommitted\n' >&2
|
|
||||||
printf 'artifact has no committed mode, and passing here would prove nothing.\n' >&2
|
|
||||||
exit "$EX_FAIL"
|
|
||||||
fi
|
|
||||||
if ! REV_SHA="$(git -C "$ROOT" rev-parse --verify --quiet "${REV}^{commit}")"; then
|
|
||||||
printf 'REFUSING: %s does not resolve to a commit in %s\n' "$REV" "$ROOT" >&2
|
|
||||||
exit "$EX_FAIL"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- THE ARTIFACT'S COMMITTED PATH, NOT ITS BASENAME -------------------------
|
|
||||||
# v2 asserted `git ls-tree HEAD -- push-guard.sh`, which is a ROOT-RELATIVE
|
|
||||||
# pathspec. These files really live several directories down. Run in place and
|
|
||||||
# every artifact came back "NOT TRACKED" -- the verifier built to stop packaging
|
|
||||||
# false-greens could not verify the tree that ships it.
|
|
||||||
#
|
|
||||||
# The tests missed it because EVERY FIXTURE INSTALLED THE ARTIFACTS AT THE
|
|
||||||
# FIXTURE ROOT. 6/6 green proved flat-layout operation and nothing about the
|
|
||||||
# deployed path. THAT IS THE THIRD TIME ON THIS TOOL THAT A CONTROL VALIDATED A
|
|
||||||
# MODEL INSTEAD OF THE SUBJECT: v1 measured a cp'd scratch repo, and then v2's
|
|
||||||
# own tests measured a layout that does not exist. A fixture is a claim about
|
|
||||||
# the world, and an untested fixture is an unreviewed one.
|
|
||||||
#
|
|
||||||
# --show-prefix answers "where is this directory inside its repository", so the
|
|
||||||
# same prefix drives ls-tree, the cloned stat, and the suite's working dir.
|
|
||||||
PREFIX="$(git -C "$REPO" rev-parse --show-prefix)"
|
|
||||||
|
|
||||||
printf '=== subject ===\n'
|
|
||||||
printf ' repo %s\n rev %s (%s)\n prefix %s\n\n' \
|
|
||||||
"$ROOT" "$REV" "$REV_SHA" "${PREFIX:-<repository root>}"
|
|
||||||
|
|
||||||
# --- 1. MODE, FROM THE COMMITTED TREE OF THE SUBJECT -------------------------
|
|
||||||
# git ls-tree reports what the COMMIT records. Nothing on my filesystem can
|
|
||||||
# move this number -- which is the whole point, and what v1 got wrong.
|
|
||||||
printf '=== committed modes (git ls-tree %s) ===\n' "$REV"
|
|
||||||
rc=0
|
|
||||||
for f in "${ARTIFACTS[@]}"; do
|
|
||||||
entry="$(git -C "$ROOT" ls-tree "$REV_SHA" -- "${PREFIX}${f}")"
|
|
||||||
if [[ -z "$entry" ]]; then
|
|
||||||
printf ' FAIL %s is NOT TRACKED at %s -- nothing committed to verify\n' "$f" "$REV"
|
|
||||||
rc=1; continue
|
|
||||||
fi
|
|
||||||
mode="${entry%% *}"; rest="${entry#* }"; type="${rest%% *}"
|
|
||||||
if [[ "$type" != blob ]]; then
|
|
||||||
printf ' FAIL %s is a %s at %s, not a regular file\n' "$f" "$type" "$REV"
|
|
||||||
rc=1; continue
|
|
||||||
fi
|
|
||||||
case "$mode" in
|
|
||||||
100755) printf ' ok %s committed %s\n' "$f" "$mode" ;;
|
|
||||||
120000) printf ' FAIL %s is a SYMLINK (%s) -- the reviewed blob is a path, not the code\n' "$f" "$mode"; rc=1 ;;
|
|
||||||
*) printf ' FAIL %s committed %s, needs 100755\n' "$f" "$mode"
|
|
||||||
printf ' fix with: git update-index --chmod=+x %s && commit\n' "$f"
|
|
||||||
rc=1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
if (( rc != 0 )); then
|
|
||||||
printf '\nREFUSING TO CONTINUE: the COMMITTED modes are wrong, whatever the disk says.\n'
|
|
||||||
printf 'A clone of this commit would exit 126 for the next person.\n'
|
|
||||||
exit "$EX_FAIL"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 2. RUN FROM A CLONE OF THAT COMMIT --------------------------------------
|
|
||||||
# Cloning materialises every file from the object store, so the modes on disk
|
|
||||||
# are the COMMITTED modes by construction. No cp, no local state, nothing this
|
|
||||||
# machine can contribute.
|
|
||||||
WORK="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$WORK"' EXIT
|
|
||||||
|
|
||||||
printf '\n=== cloning %s (no local mode bits survive this) ===\n' "$REV_SHA"
|
|
||||||
git clone -q --no-local --no-hardlinks "$ROOT" "$WORK/clone"
|
|
||||||
git -C "$WORK/clone" -c advice.detachedHead=false checkout -q "$REV_SHA"
|
|
||||||
|
|
||||||
for f in "${ARTIFACTS[@]}"; do
|
|
||||||
printf ' clone disk mode: %s %s\n' "$(stat -c '%a' "$WORK/clone/${PREFIX}${f}")" "${PREFIX}${f}"
|
|
||||||
done
|
|
||||||
|
|
||||||
printf '\n=== executing DIRECTLY (./%s), not via "bash %s" ===\n' "$SUITE" "$SUITE"
|
|
||||||
# Invoking the interpreter explicitly masks a missing exec bit completely -- it
|
|
||||||
# is how the original green was obtained. Direct execution is the thing under
|
|
||||||
# test, so the mode has to be real for this line to succeed.
|
|
||||||
cd "$WORK/clone/${PREFIX}"
|
|
||||||
if ! "./$SUITE"; then
|
|
||||||
printf '\nCLEAN-CLONE RUN FAILED.\n'
|
|
||||||
exit "$EX_FAIL"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '\n=== CLEAN CLONE: the COMMITTED artifact ran and passed ===\n'
|
|
||||||
@@ -91,12 +91,10 @@ fi
|
|||||||
|
|
||||||
if [[ -n "$dirty_files" ]]; then
|
if [[ -n "$dirty_files" ]]; then
|
||||||
echo " Modified files:"
|
echo " Modified files:"
|
||||||
mapfile -t dirty_lines <<<"$dirty_files"
|
echo "$dirty_files" | head -20 | while IFS= read -r line; do
|
||||||
file_count="${#dirty_lines[@]}"
|
echo " $line"
|
||||||
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 [[ "${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 [[ "$(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 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 [[ "${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
|
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
|
||||||
|
|
||||||
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"
|
||||||
grep -qi "dry run" <<<"$out" || note "dry-run did not announce itself"
|
echo "$out" | grep -qi "dry run" || note "dry-run did not announce itself"
|
||||||
grep -q "would roll" <<<"$out" || note "dry-run did not report a plan"
|
echo "$out" | grep -q "would roll" || 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 grep -qiE "$pattern" <<<"$PRD_CONTENT"; then
|
if echo "$PRD_CONTENT" | grep -qiE "$pattern"; 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 grep -qiE 'auth|login|session|token|permission|rbac|credential|secret' <<<"$p"; then echo auth; return; fi
|
if printf '%s' "$p" | grep -qiE 'auth|login|session|token|permission|rbac|credential|secret'; then echo auth; return; fi
|
||||||
if grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed' <<<"$p"; then echo data; return; fi
|
if printf '%s' "$p" | grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed'; then echo data; 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 'docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform'; then echo infra; 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 -qiE 'package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite'; then echo build; return; fi
|
||||||
if grep -qE '\.tsx|\.css|components/|apps/web/' <<<"$p"; then echo ui; return; fi
|
if printf '%s' "$p" | grep -qE '\.tsx|\.css|components/|apps/web/'; then echo ui; return; fi
|
||||||
if grep -qE '\.spec\.|\.test\.|__tests__/' <<<"$p"; then echo test; return; fi
|
if printf '%s' "$p" | grep -qE '\.spec\.|\.test\.|__tests__/'; then echo test; return; fi
|
||||||
if grep -qE '\.md$|docs/' <<<"$p"; then echo docs; return; fi
|
if printf '%s' "$p" | grep -qE '\.md$|docs/'; then echo docs; return; fi
|
||||||
echo none
|
echo none
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,7 @@ 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_pattern='"file_path"[[:space:]]*:[[:space:]]*"([^"]*)"'
|
FILE_PATH=$(echo "$JSON_INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -1)
|
||||||
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
|
||||||
@@ -58,7 +53,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=$(grep -A2 "$BASENAME" <<<"$OUTPUT" 2>/dev/null || sed -n '1,20p' <<<"$OUTPUT")
|
RELEVANT=$(echo "$OUTPUT" | grep -A2 "$BASENAME" 2>/dev/null || echo "$OUTPUT" | head -20)
|
||||||
|
|
||||||
echo "TypeScript type errors detected after editing $FILE_PATH:"
|
echo "TypeScript type errors detected after editing $FILE_PATH:"
|
||||||
echo "$RELEVANT"
|
echo "$RELEVANT"
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# check-test-enumeration.sh — CI test-membership guard (#1017).
|
|
||||||
#
|
|
||||||
# CI reaches shell suites through two hand-enumerated surfaces:
|
|
||||||
# S1 packages/mosaic/package.json scripts."test:framework-shell"
|
|
||||||
# S2 .woodpecker/ci.yml direct `bash packages/mosaic/framework/tools/...` commands
|
|
||||||
#
|
|
||||||
# A hand-enumerated allowlist re-arms its own gap: a new suite never auto-joins,
|
|
||||||
# so the list silently under-runs the disk (17 of 39 suites were invisible when
|
|
||||||
# #1017 was filed). This guard makes that under-run impossible to do silently:
|
|
||||||
#
|
|
||||||
# FAIL when a suite-shaped file exists on disk and is neither enumerated on
|
|
||||||
# the UNION of both surfaces nor listed in the exclusions file.
|
|
||||||
# ("Enumerated", deliberately — F1/F2 on PR #1018 proved this guard sees
|
|
||||||
# NAMING, not reachability, and its words must not claim otherwise.)
|
|
||||||
# FAIL when either surface names a path that does not exist on disk
|
|
||||||
# (a rename manufactures a stale entry silently — checked BOTH directions).
|
|
||||||
# FAIL when an exclusion entry has no reason, names a path that is gone,
|
|
||||||
# names a path that is also enumerated (contradiction), or names a path
|
|
||||||
# outside the population (dead weight that looks like coverage).
|
|
||||||
#
|
|
||||||
# POPULATION PATTERN — a deliberate decision, stated per #1017's record:
|
|
||||||
# basename matches *test*.sh (contains "test", ends ".sh"). Deliberately BROAD:
|
|
||||||
# the strict `test-*.sh` prefix cannot even name three real boundary files
|
|
||||||
# (tmux/agent-send.test.sh — CI-run; orchestrator/smoke-test.sh;
|
|
||||||
# wake/validate-973/microtest-wake-assert.sh), and three independent censuses
|
|
||||||
# handled that last file three different ways with no trace of the judgement.
|
|
||||||
# The broad pattern makes such files MEMBERS, so their disposition must be a
|
|
||||||
# signed exclusion, not an accident of the glob. The SAME pattern is applied to
|
|
||||||
# both sides of the comparison (disk and enumeration) — a comparison globbed two
|
|
||||||
# ways runs on two different populations. Scripts outside the pattern on both
|
|
||||||
# sides symmetrically (e.g. check-resident-budget.sh, verify-sanitized.sh) are
|
|
||||||
# check-scripts, not suites; their existence is still verified via the
|
|
||||||
# both-directions rule because every surface-named path must exist on disk.
|
|
||||||
#
|
|
||||||
# The surfaces are PARSED, never line-ranged: three seats independently
|
|
||||||
# mis-scoped hand-written line ranges against these files (#1017 thread). S1 is
|
|
||||||
# read via JSON + command-chain tokenization; S2 by extracting every
|
|
||||||
# packages/mosaic/framework/tools/ token wherever it appears in the file.
|
|
||||||
#
|
|
||||||
# Exclusions file format (framework/tools/quality/test-enumeration-exclusions.txt):
|
|
||||||
# <repo-relative-path> | <non-empty reason>
|
|
||||||
# Lines starting with # and blank lines are ignored. An exclusion is a recorded
|
|
||||||
# decision someone signed, not an omission nobody made.
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT="$(cd "$SCRIPT_DIR/../../../../../.." && pwd)"
|
|
||||||
while (( $# )); do
|
|
||||||
case "$1" in
|
|
||||||
--root) ROOT="$(cd "$2" && pwd)"; shift 2 ;;
|
|
||||||
*) echo "usage: check-test-enumeration.sh [--root <repo-root>]" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
PKG_JSON="$ROOT/packages/mosaic/package.json"
|
|
||||||
CI_YML="$ROOT/.woodpecker/ci.yml"
|
|
||||||
TOOLS_DIR="$ROOT/packages/mosaic/framework/tools"
|
|
||||||
EXCLUSIONS="$TOOLS_DIR/quality/test-enumeration-exclusions.txt"
|
|
||||||
|
|
||||||
for f in "$PKG_JSON" "$CI_YML"; do
|
|
||||||
[[ -f "$f" ]] || { echo "FAIL: required surface file missing: $f" >&2; exit 2; }
|
|
||||||
done
|
|
||||||
[[ -d "$TOOLS_DIR" ]] || { echo "FAIL: tools dir missing: $TOOLS_DIR" >&2; exit 2; }
|
|
||||||
|
|
||||||
fail_count=0
|
|
||||||
fail() { printf 'FAIL %s\n' "$1"; fail_count=$(( fail_count + 1 )); }
|
|
||||||
|
|
||||||
# in_population <repo-relative path> — the single pattern, used for BOTH sides.
|
|
||||||
in_population() {
|
|
||||||
local base; base="$(basename "$1")"
|
|
||||||
[[ "$base" == *test*.sh ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Surface 1: package.json test:framework-shell, parsed, repo-relative -----
|
|
||||||
# Tokens are script paths iff they contain "/" and end .sh/.py; interpreter
|
|
||||||
# names and flags are skipped. Paths are relative to packages/mosaic/.
|
|
||||||
mapfile -t S1 < <(python3 - "$PKG_JSON" <<'PY'
|
|
||||||
import json, shlex, sys
|
|
||||||
cmd = json.load(open(sys.argv[1]))["scripts"].get("test:framework-shell", "")
|
|
||||||
seen = []
|
|
||||||
for seg in cmd.split("&&"):
|
|
||||||
for tok in shlex.split(seg):
|
|
||||||
if "/" in tok and (tok.endswith(".sh") or tok.endswith(".py")):
|
|
||||||
path = "packages/mosaic/" + tok
|
|
||||||
if path not in seen:
|
|
||||||
seen.append(path)
|
|
||||||
print("\n".join(seen))
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Surface 2: ci.yml, every framework/tools token wherever it appears ------
|
|
||||||
# Comment lines (first non-whitespace char is #) are skipped BEFORE matching:
|
|
||||||
# commenting an invocation out is the most common way a suite actually gets
|
|
||||||
# disabled, and a raw-text regex would keep calling it enumerated (F1, 20155 on
|
|
||||||
# PR #1018 — demonstrated, not argued). Known residual limit: a path named only
|
|
||||||
# in a TRAILING comment on a live line still matches; no such line exists today
|
|
||||||
# and full fidelity would need a YAML parser the CI image does not ship.
|
|
||||||
mapfile -t S2 < <(grep -vE '^[[:space:]]*#' "$CI_YML" \
|
|
||||||
| grep -oE 'packages/mosaic/framework/tools/[A-Za-z0-9_./-]+\.(sh|py)' | sort -u)
|
|
||||||
|
|
||||||
# --- Union, and its population-restricted view -------------------------------
|
|
||||||
declare -A ENUM=() ENUM_POP=()
|
|
||||||
for p in "${S1[@]:-}" "${S2[@]:-}"; do
|
|
||||||
[[ -n "$p" ]] || continue
|
|
||||||
ENUM["$p"]=1
|
|
||||||
in_population "$p" && ENUM_POP["$p"]=1
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Direction B: every surface-named path must exist on disk ----------------
|
|
||||||
for p in "${!ENUM[@]}"; do
|
|
||||||
[[ -f "$ROOT/$p" ]] || fail "STALE ENUMERATION: surfaces name '$p' but it does not exist on disk"
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Exclusions: parsed with the same rigor the enumeration gets -------------
|
|
||||||
declare -A EXCLUDED=()
|
|
||||||
if [[ -f "$EXCLUSIONS" ]]; then
|
|
||||||
lineno=0
|
|
||||||
while IFS= read -r line; do
|
|
||||||
lineno=$(( lineno + 1 ))
|
|
||||||
[[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue
|
|
||||||
path="${line%%|*}"; reason="${line#*|}"
|
|
||||||
path="$(echo "$path" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
|
||||||
reason="$(echo "$reason" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
|
||||||
if [[ "$line" != *"|"* || -z "$reason" ]]; then
|
|
||||||
fail "EXCLUSION MISSING REASON: line $lineno ('$path') — an exclusion is a recorded decision someone signed"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [[ ! -f "$ROOT/$path" ]]; then
|
|
||||||
fail "STALE EXCLUSION: line $lineno excludes '$path' which does not exist on disk"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if ! in_population "$path"; then
|
|
||||||
fail "EXCLUSION OUTSIDE POPULATION: line $lineno excludes '$path' which the population pattern does not name — dead weight that reads as coverage"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [[ -n "${ENUM[$path]:-}" ]]; then
|
|
||||||
fail "CONTRADICTORY EXCLUSION: line $lineno excludes '$path' which the surfaces already enumerate"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
EXCLUDED["$path"]=1
|
|
||||||
done < "$EXCLUSIONS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Direction A: disk population must be enumerated or signed-excluded ------
|
|
||||||
disk_total=0
|
|
||||||
unlisted=0
|
|
||||||
while IFS= read -r f; do
|
|
||||||
rel="${f#"$ROOT"/}"
|
|
||||||
in_population "$rel" || continue
|
|
||||||
disk_total=$(( disk_total + 1 ))
|
|
||||||
if [[ -z "${ENUM_POP[$rel]:-}" && -z "${EXCLUDED[$rel]:-}" ]]; then
|
|
||||||
fail "UNENUMERATED: '$rel' exists on disk but is neither enumerated on any CI surface nor signed in the exclusions file"
|
|
||||||
unlisted=$(( unlisted + 1 ))
|
|
||||||
fi
|
|
||||||
done < <(find "$TOOLS_DIR" -type f -name '*.sh' | sort)
|
|
||||||
|
|
||||||
if (( fail_count > 0 )); then
|
|
||||||
printf 'enumeration guard: %d failure(s) — population %d, enumerated (in-population) %d, excluded %d\n' \
|
|
||||||
"$fail_count" "$disk_total" "${#ENUM_POP[@]}" "${#EXCLUDED[@]}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf 'enumeration guard: OK — population %d, enumerated (in-population) %d, excluded (signed) %d, surfaces name %d path(s), all present on disk\n' \
|
|
||||||
"$disk_total" "${#ENUM_POP[@]}" "${#EXCLUDED[@]}" "${#ENUM[@]}"
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# test-check-test-enumeration.sh — needles for the enumeration guard (#1017).
|
|
||||||
#
|
|
||||||
# Every failure mode the guard promises gets BOTH polarities:
|
|
||||||
# NEEDLE a fixture that MUST trip the guard, asserted on the guard's OWN
|
|
||||||
# words (--out) — exit 1 alone cannot distinguish "caught the rogue
|
|
||||||
# file" from "choked on the fixture".
|
|
||||||
# CONTROL a fixture that MUST pass. A guard that failed unconditionally
|
|
||||||
# would satisfy every needle here — the null-case defect the guard's
|
|
||||||
# own subject matter (#1017) exists to make impossible.
|
|
||||||
#
|
|
||||||
# The needles encode the specific errors that produced #1017's thread:
|
|
||||||
# n6 is the 20124 boundary file (a suite the strict prefix cannot name);
|
|
||||||
# n2b proves surface 2 is PARSED, not line-ranged (three seats mis-scoped
|
|
||||||
# hand-written ranges against ci.yml);
|
|
||||||
# n5/n7 keep the exclusions file honest so it cannot become the next silent cap;
|
|
||||||
# n8/c4 are F1 (20155): a commented-out ci.yml line is NOT enumeration —
|
|
||||||
# commenting-out is the most common way a suite actually gets disabled,
|
|
||||||
# and it must fail loud in one direction without false-staling the other.
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
GUARD="$HERE/check-test-enumeration.sh"
|
|
||||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
||||||
PASS=0; FAIL=0
|
|
||||||
|
|
||||||
# fixture <name> — a minimal repo root the guard accepts via --root:
|
|
||||||
# one suite enumerated on S1 (plus a naming-outlier suite, so the S1 parser's
|
|
||||||
# handling of non-prefix names is always exercised), one on S2, one check-script
|
|
||||||
# named on S2 that is outside the population, and an empty exclusions file.
|
|
||||||
fixture() {
|
|
||||||
local r="$TMP/$1"
|
|
||||||
mkdir -p "$r/packages/mosaic/framework/tools/git" \
|
|
||||||
"$r/packages/mosaic/framework/tools/tmux" \
|
|
||||||
"$r/packages/mosaic/framework/tools/quality/scripts" \
|
|
||||||
"$r/.woodpecker"
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/git/test-a.sh"
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/tmux/outlier.test.sh"
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/quality/scripts/test-ci.sh"
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/quality/scripts/verify-thing.sh"
|
|
||||||
cat > "$r/packages/mosaic/package.json" <<'JSON'
|
|
||||||
{"scripts": {"test:framework-shell": "bash framework/tools/git/test-a.sh && bash framework/tools/tmux/outlier.test.sh"}}
|
|
||||||
JSON
|
|
||||||
cat > "$r/.woodpecker/ci.yml" <<'YML'
|
|
||||||
steps:
|
|
||||||
sanitize:
|
|
||||||
commands:
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/verify-thing.sh
|
|
||||||
guard:
|
|
||||||
commands:
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-ci.sh
|
|
||||||
YML
|
|
||||||
: > "$r/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt"
|
|
||||||
printf '%s' "$r"
|
|
||||||
}
|
|
||||||
|
|
||||||
# expect <kind> <want-exit> <desc> [--out <substring>] -- <root>
|
|
||||||
expect() {
|
|
||||||
local kind="$1" want="$2" desc="$3"; shift 3
|
|
||||||
local need_out=""
|
|
||||||
while (( $# )); do
|
|
||||||
case "$1" in
|
|
||||||
--out) need_out="$2"; shift 2 ;;
|
|
||||||
--) shift; break ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
local root="$1" got=0 out
|
|
||||||
out="$(bash "$GUARD" --root "$root" 2>&1)" || got=$?
|
|
||||||
local why=""
|
|
||||||
[[ "$got" == "$want" ]] || why="wanted exit $want, got $got"
|
|
||||||
if [[ -z "$why" && -n "$need_out" && "$out" != *"$need_out"* ]]; then
|
|
||||||
why="exit $got as expected, but output never said: $need_out"
|
|
||||||
fi
|
|
||||||
if [[ -z "$why" ]]; then
|
|
||||||
printf ' PASS [%-7s] %s (exit %s)\n' "$kind" "$desc" "$got"
|
|
||||||
PASS=$(( PASS + 1 ))
|
|
||||||
else
|
|
||||||
printf ' FAIL [%-7s] %s — %s\n' "$kind" "$desc" "$why"
|
|
||||||
printf '%s\n' "$out" | sed 's/^/ | /'
|
|
||||||
FAIL=$(( FAIL + 1 ))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
excl() { printf '%s\n' "$2" >> "$1/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt"; }
|
|
||||||
|
|
||||||
echo "=== c1: a fully consistent fixture passes ==="
|
|
||||||
R="$(fixture c1)"
|
|
||||||
expect CONTROL 0 "consistent tree: both surfaces enumerated, nothing unlisted" \
|
|
||||||
--out "enumeration guard: OK" -- "$R"
|
|
||||||
|
|
||||||
echo "=== n1: an on-disk suite reachable from no surface must fail ==="
|
|
||||||
R="$(fixture n1)"
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
|
||||||
expect NEEDLE 1 "unlisted suite is named in the failure" \
|
|
||||||
--out "UNENUMERATED: 'packages/mosaic/framework/tools/git/test-rogue.sh'" -- "$R"
|
|
||||||
|
|
||||||
echo "=== n6: the 20124 boundary file — a suite the strict prefix cannot name ==="
|
|
||||||
R="$(fixture n6)"
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/rogue.test.sh"
|
|
||||||
expect NEEDLE 1 "naming-outlier suite (*.test.sh) is a population member, not invisible" \
|
|
||||||
--out "UNENUMERATED: 'packages/mosaic/framework/tools/git/rogue.test.sh'" -- "$R"
|
|
||||||
|
|
||||||
echo "=== c3: a non-suite script outside the pattern is outside it on BOTH sides ==="
|
|
||||||
R="$(fixture c3)"
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/check-unrelated.sh"
|
|
||||||
expect CONTROL 0 "check-script on disk, unlisted, outside population: not the guard's business" \
|
|
||||||
--out "enumeration guard: OK" -- "$R"
|
|
||||||
|
|
||||||
echo "=== n2/n2b: a surface naming a path absent from disk must fail — both surfaces ==="
|
|
||||||
R="$(fixture n2)"
|
|
||||||
rm "$R/packages/mosaic/framework/tools/git/test-a.sh"
|
|
||||||
expect NEEDLE 1 "S1 (package.json) stale entry" \
|
|
||||||
--out "STALE ENUMERATION: surfaces name 'packages/mosaic/framework/tools/git/test-a.sh'" -- "$R"
|
|
||||||
R="$(fixture n2b)"
|
|
||||||
rm "$R/packages/mosaic/framework/tools/quality/scripts/test-ci.sh"
|
|
||||||
expect NEEDLE 1 "S2 (ci.yml) stale entry — proves ci.yml is parsed, not line-ranged" \
|
|
||||||
--out "STALE ENUMERATION: surfaces name 'packages/mosaic/framework/tools/quality/scripts/test-ci.sh'" -- "$R"
|
|
||||||
|
|
||||||
echo "=== c2: a rogue suite with a SIGNED exclusion passes, and is counted ==="
|
|
||||||
R="$(fixture c2)"
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
|
||||||
excl "$R" "packages/mosaic/framework/tools/git/test-rogue.sh | non-hermetic pending fixture work (needle-suite specimen)"
|
|
||||||
expect CONTROL 0 "signed exclusion is honoured and visible in the summary" \
|
|
||||||
--out "excluded (signed) 1" -- "$R"
|
|
||||||
|
|
||||||
echo "=== n3: an exclusion with no reason is not a decision ==="
|
|
||||||
R="$(fixture n3)"
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
|
||||||
excl "$R" "packages/mosaic/framework/tools/git/test-rogue.sh | "
|
|
||||||
expect NEEDLE 1 "empty reason rejected" --out "EXCLUSION MISSING REASON" -- "$R"
|
|
||||||
R="$(fixture n3b)"
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
|
||||||
excl "$R" "packages/mosaic/framework/tools/git/test-rogue.sh"
|
|
||||||
expect NEEDLE 1 "missing separator rejected (the path alone is not a signature)" \
|
|
||||||
--out "EXCLUSION MISSING REASON" -- "$R"
|
|
||||||
|
|
||||||
echo "=== n4: an exclusion whose path is gone is stale, not satisfied ==="
|
|
||||||
R="$(fixture n4)"
|
|
||||||
excl "$R" "packages/mosaic/framework/tools/git/test-vanished.sh | was excluded once, then deleted"
|
|
||||||
expect NEEDLE 1 "stale exclusion rejected" --out "STALE EXCLUSION" -- "$R"
|
|
||||||
|
|
||||||
echo "=== n5: excluding an enumerated suite is a contradiction, not belt-and-braces ==="
|
|
||||||
R="$(fixture n5)"
|
|
||||||
excl "$R" "packages/mosaic/framework/tools/git/test-a.sh | already in CI but excluded anyway"
|
|
||||||
expect NEEDLE 1 "contradictory exclusion rejected" --out "CONTRADICTORY EXCLUSION" -- "$R"
|
|
||||||
|
|
||||||
echo "=== n8/c4: a commented-out ci.yml line is not enumeration (F1, 20155) ==="
|
|
||||||
R="$(fixture n8)"
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-disabled.sh"
|
|
||||||
printf ' # - bash packages/mosaic/framework/tools/git/test-disabled.sh\n' >> "$R/.woodpecker/ci.yml"
|
|
||||||
expect NEEDLE 1 "suite named only in a commented-out invocation is UNENUMERATED" \
|
|
||||||
--out "UNENUMERATED: 'packages/mosaic/framework/tools/git/test-disabled.sh'" -- "$R"
|
|
||||||
R="$(fixture c4)"
|
|
||||||
printf ' # - bash packages/mosaic/framework/tools/git/test-vanished.sh\n' >> "$R/.woodpecker/ci.yml"
|
|
||||||
expect CONTROL 0 "comment naming an absent path raises no false stale-enumeration" \
|
|
||||||
--out "enumeration guard: OK" -- "$R"
|
|
||||||
|
|
||||||
echo "=== n7: excluding a file outside the population is dead weight, not coverage ==="
|
|
||||||
R="$(fixture n7)"
|
|
||||||
excl "$R" "packages/mosaic/framework/tools/quality/scripts/verify-thing.sh | not a suite but signing it anyway"
|
|
||||||
expect NEEDLE 1 "out-of-population exclusion rejected" --out "EXCLUSION OUTSIDE POPULATION" -- "$R"
|
|
||||||
|
|
||||||
echo
|
|
||||||
printf 'enumeration-guard needles: %d passed, %d failed\n' "$PASS" "$FAIL"
|
|
||||||
(( FAIL == 0 ))
|
|
||||||
@@ -176,12 +176,8 @@ 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
|
| LC_ALL=C sort -r | head -1
|
||||||
)
|
|
||||||
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,12 +39,11 @@ 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" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
trap 'rm -f "$STRIPPED" "$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; }
|
||||||
@@ -181,86 +180,41 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
|||||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
# ── 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. The earlier test used a child cp shim to signal its parent, making
|
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
||||||
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
# fires and ONLY the signal path governs), and assert the shipped installer
|
||||||
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
# restores AND exits without reporting success. The control strips `exit 1` from
|
||||||
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
# the trap and shows the buggy resume-to-success.
|
||||||
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
make_term_shim() {
|
||||||
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
local dir="$1"
|
||||||
# than manufacturing a pass. The only semantic difference between fixtures is
|
cat > "$dir/cp" <<SHIM
|
||||||
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
#!/usr/bin/env bash
|
||||||
TERM_MARKER='[test-control] TERM handler entered'
|
dest="\${@: -1}"
|
||||||
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
case "\$dest" in
|
||||||
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
*/$POISON_REL)
|
||||||
|
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
||||||
make_signal_installer() {
|
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
||||||
local output="$1" handler="$2"
|
esac
|
||||||
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||||
local target_cp=' cp "$abs" "$dst/$rel"'
|
SHIM
|
||||||
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
|
chmod +x "$dir/cp"
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
||||||
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 rc
|
local installer="$1" H OUT SHIM rc
|
||||||
H=$(mktemp -d); OUT=$(mktemp)
|
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||||
seed_home "$H"
|
seed_home "$H"
|
||||||
|
make_term_shim "$SHIM"
|
||||||
set +e
|
set +e
|
||||||
PATH="$ORIG_PATH" \
|
PATH="$SHIM:$ORIG_PATH" \
|
||||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
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 "$SIGNALED")
|
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
||||||
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" \
|
||||||
@@ -268,13 +222,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'"
|
||||||
|
|
||||||
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
||||||
chk "[control] TERM handler observable fires exactly once" \
|
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
||||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
||||||
chk "[control] without 'exit 1' the handler restores before returning" \
|
"$INSTALL" > "$NOEXIT"
|
||||||
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
chk "[control] the exit-strip actually changed the installer" \
|
||||||
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
"! cmp -s '$INSTALL' '$NOEXIT'"
|
||||||
"[ '$rcD' -eq 0 ]"
|
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
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'"
|
||||||
|
|
||||||
@@ -336,7 +290,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 -m1 -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG")"
|
SNAP_E="$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG" | head -1)"
|
||||||
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" \
|
||||||
@@ -353,13 +307,12 @@ 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).
|
||||||
orphan_snapshot="$(grep -m1 -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null || true)"
|
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
||||||
[ -n "$orphan_snapshot" ] && rm -rf "$orphan_snapshot"
|
|
||||||
|
|
||||||
# Cleanup (generated installer controls are also removed by the EXIT trap).
|
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
|
||||||
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
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" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "RESULT: $pass passed, $fail failed"
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
# test-enumeration-exclusions.txt — signed exclusions for check-test-enumeration.sh (#1017).
|
|
||||||
#
|
|
||||||
# Every entry is a recorded decision: a suite-shaped file that exists on disk,
|
|
||||||
# is NOT reachable from any CI surface, and carries the reason someone signed
|
|
||||||
# for that. The guard FAILS on an entry with no reason, a stale path, or a path
|
|
||||||
# the surfaces already enumerate. Burning an entry down = making it CI-reachable
|
|
||||||
# (package.json test:framework-shell or a ci.yml step) and deleting its line.
|
|
||||||
#
|
|
||||||
# Format: <repo-relative path> | <reason>
|
|
||||||
# All entries below were signed at #1017's filing base (main 826a8b3b, 2026-07-31)
|
|
||||||
# by pepper (sb-it-1-dt); measurements cited are one-run assertions from that seat.
|
|
||||||
|
|
||||||
# --- tools/git: the #1007 five — non-hermetic, resolve real credentials ---
|
|
||||||
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
|
|
||||||
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
|
||||||
packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
|
||||||
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
|
|
||||||
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
|
|
||||||
|
|
||||||
# --- tools/git: push guards — measured green locally, CI-image fitness unverified ---
|
|
||||||
packages/mosaic/framework/tools/git/test-push-guard.sh | measured green at 826a8b3b (46 passed / 0 failed, one run, 2026-07-31); CI-image fitness unverified; #1017 burndown
|
|
||||||
packages/mosaic/framework/tools/git/test-mutate-push-guard.sh | measured green at 826a8b3b (8/0, 13 mutants killed 0 survived, one run, 2026-07-31); requires setsid (util-linux), absent from the alpine base image; #1017 burndown
|
|
||||||
packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh | hermeticity unaudited — the unprotected suite in #1007's protected/unprotected split; audit before CI; #1017 burndown
|
|
||||||
|
|
||||||
# --- tools/git: unmeasured ---
|
|
||||||
packages/mosaic/framework/tools/git/test-verify-clean-clone.sh | unmeasured in CI image; asserts git file-mode (100644/755) semantics that need verification on the CI filesystem first; #1017 burndown
|
|
||||||
packages/mosaic/framework/tools/git/test-help-exit-code.sh | unmeasured in CI image; stub-based (#701 regression harness), likely CI-fit; #1017 burndown
|
|
||||||
packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh | unmeasured in CI image; fixture-based (#546/#547 regression harness), likely CI-fit; #1017 burndown
|
|
||||||
|
|
||||||
# --- tools/tmux: require a live tmux server ---
|
|
||||||
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
|
|
||||||
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 ---
|
|
||||||
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/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
|
|
||||||
|
|
||||||
# --- naming-boundary files the strict test-*.sh prefix cannot even name ---
|
|
||||||
# (#1017: three independent censuses handled the microtest file three different
|
|
||||||
# ways — editorial drop, structural exclusion, accidental inclusion — with no
|
|
||||||
# recorded judgement. These lines ARE that judgement, signed.)
|
|
||||||
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
|
|
||||||
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
|
|
||||||
@@ -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 grep -qF "$QUEUED_RE" <<<"$pane"; then
|
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; 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" ] && grep -qF "$snippet" <<<"$promptline"; then
|
if [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; 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,20 +34,16 @@ 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
|
||||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
capture_named | grep -qF "named socket hello" || fail "send-message.sh did not deliver to named socket"
|
||||||
grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket"
|
if capture_default | grep -qF "named socket hello"; then
|
||||||
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
|
||||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
capture_named | grep -qF "[tester:source ->" || fail "agent-send.sh did not include preamble"
|
||||||
grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble"
|
capture_named | grep -qF "agent socket hello" || fail "agent-send.sh did not deliver to named socket"
|
||||||
grep -qF "agent socket hello" <<<"$named_pane" || fail "agent-send.sh did not deliver to named socket"
|
if capture_default | grep -qF "agent socket hello"; then
|
||||||
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
|
||||||
|
|
||||||
@@ -69,11 +65,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)
|
||||||
grep -qF "CONCPAYLOAD-${i}-END" <<<"$pane" \
|
printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${i}-END" \
|
||||||
|| 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 grep -qF "CONCPAYLOAD-${j}-END" <<<"$pane"; then
|
if printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${j}-END"; 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 ] && grep -qF "✓ delivered" <<<"$out"; then
|
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qF "✓ delivered"; 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")]"
|
||||||
|
|||||||
@@ -191,177 +191,3 @@ _wake_init_dir() {
|
|||||||
[ -f "$dir/pending.jsonl" ] || printf '' | _atomic_write "$dir/pending.jsonl"
|
[ -f "$dir/pending.jsonl" ] || printf '' | _atomic_write "$dir/pending.jsonl"
|
||||||
[ -f "$dir/ack-ledger.jsonl" ] || printf '' | _atomic_write "$dir/ack-ledger.jsonl"
|
[ -f "$dir/ack-ledger.jsonl" ] || printf '' | _atomic_write "$dir/ack-ledger.jsonl"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# #973 — three-valued grep assertion helpers for the wake test suites.
|
|
||||||
#
|
|
||||||
# grep's exit contract is three-valued: 0 = match, 1 = no match, >1 = ERROR
|
|
||||||
# (bad file, bad pattern, resource failure). Every wake-suite assertion used
|
|
||||||
# to read all non-zero as "absent", so a grep that COULD NOT LOOK wore the
|
|
||||||
# colour of a verdict: OR-polarity sites (`|| fail`) went falsely red,
|
|
||||||
# AND-polarity sites (`&& fail` — including the credential canaries) went
|
|
||||||
# falsely green. The repair is to refuse to answer: rc 0 -> match, rc 1 -> no
|
|
||||||
# match, anything else -> loud abort naming the call site, the raw exit code,
|
|
||||||
# and the arguments. An error NEVER becomes a verdict.
|
|
||||||
#
|
|
||||||
# Production tools (store.sh, ack.sh) source this file but call none of the
|
|
||||||
# helpers below; they are inert outside the suites.
|
|
||||||
#
|
|
||||||
# Suite integration contract:
|
|
||||||
# - Call `wake_assert_init` ONCE at suite top level, right after sourcing.
|
|
||||||
# It dups the suite's real stderr to a saved fd BEFORE any call-site
|
|
||||||
# redirect exists, so an abort stays loud even at sites that append
|
|
||||||
# `2>/dev/null` (the preimage credential canaries pre-swallow stderr —
|
|
||||||
# exactly where a silent abort would recreate the defect being fixed).
|
|
||||||
# - Assertion sites live inside `( ... ) && ok` subshell blocks, pipelines,
|
|
||||||
# and `$(...)` substitutions, where a plain `exit` dies one layer deep and
|
|
||||||
# the suite would carry on to emit a verdict. The abort therefore signals
|
|
||||||
# the suite's MAIN shell ($$ is the main PID in every subshell) and then
|
|
||||||
# exits the current context: the suite dies by signal, non-zero, with NO
|
|
||||||
# verdict line emitted.
|
|
||||||
#
|
|
||||||
# Validation instrumentation (#973 evidence, not part of the assertion fix):
|
|
||||||
# - WAKE_ASSERT_LEDGER=<file>: every helper call appends
|
|
||||||
# "<helper> <caller-file>:<caller-line>" to <file>. That is the ONLY
|
|
||||||
# divergence from production behaviour — the suite otherwise runs its
|
|
||||||
# normal arms, so a validate run exercises exactly the shipped paths.
|
|
||||||
# - WAKE_ASSERT_FORCE_GREP_ERROR_AT=<caller-file>:<caller-line>: at exactly
|
|
||||||
# that call site, the invocation is routed through a REAL grep driven onto
|
|
||||||
# its real error path (unknown option -> rc 2) — a genuinely executed
|
|
||||||
# failing process, not a stubbed return — to prove per-site that the abort
|
|
||||||
# fires. Unset in production; matching no site is a no-op.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# wake_assert_init — dup the suite's real stderr once, for abort loudness.
|
|
||||||
# MUST be called at suite TOP LEVEL, immediately after sourcing and before any
|
|
||||||
# test block: a lazy (first-call) dup could capture an already-redirected
|
|
||||||
# stderr if the first executed helper call sat under a call-site 2>/dev/null,
|
|
||||||
# silencing every abort thereafter. The fd is allocated dynamically (>= 10),
|
|
||||||
# so it cannot collide with the wake lock fds (8) or the detector run-loop
|
|
||||||
# lock (9).
|
|
||||||
#
|
|
||||||
# Init also PINS the BASH_LINENO convention the site coordinates depend on:
|
|
||||||
# a helper call written across a backslash continuation must report at its
|
|
||||||
# FIRST physical line (the denominator artifact's convention). That was
|
|
||||||
# measured on a developer bash (5.3.x); CI runs whatever bash its base image
|
|
||||||
# baked in, and that version floats silently between image rebuilds. A bash
|
|
||||||
# that disagrees would shift every continuation-site coordinate by one line
|
|
||||||
# UNDER the validation instead of in front of it — so the convention is
|
|
||||||
# asserted at runtime, in the same bash binary that runs the suite, and a
|
|
||||||
# disagreeing bash aborts the suite loudly instead of skewing coordinates.
|
|
||||||
_wake_assert_lineno_pin() {
|
|
||||||
local _wa_pin_tmp _wa_pin_got
|
|
||||||
_wa_pin_tmp="$(mktemp)" || {
|
|
||||||
_wake_assert_err_note "WAKE-ASSERT INIT ABORT: mktemp failed; cannot pin the BASH_LINENO convention — a pin that silently does not run is not a pin (#973)"
|
|
||||||
exit 97
|
|
||||||
}
|
|
||||||
cat >"$_wa_pin_tmp" <<'WAKE_ASSERT_PIN'
|
|
||||||
_wap() { printf '%s\n' "${BASH_LINENO[0]}"; }
|
|
||||||
(
|
|
||||||
_wap simple
|
|
||||||
_wap \
|
|
||||||
continuation
|
|
||||||
)
|
|
||||||
WAKE_ASSERT_PIN
|
|
||||||
# WAKE_ASSERT_PIN_BASH: test-only interpreter override so the pin's abort
|
|
||||||
# arm can be PROVEN to fire (microtest C10) — bash resets $BASH at startup,
|
|
||||||
# so the real probe interpreter cannot be spoofed from the environment.
|
|
||||||
_wa_pin_got="$("${WAKE_ASSERT_PIN_BASH:-${BASH:-bash}}" "$_wa_pin_tmp" 2>/dev/null)"
|
|
||||||
rm -f "$_wa_pin_tmp"
|
|
||||||
if [ "$_wa_pin_got" != "$(printf '3\n4')" ]; then
|
|
||||||
_wake_assert_err_note "WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated on bash ${BASH_VERSION}: probe reported [${_wa_pin_got:-<no output>}], expected [3 4] (simple call at own line, continuation call at FIRST physical line) — site coordinates are untrustworthy on this bash (#973)"
|
|
||||||
exit 97
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
wake_assert_init() {
|
|
||||||
if [ -z "${_wake_assert_err_fd:-}" ]; then
|
|
||||||
exec {_wake_assert_err_fd}>&2
|
|
||||||
_wake_assert_lineno_pin
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# _wake_assert_err_note MSG — write MSG to the saved real-stderr fd, falling
|
|
||||||
# back to the current stderr if init was never called.
|
|
||||||
_wake_assert_err_note() {
|
|
||||||
if [ -n "${_wake_assert_err_fd:-}" ]; then
|
|
||||||
printf '%s\n' "$1" >&"$_wake_assert_err_fd" 2>/dev/null ||
|
|
||||||
printf '%s\n' "$1" >&2
|
|
||||||
else
|
|
||||||
printf '%s\n' "$1" >&2
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# _wake_assert_abort HELPER SITE RC ARGS... — refuse to answer, loudly.
|
|
||||||
# Writes the named reason to the saved real-stderr fd (falling back to the
|
|
||||||
# current stderr), signals the suite's main shell, and exits this context.
|
|
||||||
_wake_assert_abort() {
|
|
||||||
local _wa_helper="$1" _wa_where="$2" _wa_code="$3"
|
|
||||||
shift 3
|
|
||||||
_wake_assert_err_note "WAKE-ASSERT ABORT: ${_wa_helper} at ${_wa_where}: grep exit ${_wa_code} is an error, not a verdict (args: $*) — refusing to answer (#973)"
|
|
||||||
if [ -n "${BASHPID:-}" ] && [ "$BASHPID" != "$$" ]; then
|
|
||||||
kill -TERM "$$" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
exit 97
|
|
||||||
}
|
|
||||||
|
|
||||||
# _wake_assert_armed SITE — true iff the forced-error arm targets SITE; on a
|
|
||||||
# match it emits a positive confirmation FIRST, so "site did not abort" can
|
|
||||||
# never conflate SITE NOT CONVERTED with ARM NEVER REACHED IT: an armed run
|
|
||||||
# with no ARMED line means the arm matched nothing (typo/renumber/drift), and
|
|
||||||
# an ARMED line with no abort means the site's error path is broken. The two
|
|
||||||
# defects are separable on stderr alone.
|
|
||||||
_wake_assert_armed() {
|
|
||||||
[ "${WAKE_ASSERT_FORCE_GREP_ERROR_AT:-}" = "$1" ] || return 1
|
|
||||||
_wake_assert_err_note "WAKE-ASSERT ARMED: forcing real grep error at $1 (#973)"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# has_match GREP_ARGS... — three-valued grep verdict.
|
|
||||||
# Drop-in for verdict-bearing `grep` calls (flags, files, stdin all pass
|
|
||||||
# through; stdout is not captured, so extract-form call sites may use it
|
|
||||||
# inside a substitution). Returns 0 on match, 1 on no-match; any other grep
|
|
||||||
# exit aborts the suite via _wake_assert_abort.
|
|
||||||
has_match() {
|
|
||||||
local _wa_site="${BASH_SOURCE[1]##*/}:${BASH_LINENO[0]}" _wa_rc=0
|
|
||||||
if [ -n "${WAKE_ASSERT_LEDGER:-}" ]; then
|
|
||||||
printf 'has_match %s\n' "$_wa_site" >>"$WAKE_ASSERT_LEDGER"
|
|
||||||
fi
|
|
||||||
if _wake_assert_armed "$_wa_site"; then
|
|
||||||
command grep --wake-assert-forced-error -- /dev/null
|
|
||||||
_wa_rc=$?
|
|
||||||
else
|
|
||||||
command grep "$@"
|
|
||||||
_wa_rc=$?
|
|
||||||
fi
|
|
||||||
case "$_wa_rc" in
|
|
||||||
0) return 0 ;;
|
|
||||||
1) return 1 ;;
|
|
||||||
*) _wake_assert_abort has_match "$_wa_site" "$_wa_rc" "$@" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# count_lines GREP_ARGS... — `grep -c` with the same three-way discipline.
|
|
||||||
# Call sites drop their `-c` (the helper supplies it) and keep every other
|
|
||||||
# argument. Prints the count on rc 0 AND rc 1 (rc 1 is grep's "count is 0" —
|
|
||||||
# a valid measurement, not an error); any other exit aborts. The abort still
|
|
||||||
# kills the suite from inside a `$(...)` capture: the substitution subshell
|
|
||||||
# cannot exit the suite, but the signal to the main shell can — a count from
|
|
||||||
# a failed measurement is never printed.
|
|
||||||
count_lines() {
|
|
||||||
local _wa_site="${BASH_SOURCE[1]##*/}:${BASH_LINENO[0]}" _wa_rc=0 _wa_out=""
|
|
||||||
if [ -n "${WAKE_ASSERT_LEDGER:-}" ]; then
|
|
||||||
printf 'count_lines %s\n' "$_wa_site" >>"$WAKE_ASSERT_LEDGER"
|
|
||||||
fi
|
|
||||||
if _wake_assert_armed "$_wa_site"; then
|
|
||||||
_wa_out="$(command grep --wake-assert-forced-error -c -- /dev/null)"
|
|
||||||
_wa_rc=$?
|
|
||||||
else
|
|
||||||
_wa_out="$(command grep -c "$@")"
|
|
||||||
_wa_rc=$?
|
|
||||||
fi
|
|
||||||
case "$_wa_rc" in
|
|
||||||
0 | 1) printf '%s\n' "$_wa_out" ;;
|
|
||||||
*) _wake_assert_abort count_lines "$_wa_site" "$_wa_rc" "$@" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,16 +54,6 @@ Commands:
|
|||||||
Local-write only; a background sync
|
Local-write only; a background sync
|
||||||
ships it (never blocks on network).
|
ships it (never blocks on network).
|
||||||
--no-sync suppresses the background ship.
|
--no-sync suppresses the background ship.
|
||||||
--force-past-quarantine passes the #946
|
|
||||||
force flag through to store.sh consume:
|
|
||||||
the ONLY way to advance past a
|
|
||||||
QUARANTINED (dead-lettered, never
|
|
||||||
delivered) seq. The store's per-seq
|
|
||||||
step-over diagnostics are re-emitted on
|
|
||||||
stderr; no consumed-hash witness is
|
|
||||||
recorded for the quarantined entry.
|
|
||||||
Without the flag, a consume that would
|
|
||||||
cross a quarantined seq is REFUSED.
|
|
||||||
embed --upto N [--wake-id ID] [--agent A]
|
embed --upto N [--wake-id ID] [--agent A]
|
||||||
Print the copy-run ack line to EMBED in
|
Print the copy-run ack line to EMBED in
|
||||||
a digest (does not perform the ack).
|
a digest (does not perform the ack).
|
||||||
@@ -179,7 +169,7 @@ cmd_received() {
|
|||||||
|
|
||||||
cmd_consumed() {
|
cmd_consumed() {
|
||||||
_need_jq
|
_need_jq
|
||||||
local upto='' wake_id='' do_sync="1" force="0"
|
local upto='' wake_id='' do_sync="1"
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--upto)
|
--upto)
|
||||||
@@ -194,10 +184,6 @@ cmd_consumed() {
|
|||||||
do_sync="0"
|
do_sync="0"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--force-past-quarantine)
|
|
||||||
force="1"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
*)
|
*)
|
||||||
echo "ack.sh consumed: unknown option '$1'" >&2
|
echo "ack.sh consumed: unknown option '$1'" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -214,25 +200,11 @@ cmd_consumed() {
|
|||||||
# Advance consumed_seq via the store. The store enforces the CONTIGUOUS
|
# Advance consumed_seq via the store. The store enforces the CONTIGUOUS
|
||||||
# gapless-prefix rule and rejects a gap (cannot ack N while N-1 unconsumed).
|
# gapless-prefix rule and rejects a gap (cannot ack N while N-1 unconsumed).
|
||||||
# This is a LOCAL-WRITE cursor advance — no network.
|
# This is a LOCAL-WRITE cursor advance — no network.
|
||||||
local new_cursor store_args
|
local new_cursor
|
||||||
store_args=(consume --upto "$upto")
|
if ! new_cursor="$("$STORE_SH" consume --upto "$upto" 2>&1)"; then
|
||||||
if [ "$force" = "1" ]; then
|
|
||||||
store_args+=(--force-past-quarantine)
|
|
||||||
fi
|
|
||||||
if ! new_cursor="$("$STORE_SH" "${store_args[@]}" 2>&1)"; then
|
|
||||||
echo "ack.sh consumed: refused — $new_cursor" >&2
|
echo "ack.sh consumed: refused — $new_cursor" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "$force" = "1" ]; then
|
|
||||||
# #946: on the forced path the store's LOUD per-seq step-over diagnostics
|
|
||||||
# were captured together with the cursor line (2>&1 above). Re-emit them on
|
|
||||||
# OUR stderr — the loudness must survive the wrapper — and keep only the
|
|
||||||
# final line (the cursor) for the CONSUMED report below.
|
|
||||||
local cursor_line
|
|
||||||
cursor_line="$(printf '%s\n' "$new_cursor" | tail -n1)"
|
|
||||||
printf '%s\n' "$new_cursor" | sed '$d' | grep -v '^[[:space:]]*$' >&2 || true
|
|
||||||
new_cursor="$cursor_line"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Record the CONSUMED ack in the local ledger (still no network).
|
# Record the CONSUMED ack in the local ledger (still no network).
|
||||||
local record
|
local record
|
||||||
|
|||||||
@@ -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
|
||||||
awk -v key="$key" 'index($0, key "=") == 1 { sub(/^[^=]*=/, ""); gsub(/[[:space:]]/, ""); print; exit }' "$MANIFEST"
|
sed -n "s/^${key}=//p" "$MANIFEST" | head -n1 | tr -d '[:space:]'
|
||||||
}
|
}
|
||||||
|
|
||||||
# _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" ] && ! grep -Eq '^[0-9a-f]{7,64}$' <<<"$snap_sha"; then
|
if [ -n "$snap_sha" ] && ! printf '%s' "$snap_sha" | grep -Eq '^[0-9a-f]{7,64}$'; 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" ] && ! grep -Eq '^[0-9]{1,12}$' <<<"$snap_ts"; then
|
if [ -n "$snap_ts" ] && ! printf '%s' "$snap_ts" | grep -Eq '^[0-9]{1,12}$'; 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
|
||||||
@@ -414,22 +414,6 @@ cmd_poll_once() {
|
|||||||
_wake_clean_stale_tmp "$STATE_DIR"
|
_wake_clean_stale_tmp "$STATE_DIR"
|
||||||
mkdir -p "$DET_DIR"
|
mkdir -p "$DET_DIR"
|
||||||
|
|
||||||
local failed=0
|
|
||||||
|
|
||||||
# #958 preimage provenance: check the OPERATOR-SIDE preimage definition (the
|
|
||||||
# source adapter file, the watch-list, operator-declared extras) BEFORE
|
|
||||||
# observing any source. A changed preimage re-baselines EVERY source at once;
|
|
||||||
# running the check first means its first-class cause line is enqueued at a
|
|
||||||
# LOWER observed_seq than the N per-source deltas it explains, so the digest
|
|
||||||
# shows the cause, not just the flood. An infrastructure failure of the check
|
|
||||||
# is LOUD and marks this pass failed (G2a discipline — never read as "no
|
|
||||||
# change"), but source observation still proceeds: provenance must not be
|
|
||||||
# able to starve wake delivery.
|
|
||||||
if ! "$SCRIPT_DIR/preimage.sh" check --enqueue; then
|
|
||||||
echo "detector.sh: FAIL LOUD — preimage provenance check failed (see preimage.sh above); source observation continues but this pass exits non-zero." >&2
|
|
||||||
failed=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Iterate the DECLARED source-coverage inventory (§4/G3): only sources listed
|
# Iterate the DECLARED source-coverage inventory (§4/G3): only sources listed
|
||||||
# in watches[].sources[] are polled. An omitted source is not observed (and so
|
# in watches[].sources[] are polled. An omitted source is not observed (and so
|
||||||
# cannot make anything pass vacuously); a referenced-but-undefined source is a
|
# cannot make anything pass vacuously); a referenced-but-undefined source is a
|
||||||
@@ -443,7 +427,7 @@ cmd_poll_once() {
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local kind id def class
|
local failed=0 kind id def class
|
||||||
while IFS=$'\t' read -r kind id; do
|
while IFS=$'\t' read -r kind id; do
|
||||||
[ -n "$kind" ] || continue
|
[ -n "$kind" ] || continue
|
||||||
# Resolve the source definition from its top-level collection by id.
|
# Resolve the source definition from its top-level collection by id.
|
||||||
@@ -529,19 +513,7 @@ cmd_run() {
|
|||||||
echo "detector.sh: WARN — off-host liveness beacon emit failed (see beacon.sh); the off-host absence check remains the authoritative dead-man." >&2
|
echo "detector.sh: WARN — off-host liveness beacon emit failed (see beacon.sh); the off-host absence check remains the authoritative dead-man." >&2
|
||||||
fi
|
fi
|
||||||
[ "$once" -eq 1 ] && break
|
[ "$once" -eq 1 ] && break
|
||||||
# Close the detector lock fd in the sleep child; otherwise an orphaned sleep
|
sleep "$interval"
|
||||||
# keeps the single-instance flock (fd 9, taken at exec 9> above) alive after
|
|
||||||
# the detector parent dies. The lock is non-blocking (`flock -n`, above), so
|
|
||||||
# for as long as that sleep survives a replacement instance is REFUSED and
|
|
||||||
# exits rather than queueing. This particular hold is BOUNDED by one poll
|
|
||||||
# interval (WAKE_DETECTOR_INTERVAL, default 30s): when the orphaned sleep
|
|
||||||
# exits its copy of fd 9 closes, ending this bounded sleep-child hold. It
|
|
||||||
# does NOT follow that the next start succeeds — other inheritors of fd 9
|
|
||||||
# (the M1 adapter, M2 sink grandchildren) are outside this patch's scope and
|
|
||||||
# can keep holding the flock. The cost this removes is a restart window in
|
|
||||||
# which every supervisor retry fails on the sleep child's account.
|
|
||||||
# `9>&-` closes ONLY the child's copy — the parent's lock is unaffected.
|
|
||||||
sleep "$interval" 9>&-
|
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,14 +112,6 @@ Exit codes:
|
|||||||
diagnostic (#924/G2a) — it never wedges the whole render either.
|
diagnostic (#924/G2a) — it never wedges the whole render either.
|
||||||
Reconciler enumerations (locators.reconciled==true) render ORIENTATION-tier,
|
Reconciler enumerations (locators.reconciled==true) render ORIENTATION-tier,
|
||||||
gate-exempt.
|
gate-exempt.
|
||||||
#946: quarantined entries are DISCLOSED in a QUARANTINED section (by
|
|
||||||
seq/class only — content stays excluded) and the embedded ack copy-run
|
|
||||||
line is CLAMPED below the lowest quarantined seq (the digest never
|
|
||||||
instructs the consumer to record a delivery that never happened; the
|
|
||||||
clamp is announced as an ACK CLAMPED note). A store-mode render also
|
|
||||||
REPLACES the store's quarantined.set (store.sh quarantine-sync) so the
|
|
||||||
consume path enforces the same clamp; --from-file/--stdin renders never
|
|
||||||
touch the set.
|
|
||||||
2 usage error.
|
2 usage error.
|
||||||
3 jq is required but missing.
|
3 jq is required but missing.
|
||||||
|
|
||||||
@@ -488,19 +480,6 @@ _dlq_alarm_offhost() {
|
|||||||
# A dead-letter write failure is itself alarmed (both legs) but never aborts the
|
# A dead-letter write failure is itself alarmed (both legs) but never aborts the
|
||||||
# drain — the good entries MUST still deliver (availability is the whole point of
|
# drain — the good entries MUST still deliver (availability is the whole point of
|
||||||
# #920).
|
# #920).
|
||||||
#
|
|
||||||
# RETENTION IS LOAD-BEARING (#953) — read BEFORE adding rotation/pruning/caps:
|
|
||||||
# this ledger is append-only history AND the SOLE evidence base for
|
|
||||||
# store.sh quarantine-audit's conviction predicate (#946): a false
|
|
||||||
# consumed-hashes witness row is provable ONLY while its matching entry
|
|
||||||
# survives HERE. Any rotation, pruning, or size cap — however locally correct
|
|
||||||
# — silently converts provable rows into unprovable ones, and the audit's
|
|
||||||
# clean sweep reads IDENTICALLY before and after the evidence disappears (no
|
|
||||||
# signal at either end; the audit never guesses, by design). If retention
|
|
||||||
# limits ever become genuinely necessary, they MUST ship with (a) a loud
|
|
||||||
# signal at prune time naming what evidence is being given up, and (b) the
|
|
||||||
# audit's residual-class reporting updated IN THE SAME CHANGE. Until then:
|
|
||||||
# nothing prunes this file, and that is a recorded decision, not an omission.
|
|
||||||
_quarantine_entry() {
|
_quarantine_entry() {
|
||||||
local line="$1" seq="$2" dlq="$STATE_DIR/dead-letter.jsonl"
|
local line="$1" seq="$2" dlq="$STATE_DIR/dead-letter.jsonl"
|
||||||
if [ ! -f "$dlq" ] || ! grep -qxF "$line" "$dlq" 2>/dev/null; then
|
if [ ! -f "$dlq" ] || ! grep -qxF "$line" "$dlq" 2>/dev/null; then
|
||||||
@@ -565,7 +544,7 @@ cmd_render() {
|
|||||||
# #920 head-of-line-blocking defect that exit-4'd the entire cumulative-state
|
# #920 head-of-line-blocking defect that exit-4'd the entire cumulative-state
|
||||||
# drain). Reconciler enumerations (locators.reconciled==true) are ORIENTATION-
|
# drain). Reconciler enumerations (locators.reconciled==true) are ORIENTATION-
|
||||||
# tier and gate-exempt, so they pass straight through to the deliverable set.
|
# tier and gate-exempt, so they pass straight through to the deliverable set.
|
||||||
local line loc seq pending_ok='' quarantined=0 q_seqs='' q_disclose=''
|
local line loc seq pending_ok='' quarantined=0
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[ -n "$line" ] || continue
|
[ -n "$line" ] || continue
|
||||||
printf '%s' "$line" | jq -e . >/dev/null 2>&1 || continue
|
printf '%s' "$line" | jq -e . >/dev/null 2>&1 || continue
|
||||||
@@ -575,17 +554,6 @@ cmd_render() {
|
|||||||
seq="$(jq -r '.observed_seq // "?"' <<<"$line")"
|
seq="$(jq -r '.observed_seq // "?"' <<<"$line")"
|
||||||
_quarantine_entry "$line" "$seq"
|
_quarantine_entry "$line" "$seq"
|
||||||
quarantined=$((quarantined + 1))
|
quarantined=$((quarantined + 1))
|
||||||
# #946: collect the quarantined identity for DISCLOSURE + the ack
|
|
||||||
# CLAMP. Disclosure is by durable identity (observed_seq) + class ONLY:
|
|
||||||
# this entry failed the locator gate, so its content is exactly what
|
|
||||||
# this digest refuses to re-inject (the exclusion property Q1/Q4
|
|
||||||
# assert) — the consumer re-verifies via the dead-letter ledger, never
|
|
||||||
# via this line.
|
|
||||||
case "$seq" in
|
|
||||||
'' | *[!0-9]*) : ;; # an unnumbered entry cannot clamp the numeric cursor
|
|
||||||
*) q_seqs="$q_seqs$seq"$'\n' ;;
|
|
||||||
esac
|
|
||||||
q_disclose="$q_disclose * seq $seq [$(_scrub_inline "$(jq -r '.class // "actionable"' <<<"$line")")] HELD — dead-lettered (no §2.1 hard locator); content withheld, NOT delivered."$'\n'
|
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -595,33 +563,6 @@ cmd_render() {
|
|||||||
pending="$(printf '%s' "$pending_ok" | grep -v '^[[:space:]]*$' || true)"
|
pending="$(printf '%s' "$pending_ok" | grep -v '^[[:space:]]*$' || true)"
|
||||||
depth="$(printf '%s\n' "$pending" | grep -c . || true)"
|
depth="$(printf '%s\n' "$pending" | grep -c . || true)"
|
||||||
|
|
||||||
# --- #946: quarantine truth-sync + ack clamp ------------------------------
|
|
||||||
# (1) SYNC: an AUTHORITATIVE full-set render (src=store) REPLACES the store's
|
|
||||||
# quarantined.set with THIS render's quarantined seqs (possibly none — an
|
|
||||||
# empty replace IS the #944 recovery: once the gate is fixed and everything
|
|
||||||
# renders, the stale set clears and the store-side clamp self-heals). A
|
|
||||||
# foreign-data render (--from-file/--stdin) must NEVER rewrite lane truth.
|
|
||||||
if [ "$src" = "store" ]; then
|
|
||||||
if ! printf '%s' "$q_seqs" | "$STORE_SH" quarantine-sync; then
|
|
||||||
echo "digest.sh: WARN (#946) — store.sh quarantine-sync FAILED; the store-side consume clamp may be stale for this lane (the clamped ack line rendered below is still correct)." >&2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
# (2) CLAMP: the embedded ack may advance AT MOST to just below the LOWEST
|
|
||||||
# quarantined seq — consume requires a contiguous prefix, so one held seq
|
|
||||||
# caps everything above it. With nothing quarantined this is the observed
|
|
||||||
# cursor unchanged. Render-local on purpose: it protects the copy-run line in
|
|
||||||
# EVERY mode, including hermetic --from-file renders.
|
|
||||||
local ack_upto="$observed" min_q='' qs q_list=''
|
|
||||||
while IFS= read -r qs; do
|
|
||||||
[ -n "$qs" ] || continue
|
|
||||||
if [ -z "$min_q" ] || [ "$qs" -lt "$min_q" ]; then min_q="$qs"; fi
|
|
||||||
done <<<"$q_seqs"
|
|
||||||
if [ -n "$min_q" ] && [ "$((min_q - 1))" -lt "$ack_upto" ]; then
|
|
||||||
ack_upto=$((min_q - 1))
|
|
||||||
fi
|
|
||||||
[ "$ack_upto" -ge 0 ] || ack_upto=0
|
|
||||||
q_list="$(printf '%s' "$q_seqs" | tr '\n' ' ' | sed -e 's/[[:space:]]*$//')"
|
|
||||||
|
|
||||||
# --- render (all validated) ----------------------------------------------
|
# --- render (all validated) ----------------------------------------------
|
||||||
local n_actionable=0
|
local n_actionable=0
|
||||||
{
|
{
|
||||||
@@ -644,8 +585,7 @@ 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")"
|
olabel="$(_locator_line "$oloc" | head -n1)"
|
||||||
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
|
||||||
@@ -716,16 +656,6 @@ cmd_render() {
|
|||||||
printf '%s\n' "$hbody"
|
printf '%s\n' "$hbody"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# #946: QUARANTINED disclosure — a held entry must be VISIBLE in the digest
|
|
||||||
# it was held from (five successive live digests each silently stepped the
|
|
||||||
# consumer past buried seq 68). Disclosure is by seq/class ONLY; the
|
|
||||||
# entry's content already failed the locator gate and stays EXCLUDED.
|
|
||||||
if [ -n "$q_disclose" ]; then
|
|
||||||
printf '\n-- QUARANTINED (dead-lettered; HELD — NOT delivered; the ack below does NOT cover these) --\n'
|
|
||||||
printf '%s' "$q_disclose"
|
|
||||||
printf ' disposition: see %s/dead-letter.jsonl — fix the source locator (re-delivery is automatic once the entry passes the gate), or step past EXPLICITLY with ack.sh consumed --force-past-quarantine.\n' "$STATE_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Embedded ack copy-run line (W2). CONSUMED is a consumer act; this is the
|
# Embedded ack copy-run line (W2). CONSUMED is a consumer act; this is the
|
||||||
# exact local-write line the consumer runs after durable capture.
|
# exact local-write line the consumer runs after durable capture.
|
||||||
#
|
#
|
||||||
@@ -740,19 +670,12 @@ cmd_render() {
|
|||||||
# itself was env-less (agent=="default"), baking "default" is no worse than
|
# itself was env-less (agent=="default"), baking "default" is no worse than
|
||||||
# today — the fix wins the common case where WAKE_AGENT was set at render.
|
# today — the fix wins the common case where WAKE_AGENT was set at render.
|
||||||
printf '\n-- ACK (copy-run; local-write only, never blocks on network) --\n'
|
printf '\n-- ACK (copy-run; local-write only, never blocks on network) --\n'
|
||||||
# #946: the embedded --upto is the CLAMPED cursor (ack_upto), never the raw
|
|
||||||
# observed cursor while a quarantined seq sits inside (consumed, observed] —
|
|
||||||
# the copy-run line itself must not instruct the consumer to record
|
|
||||||
# deliveries that never happened. The clamp is disclosed loudly.
|
|
||||||
if [ "$ack_upto" -ne "$observed" ]; then
|
|
||||||
printf '# ACK CLAMPED (#946): embedding --upto %s, not observed_seq %s — quarantined seq(s) %s were dead-lettered and NEVER delivered; an ordinary ack cannot step past them. Only ack.sh consumed ... --force-past-quarantine (loud) can.\n' "$ack_upto" "$observed" "$q_list"
|
|
||||||
fi
|
|
||||||
local ack_line agent_scrubbed
|
local ack_line agent_scrubbed
|
||||||
agent_scrubbed="$(_scrub_inline "$agent")"
|
agent_scrubbed="$(_scrub_inline "$agent")"
|
||||||
if [ -n "$wake_id" ]; then
|
if [ -n "$wake_id" ]; then
|
||||||
ack_line="$("$ACK_SH" embed --upto "$ack_upto" --agent "$agent_scrubbed" --wake-id "$wake_id" 2>/dev/null || true)"
|
ack_line="$("$ACK_SH" embed --upto "$observed" --agent "$agent_scrubbed" --wake-id "$wake_id" 2>/dev/null || true)"
|
||||||
else
|
else
|
||||||
ack_line="$("$ACK_SH" embed --upto "$ack_upto" --agent "$agent_scrubbed" 2>/dev/null || true)"
|
ack_line="$("$ACK_SH" embed --upto "$observed" --agent "$agent_scrubbed" 2>/dev/null || true)"
|
||||||
fi
|
fi
|
||||||
printf '%s\n' "${ack_line:-# ack unavailable}"
|
printf '%s\n' "${ack_line:-# ack unavailable}"
|
||||||
} | _redact_secrets
|
} | _redact_secrets
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user