Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e91c8c06a5 | ||
|
|
a50b5a6b4d | ||
|
|
22cedbb506 | ||
|
|
bad53564ed | ||
|
|
518c4185ee | ||
|
|
877473c244 | ||
|
|
f0555016f4 | ||
|
|
fb7087b3eb | ||
|
|
836aab1ab5 | ||
|
|
656fa9ceb7 | ||
|
|
6afb3cb5b3 | ||
|
|
351cb67cec | ||
|
|
fcfc1b08f9 | ||
|
|
2451c2f21a | ||
|
|
c6329ec91e | ||
|
|
01694f3f98 |
@@ -46,10 +46,6 @@ steps:
|
|||||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||||
# with everything it guards; this direct line keeps one instrument running.
|
# with everything it guards; this direct line keeps one instrument running.
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||||
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
|
|
||||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
|
||||||
# joins CI directly rather than the exclusions file.
|
|
||||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
|
||||||
|
|
||||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||||
|
|||||||
@@ -245,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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+43
-62
@@ -79,6 +79,49 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Per-estate durable agent working memory (#1051)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
Agent and lane continuity currently accumulates as plain local files with no repository backing. The installer must make a private, per-estate `mosaic-brain` clone at `~/.mosaic` reproducible without authorizing cross-estate access or introducing an independent credential path.
|
||||||
|
|
||||||
|
### Normative requirements
|
||||||
|
|
||||||
|
1. `MB-REQ-01` (R1): Ensure the target estate's existing `mosaic-brain` can be cloned to `~/.mosaic`; repository creation and live access granting remain broker-mediated.
|
||||||
|
2. `MB-REQ-02` (R2/Q1): Derive estate and brain target from the configured target git host through the credential broker's estate registry. A second `brain_repo` authority and host-machine inference are forbidden; an unknown host fails closed with a named diagnosis.
|
||||||
|
3. `MB-REQ-03` (R3): Seat access is granted only through `mosaic cred`; callers must never resolve or read a token independently. Live grant verification is gated on MC-CRED-01 implementation.
|
||||||
|
4. `MB-REQ-04` (R4): The eventual live postcondition requires `~/.mosaic` to be a `main`-branch git repo with the expected remote and a seat-owned read/write round-trip. This live validation is gated on MC-CRED-01 implementation and cannot be replaced by a clone exit code.
|
||||||
|
5. `MB-REQ-05` (R5): The out-of-estate refusal control covers both Git and API resolver axes. Axis disagreement is `indeterminate` failure, never permission; contract tests bind to the broker's four terminal classes and stable reason codes.
|
||||||
|
6. `MB-REQ-06` (R6): The brain skeleton excludes `*.token`, `*.key`, `*.pem`, `.env`, and `credentials.json`; credentials remain broker-owned and no error path may print secret material. Arbitrary legacy content is never auto-published from a heuristic denylist: an approved content scanner must bind approval to the exact source snapshot, otherwise the item is retained and reported.
|
||||||
|
7. `MB-REQ-07` (R7): Detect existing local lane directories and seat state files, publish approved snapshots into the durable layout without overwrite or deletion, and explicitly report every detected item that cannot be migrated. Automatic source deletion is parked until command-scoped identity propagation and the required clean audit; retained sources are always reported. Lane findings are append-only; `board/` has a named single writer; writes push immediately rather than on a timer.
|
||||||
|
8. `MB-REQ-08` (R8): `mosaic doctor` reports missing clone, wrong remote, incomplete write-access evidence, and uncommitted local state. `--fix` repairs the first three only through the approved installer/broker path; it never hand-rolls credential resolution.
|
||||||
|
9. `MB-REQ-09`: Retention is ownership-first and archive-only. Every retained artifact requires a named durable owner; absent or non-durable ownership leaves the gate open and blocking. Age and size never authorize deletion.
|
||||||
|
10. `MB-REQ-10`: Brain provisioning occupies canonical installer P7 only after the applicable P5 credential postcondition commits; canonical phase numbers are unchanged.
|
||||||
|
|
||||||
|
### Current delivery slice
|
||||||
|
|
||||||
|
In scope now: estate derivation, secret exclusion, non-destructive migration, doctor reporting/repair orchestration, and red-first tests over all four credential-contract terminal classes. Live grant and live read/write round-trip evidence remain explicitly gated on the working MC-CRED-01 broker and must not be mocked or replaced by independent token lookup.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. `AC-MB-01`: Contract tests observe RED before implementation and then distinguish `ok/0`, `refused/10`, `error/20`, and `indeterminate/30`, preserving v1.5 diagnoses including refused `provider-identity-mismatch`/`credential-rejected` and indeterminate `identity-not-measured`/`provider-unavailable`. A scope-forbidden `/user` result with confirmed in-scope repository capability is never represented as a dead credential. `identity-not-found` remains reserved for a future visibility-authorized inventory operation and is not an expected `validate` result.
|
||||||
|
2. `AC-MB-02`: Estate resolution uses the configured target git host and one registry; unknown, mismatched, and host-machine-derived inputs fail closed.
|
||||||
|
3. `AC-MB-03`: A clean fixture contains the required layout and exact secret exclusions; filename-, content-, binary-, and size-based secret controls remain outside Git without their values appearing in output. Without an approved scanner, even benign legacy content is retained and reported rather than auto-published.
|
||||||
|
4. `AC-MB-04`: Migration publishes approved lane-durable and seat-state snapshots into collision-safe archive/ledger paths, retains and reports every source, never overwrites an existing finding, and never deletes by age/size.
|
||||||
|
5. `AC-MB-05`: Doctor detects all four R8 defect classes; `--fix` repairs eligible classes through the approved P7/broker seam and leaves unresolved credential-dependent states visible.
|
||||||
|
6. `AC-MB-06`: Git-axis and API-axis refusal must both be authoritative `refused` outcomes with matching stable reason codes; any disagreement yields `indeterminate`.
|
||||||
|
7. `AC-MB-07`: Independent code review and security review pass at the exact head, and HOMELAB Woodpecker instance `mosaic` is terminal green before integration.
|
||||||
|
8. `AC-MB-08`: After reviewed merge to `main`, report only **believed-fixed, pending jarvis validation**; issue #1051 remains open until W-jarvis validates the installed result.
|
||||||
|
|
||||||
|
### Constraints and risks
|
||||||
|
|
||||||
|
- MC-CRED-01 contract v1.5 is the caller boundary; no independent credential/token lookup is permitted. Identity is established from governed mint-time binding and provider evidence when measurable, never a credential filename. Runtime validation does not widen a least-privilege token merely to make `/user` observable.
|
||||||
|
- C1 owns installer phase sequencing. This slice consumes P5/P7 ordering without renumbering or duplicating the phase machine.
|
||||||
|
- Lane content is findings, so last-writer-wins is data loss. Append-only names and explicit collision handling are mandatory.
|
||||||
|
- A created-but-empty brain beside unbacked local doctrine fails the objective; migration is a primary acceptance gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Compaction Refresh Trust Lifecycle (M1, #827–#830)
|
## Compaction Refresh Trust Lifecycle (M1, #827–#830)
|
||||||
|
|
||||||
### Problem and objective
|
### Problem and objective
|
||||||
@@ -146,68 +189,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)
|
||||||
|
|||||||
+10
-13
@@ -5,14 +5,14 @@ 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. |
|
||||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||||
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||||
|
|
||||||
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
||||||
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
||||||
@@ -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,83 +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 |
|
|
||||||
|
|
||||||
## Tranche 3 — wake validation harnesses
|
|
||||||
|
|
||||||
All 26 baseline occurrences (25 normalized identities; one preimage selector occurs twice) are `FIXED` and mechanically bound through the wake fixture and shared scanner.
|
|
||||||
|
|
||||||
| Baseline site(s) | Verdict | Construction |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `wake/test-wake-digest-quarantine.sh:567` | FIXED | complete match populations are captured, then first line selected by parameter expansion |
|
|
||||||
| `wake/test-wake-preimage.sh:182-183,346-347` | FIXED | jq `first(...)` reads each JSONL file directly |
|
|
||||||
| `wake/validate-973/microtest-wake-assert.sh:153,170-171,176,204-209,233-234,251-252,286-287` | FIXED | scalar assertions use here-strings; diagnostics use non-early sed ranges; source line captured before matching |
|
|
||||||
| `wake/validate-973/validate-973.sh:110,119,180,182,187` | FIXED | scalar assertions use here-strings; diagnostic truncation uses consuming sed ranges |
|
|
||||||
|
|
||||||
The scoped inventory is complete: 26 runtime/general + 22 non-wake tests + 26 wake tests fixed; 11 explicitly withdrawn or non-load-bearing sites retain their documented verdicts.
|
|
||||||
@@ -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.
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# #1051 — per-estate mosaic-brain installer
|
||||||
|
|
||||||
|
Last updated: 2026-08-05
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusions, non-destructive migration, doctor diagnostics/fixes, and credential-contract terminal-class handling. Live broker grants and live read/write round-trips remain gated on MC-CRED-01.
|
||||||
|
|
||||||
|
## Sources and bindings
|
||||||
|
|
||||||
|
- Provider issue: HOMELAB `git.mosaicstack.dev`, `GET /api/v1/repos/mosaicstack/stack/issues/1051`, `application/json;charset=utf-8`.
|
||||||
|
- Issue requirements: R1–R8 read directly on 2026-08-05.
|
||||||
|
- MC-CRED caller contract: v1.5, SHA-256 `4cecba3386b37431d4a075205c6dfe43555c7673922fed61b84f43cac1a6ae92` at the 2026-08-05 re-derivation. Earlier moving bindings were v1.5 `710d22d61a93a4b9c70fc55506a023a675a110417fa7a6e72dc051c0d9fe8237`, v1.4 `27f20158561ae8292f3bfc926b5e97f398de93db6a1cf65fcc215d08811d39af`/`d12ad4595b7aef078e392988a07ab5cb00244440775c9c733dc825746d7ac67b`, and v1.3 `8cfa4853d2b0b0e8cc9e792fa8411310e16d7704c06e0af9d9a57155131d8086`.
|
||||||
|
- Fleet doctrine: SHA-256 `026b43322e0551ef15b646a9f30d3a6aef58c662a810b732be2a03b1ecf7d36e` at intake.
|
||||||
|
- Intake base was HOMELAB provider `next` = `4df478cdd150fdf8d52ea109f02ade5d85017acd`; `main` = `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`. On 2026-08-05 `mos-claude` ruled that L0 trunk-based gate 15 requires all three lanes to retarget to `main`; `next` remains a non-merging integration branch. Never weaken or patch `pr-merge.sh`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In now
|
||||||
|
|
||||||
|
- R2/Q1 target-host estate derivation using one registry.
|
||||||
|
- R5 both-axis refusal parity and disagreement failure.
|
||||||
|
- R6 exact secret exclusions and no secret-bearing diagnostics.
|
||||||
|
- R7 detection plus non-destructive, collision-safe migration/reporting.
|
||||||
|
- R8 doctor checks and approved-seam fix orchestration.
|
||||||
|
- Red-first tests over all four contract terminal classes and stable reason codes.
|
||||||
|
|
||||||
|
### Gated / excluded
|
||||||
|
|
||||||
|
- R3 live grant: waits for working MC-CRED-01.
|
||||||
|
- R4 live seat-owned read/write round-trip: waits for working MC-CRED-01.
|
||||||
|
- No independent token lookup, grant helper, or shared-credential fallback.
|
||||||
|
- No phase renumbering; C1 owns the phase machine and provides the P5→P7 seam.
|
||||||
|
- No age/size reaping or deletion.
|
||||||
|
|
||||||
|
## Owner authority ruling and resolver seam
|
||||||
|
|
||||||
|
- Binding addendum: `/home/hermes/agent-work/tl-mosaic/CHARTER-MB-BRAIN-01-ADDENDUM.md`; re-read after compaction.
|
||||||
|
- HOMELAB durable lane-archive owner and user-namespace brain owner are the human provider account selected by local estate policy (operator ruling: `jason.woltje`) with a required GLPI queue as the standing remediation process. The brain target is therefore `<policy-owner>/mosaic-brain` on the estate host, not `<installer-source-org>/mosaic-brain`. Framework source remains operator-agnostic: the actual login and queue are local policy, not hardcoded open-source context.
|
||||||
|
- Provider lookup is anonymous because the ruled owner is public. It requires exact allowlisted login plus a same-invocation public known-good control, private 404 control, and generated absent 404 control. It sends no Authorization header and never widens token scope.
|
||||||
|
- Provider `active` is deliberately ignored: non-admin reads return false for demonstrably active accounts. Resolvability + exact login + public visibility are the gate.
|
||||||
|
- Private and absent principals both return anonymous 404. The fail-closed reason is `owner-not-resolvable`, never owner-not-found.
|
||||||
|
- Caller `owner` strings and `validated=true` are ignored. Migration consumes only an injected source-of-truth resolver result. Owner grammar is NFKC-stable, ASCII allowlisted, exact-policy matched, and mission-seat class is excluded.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Pre-register acceptance tests and observe each requirement RED for its own missing behavior.
|
||||||
|
2. Commit the red tests before implementation.
|
||||||
|
3. Implement a narrow brain provisioning/doctor helper that consumes broker JSON outcomes and the shared estate registry without credential resolution.
|
||||||
|
4. Implement safe migration and exact brain skeleton/ignore policy.
|
||||||
|
5. Integrate the helper into C1's P7 seam and `mosaic doctor` after C1 lands/rebase.
|
||||||
|
6. Run focused, package, installer, lint, typecheck, format, and situational security tests.
|
||||||
|
7. Run independent code and security reviews in parallel; remediate and re-review.
|
||||||
|
8. Push after HOMELAB queue guard, open the reviewed PR to `main`, and preserve merge order C1 → MC-CRED → MB-BRAIN. Do not modify the merge guard; `next` is non-merging integration only.
|
||||||
|
9. Re-take CI measurement at the rebased exact head; do not rework code solely because base evidence moved.
|
||||||
|
|
||||||
|
## Acceptance interpretation registered before results
|
||||||
|
|
||||||
|
- `ok/0`: complete authoritative evidence only.
|
||||||
|
- `refused/10`: complete authoritative denial only.
|
||||||
|
- `error/20`: local contract/control failure; never reinterpret as denial.
|
||||||
|
- `indeterminate/30`: incomplete/disagreeing evidence; fail closed, never resolve permissively.
|
||||||
|
- Both Git and API axes must return authoritative `refused` with the same stable reason code for R5. Any axis disagreement is `indeterminate`. A provider `/user` login mismatch is first-class `provider-identity-mismatch`; credential filenames never establish principal identity.
|
||||||
|
- Migration publication requires the durable object to contain the approved snapshot and no overwrite. Automatic path-based source deletion is parked; every source is retained and reported.
|
||||||
|
- Secret exclusion is tested through exact ignore rules, nested secret-shaped paths, bounded UTF-8 content controls, and an approved-scanner gate bound to the exact source snapshot. Without an approved scanner, even benign content is retained and reported rather than committed.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
No explicit token ceiling was supplied. Working cap: 55K tokens for implementation/review and 3 focused remediation attempts per failure class. Reduce optional refactoring and documentation breadth before touching required acceptance scope.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- C1 and MC-CRED branches have not merged into `main`; integration edits must wait for their exact interfaces or be confined to stable contract seams.
|
||||||
|
- A broker runtime test before MC-CRED lands would either fail for an irrelevant reason or pressure a hand-rolled workaround; contract fixtures are allowed, live capability claims are not.
|
||||||
|
- Migration can lose data through overwrite, cross-device move failure, or partial copy. Implementation must stage, verify resulting bytes, and retain/report source on incomplete transfer.
|
||||||
|
- `~/.mosaic` is a git repo, while current working state may live under multiple local roots; detection must be explicit and cannot treat age/size as ownership.
|
||||||
|
|
||||||
|
## Progress / evidence
|
||||||
|
|
||||||
|
- [x] Charter receipt accepted by `tl-mosaic`.
|
||||||
|
- [x] Issue #1051 R1–R8 read directly from provider.
|
||||||
|
- [x] Contract re-derived through v1.3, moving v1.4, and v1.5 before R5 integration. v1.5 separates in-scope repository capability from `/user` identity measurement: 401 is `credential-rejected`/refused, 403/404 may become `identity-not-measured` only after in-scope capability succeeds, and 200 login mismatch is refused. `identity-not-found` is not reachable from `validate`.
|
||||||
|
- [x] C1 P5→P7 seam receipt read; no brain implementation is in C1.
|
||||||
|
- [x] RED acceptance set committed at `cf11c6c86abae073d8b02b4014cd5447ba67f12a`; author and committer read back as `be-coder-07` and branch reachability was independently verified by `tl-mosaic`.
|
||||||
|
- [x] Moving-contract REDs observed independently for v1.4 mismatch, R8 prerequisite ordering, owner resolver seam/allowlist, tracked skeleton/no-follow behavior, runtime observation/publication, and provider owner resolution.
|
||||||
|
- [x] Focused implementation includes secure migration, v1.5 write-differential/subject binding, production Git+API refusal parity, provider-backed durable owner resolution that ignores non-admin `active`, required GLPI standing-process policy, P7 provision orchestration, an internal installer command, and installed `mosaic doctor` wiring. Latest focused result: 97/97 (secure config 4, store 45, runtime 19, owner resolver 16, provision 5, provision command 3, installed doctor 5).
|
||||||
|
- [x] MC-CRED added the required canonical reverse registry seam `ParsedCredentialEstateRegistry.resolveByHost()`; the 32-line permissive shim was removed. After exact-head CI proved the cross-PR source dependency was absent, the provider-fetched canonical registry implementation, DTO dependencies, and registry tests were tracked byte-for-byte on this branch so a fresh checkout validates the real seam rather than a stub. A later rebase onto merged MC-CRED should recognize those identical files as upstream.
|
||||||
|
- [x] Identity gotcha measured: inline `MOSAIC_GIT_IDENTITY=be-coder-07` controls credential resolution but does not override `user.name`/`user.email` inherited from the linked worktree common-dir config (`coder-mos1`). The first local P7 RED commit was immediately amended before push with command-scoped `GIT_AUTHOR_*` + `GIT_COMMITTER_*`; resulting author and committer both read back as `be-coder-07`. Every subsequent authoring command must carry both identity sets and be verified.
|
||||||
|
- [x] R6 migration reports filename- or content-secret-shaped files without copying them; arbitrary legacy content requires an approved scanner bound to the exact source snapshot, and production currently retains/reports when no approved scanner is configured. `.gitignore` is canonical allowlisted content only: an existing noncanonical regular file fails closed and is never merged into publication. Symlinked `.gitignore`, layout directories, and nested migration destinations fail closed; a dirty checkout blocks provisioning before skeleton publication. The brain root is principal-owned mode `0700` before clone and after clone, all memory-bearing layout directories are mode `0700` even under umask `0022`, and doctor reports owner-accessible roots as hard unsafe findings.
|
||||||
|
- [x] Provider owner lookup uses manual redirect handling, a five-second abort signal, strict JSON content type/shape, and an incrementally enforced 256 KiB response ceiling.
|
||||||
|
- [x] Security-critical owner policy/registry reads have direct controls for principal UID ownership, file/ancestor permissions, and descriptor-safe regular-file reads.
|
||||||
|
- [x] Automatic source deletion is parked per the shared-Git-identity governance ruling; remotely reachable snapshots still leave and report every source.
|
||||||
|
- [x] Multi-host push-on-write uses an isolated temporary Git index populated from approved in-memory blobs rather than pathname re-reads, verifies each committed blob ID, the exact changed-path allowlist, and both author/committer trailers before push, then reconciles only approved paths into the real checkout index. Real-repository controls prove a clean checkout remains clean, a concurrent non-fast-forward fetch/rebase/push remains clean and preserves both findings, destination-path substitution cannot change committed bytes, and unrelated pre-staged secret-shaped content remains staged but never enters the published commit.
|
||||||
|
- [x] Doctor Git observations preserve three states: `clean`, `dirty`, and `unmeasurable`; failed remote, branch, or status measurements emit hard `brain-git-state-indeterminate` findings rather than mismatch or ready. The boolean-literal guard sweep covered all MB-BRAIN production files in the 20-file PR population: its only remaining `=== false` guard is the non-nullable `isAbsolute()` predicate; no nullable boolean measurement guards remain.
|
||||||
|
- [x] Author-run Review 10 and focused 88/88 evidence were declared void when blocker fixes changed the head; neither is an independent gate pass.
|
||||||
|
- [ ] Installer shell P7 invocation after C1 + MC-CRED integration; production command is registered but the C1 shell has not yet called it.
|
||||||
|
- [ ] Implementation green on merged dependency base.
|
||||||
|
- [ ] Independent code review.
|
||||||
|
- [ ] Independent security review.
|
||||||
|
- [ ] HOMELAB CI terminal green at exact head.
|
||||||
|
- [ ] Reviewed PR retargeted to `main` after C1 and MC-CRED; `next` remains non-merging integration only.
|
||||||
|
|
||||||
|
## Exact-head CI dependency remediation
|
||||||
|
|
||||||
|
- Pipeline `#2222` at `a50b5a6b` ran the sole pull-request-eligible workflow (`ci`, 1/3 defined workflows) and failed `typecheck` with two `TS2307` errors before lint, format, or tests could execute.
|
||||||
|
- History establishes that the imports are intentional: commit `2451c2f` introduced both consumers, while the contemporaneous registry-seam report explicitly called the local 32-line implementation a disposable scaffold and required MC-CRED's canonical parser. This was a deliberate cross-PR dependency, not a wrong import or forgotten shim add.
|
||||||
|
- RED-first root typecheck reproduced the two missing-module errors. The fix tracks the provider-fetched MC-CRED registry, its two DTO dependencies, its unit test, and the result DTO dependency. Three DTO files remain byte-identical to `fbff4ffa`; author review found that the canonical parser accepted a trailing-slash origin which MB-BRAIN consumers concatenate into double-slash URLs, so the parser and test are intentionally hardened here pending propagation to MC-CRED.
|
||||||
|
- R7 removed the tracked registry module and root typecheck returned RED with three missing-module errors (the two production consumers plus the registry unit test); restoring the same SHA-256 returned typecheck to 45/45 tasks.
|
||||||
|
- With the suppressing typecheck failure removed, lint ran 25/25 tasks and formatting passed. Full tests actually ran: 1,607/1,614 passed; the seven failures are the four pre-registered P7 integration tests intentionally held for C1/MC-CRED integration plus the three previously disclosed ambient update-banner CLI smoke failures. No test was weakened. Build ran 25/25 tasks.
|
||||||
|
- Author review's trailing-slash finding was reproduced RED (`https://git.example.invalid/` accepted), then fixed by requiring the configured source to equal `URL.origin`; the control reran GREEN. Security review reported risk `none` with zero findings; final code re-review remains required after remediation.
|
||||||
|
- This is a second instance of the `#1068` suppression class: an early integrity failure prevented every downstream stage carrying behavioral evidence from running while the workflow's aggregate failure looked like a completed check. Workflow reordering remains `#1068` scope and is not changed here.
|
||||||
|
|
||||||
|
## Completion language
|
||||||
|
|
||||||
|
After reviewed merge to `main`, only: **believed-fixed, pending jarvis validation**. Issue #1051 remains open until W-jarvis validates the installed result.
|
||||||
@@ -1,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,41 +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.
|
|
||||||
|
|
||||||
## Tranche 3 TDD
|
|
||||||
|
|
||||||
Expanded the shared scanner over four wake validation harnesses. RED named 26 occurrences. The wake fixture asserts 26 occurrences / 25 normalized identities through the same scanner; all scalar assertions now use redirection, direct jq selection, complete capture, or consuming diagnostic ranges. Current-tree finding count is zero across the full scoped population.
|
|
||||||
|
|
||||||
## 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 static inventory remains aligned at 261/261 after line-neutral rewrites; no static-set mismatch. 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.
|
|
||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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,12 +352,8 @@ 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
|
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||||
# a fresh-marker mutant into the stale state that this fixture must distinguish.
|
|
||||||
STALE_OBSERVATION_EPOCH=$(date +%s)
|
|
||||||
MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \
|
|
||||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
|
||||||
wait_for_sidecar_status "$STALE_HB"
|
wait_for_sidecar_status "$STALE_HB"
|
||||||
|
|
||||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ cp "$TARGET" "$BAK"
|
|||||||
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
||||||
|
|
||||||
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
||||||
PROSE_LO="$(grep -n -m1 '^usage() {' "$BAK" | cut -d: -f1)"
|
PROSE_LO="$(grep -n '^usage() {' "$BAK" | head -1 | cut -d: -f1)"
|
||||||
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
||||||
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
||||||
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
||||||
|
|||||||
@@ -219,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]
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -14,8 +14,6 @@ MERGE_METHOD="squash"
|
|||||||
DELETE_BRANCH=false
|
DELETE_BRANCH=false
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
EXPECT_HEAD=""
|
EXPECT_HEAD=""
|
||||||
CO_AUTHOR_TRAILERS=false
|
|
||||||
ESCALATE_TO=""
|
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -29,16 +27,12 @@ Options:
|
|||||||
-d, --delete-branch Delete the head branch after merge
|
-d, --delete-branch Delete the head branch after 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
|
--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 --co-author-trailers --escalate-to tl-mosaic
|
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -63,25 +57,9 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--expect-head)
|
--expect-head)
|
||||||
if [[ $# -lt 2 ]]; then
|
|
||||||
echo "Error: --expect-head requires one full commit SHA." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
EXPECT_HEAD="$2"
|
EXPECT_HEAD="$2"
|
||||||
shift 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
|
||||||
;;
|
;;
|
||||||
@@ -110,30 +88,17 @@ 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
|
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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_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_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())')"
|
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
|
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
|
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -157,442 +122,70 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
write_curl_auth_config() {
|
merge_gitea_with_api() {
|
||||||
local mode="$1" credential="$2"
|
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||||
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"
|
|
||||||
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 sys
|
|
||||||
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
|
||||||
pull = json.load(handle)
|
|
||||||
head = pull.get("head") if isinstance(pull, dict) else None
|
|
||||||
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
|
|
||||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
|
|
||||||
raise SystemExit(1)
|
|
||||||
print(sha)
|
|
||||||
PY
|
|
||||||
then
|
|
||||||
echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2
|
|
||||||
rm -f "$response_file"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
rm -f "$response_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch_gitea_pr_commits() {
|
|
||||||
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
|
||||||
local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc
|
|
||||||
mkdir -p "$work_root"
|
|
||||||
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
|
||||||
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX")
|
|
||||||
printf '[]' > "$combined_file"
|
|
||||||
|
|
||||||
page=1
|
|
||||||
while true; do
|
|
||||||
page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX")
|
|
||||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}"
|
|
||||||
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \
|
|
||||||
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
|
|
||||||
curl_rc=$?
|
|
||||||
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
|
||||||
if [[ "$curl_rc" -ne 0 ]]; then
|
|
||||||
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
|
||||||
rm -f "$page_file" "$combined_file"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ ! "$raw_code" =~ ^2 ]]; then
|
|
||||||
LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file")
|
|
||||||
rm -f "$page_file" "$combined_file"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
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"
|
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 "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||||
mkdir -p "$work_root"
|
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||||
attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX")
|
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
||||||
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 json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
head_sha, delete_branch = sys.argv[1:]
|
||||||
fields = json.load(handle)
|
|
||||||
head_sha, delete_branch = sys.argv[2:]
|
|
||||||
payload = {"Do": "squash", "head_commit_id": head_sha}
|
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||||
if delete_branch == "true":
|
if delete_branch == "true":
|
||||||
payload["delete_branch_after_merge"] = 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=(",", ":")))
|
print(json.dumps(payload, separators=(",", ":")))
|
||||||
PY
|
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
|
token=$(get_gitea_token "$host" || true)
|
||||||
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
if [[ -n "$token" ]]; then
|
||||||
rm -f "$body_file" "$payload_file"
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||||
return 1
|
-X POST \
|
||||||
|
-H "User-Agent: curl/8" \
|
||||||
|
-H "Authorization: token $token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" \
|
||||||
|
"$api_url" || true)
|
||||||
|
if [[ "$raw_code" =~ ^2 ]]; then
|
||||||
|
rm -f "$body_file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
fi
|
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() {
|
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
||||||
local host="$1" token attempt_rc
|
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
|
||||||
|
|
||||||
if ! token=$(get_gitea_token "$host"); then
|
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
||||||
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
import json
|
||||||
return 1
|
import sys
|
||||||
fi
|
code, path = sys.argv[1], sys.argv[2]
|
||||||
if [[ -z "$token" ]]; then
|
try:
|
||||||
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||||
return 1
|
raw = handle.read(500)
|
||||||
fi
|
data = json.loads(raw) if raw else {}
|
||||||
if merge_gitea_api_attempt "$host" token "$token"; then
|
message = data.get("message") or data.get("error") or raw or "empty response"
|
||||||
return 0
|
except Exception:
|
||||||
else
|
try:
|
||||||
attempt_rc=$?
|
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
|
||||||
fi
|
except Exception:
|
||||||
if [[ "$attempt_rc" -eq 75 ]]; then
|
message = "unreadable response"
|
||||||
return 75
|
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
|
||||||
fi
|
PY
|
||||||
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
|
rm -f "$body_file"
|
||||||
echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,10 +195,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,10 +209,6 @@ fi
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
|
||||||
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
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[@]}"
|
||||||
@@ -629,7 +219,7 @@ case "$PLATFORM" in
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||||
# tea cannot express it, so every Gitea merge uses the authenticated API path.
|
# tea cannot express it, so exact-head merges use the authenticated API path.
|
||||||
merge_gitea_with_api "$HOST"
|
merge_gitea_with_api "$HOST"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -36,30 +36,13 @@ cat > "$WORK_DIR/gitea/curl" <<'SH'
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
payload=""
|
payload=""
|
||||||
out_file=""
|
for ((i=1; i<=$#; i++)); do
|
||||||
while [[ $# -gt 0 ]]; do
|
if [[ "${!i}" == "-d" ]]; then
|
||||||
case "$1" in
|
j=$((i + 1))
|
||||||
-d|--data|--data-binary)
|
payload="${!j}"
|
||||||
payload="$2"
|
fi
|
||||||
[[ "$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
|
done
|
||||||
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||||
[[ -n "$out_file" ]] && printf '{}' > "$out_file"
|
|
||||||
printf '200'
|
printf '200'
|
||||||
SH
|
SH
|
||||||
chmod +x "$WORK_DIR/gitea/curl"
|
chmod +x "$WORK_DIR/gitea/curl"
|
||||||
|
|||||||
@@ -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)"
|
|
||||||
@@ -225,10 +225,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:-}"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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=()
|
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
|
||||||
mapfile -t snapshots < <(
|
| LC_ALL=C sort -r | head -1
|
||||||
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
|
|
||||||
| LC_ALL=C sort -r
|
|
||||||
)
|
|
||||||
printf '%s\n' "${snapshots[0]:-}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
|
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ chk "[reset-fail] the manual-recovery pointer is emitted (not a silent set -e ex
|
|||||||
"grep -q 'Snapshot restore could not reset' '$OUTG'"
|
"grep -q 'Snapshot restore could not reset' '$OUTG'"
|
||||||
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
|
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
|
||||||
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
|
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
|
||||||
SNAP_E="$(grep -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,8 +353,7 @@ 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 (generated installer controls are also removed by the EXIT trap).
|
||||||
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re
|
|||||||
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||||
|
|
||||||
# --- single-suite directories: unmeasured in CI ---
|
# --- single-suite directories: unmeasured in CI ---
|
||||||
|
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
|
||||||
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
|
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
|
||||||
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
|
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
|
||||||
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
|
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
|
|||||||
sleep 1.2
|
sleep 1.2
|
||||||
pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
|
pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
|
||||||
|
|
||||||
if 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")]"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -644,8 +644,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
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ EOF
|
|||||||
_manifest_val() {
|
_manifest_val() {
|
||||||
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 path + JSON + shape + Gate B schema range (mirrors
|
# _load_watchlist — validate path + JSON + shape + Gate B schema range (mirrors
|
||||||
|
|||||||
@@ -564,7 +564,7 @@ echo "== Q16 (guard): Q2's ENUM-B fixture must STAY address-free — the reconci
|
|||||||
# Token concatenated so THIS guard's own source lines never contain the
|
# Token concatenated so THIS guard's own source lines never contain the
|
||||||
# literal fixture id and cannot self-match.
|
# literal fixture id and cannot self-match.
|
||||||
enum_id='ENUM''-B'
|
enum_id='ENUM''-B'
|
||||||
fixture_lines="$(has_match -F "\"id\":\"$enum_id\"" "$self")"; fixture_matches="$(has_match -F '"observed_seq":5' <<<"$fixture_lines")"; fixture_line="${fixture_matches%%$'\n'*}"
|
fixture_line="$(has_match -F "\"id\":\"$enum_id\"" "$self" | has_match -F '"observed_seq":5' | head -n1)"
|
||||||
[ -n "$fixture_line" ] || fail_msg "Q16: could not locate Q2's $enum_id fixture line (renamed/renumbered? update this guard)"
|
[ -n "$fixture_line" ] || fail_msg "Q16: could not locate Q2's $enum_id fixture line (renamed/renumbered? update this guard)"
|
||||||
fixture_json="$(printf '%s' "$fixture_line" | sed "s/.*'\({.*}\)'.*/\1/")"
|
fixture_json="$(printf '%s' "$fixture_line" | sed "s/.*'\({.*}\)'.*/\1/")"
|
||||||
# Positive controls FIRST (blind-instrument rule): the extraction must yield
|
# Positive controls FIRST (blind-instrument rule): the extraction must yield
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ echo "== P3: detector orders the cause line BEFORE the delta it explains =="
|
|||||||
printf 'r1 state v2\n' >"$fx/repo_r1"
|
printf 'r1 state v2\n' >"$fx/repo_r1"
|
||||||
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: second poll failed"
|
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: second poll failed"
|
||||||
sd="$(state_dir)"
|
sd="$(state_dir)"
|
||||||
pre_seq="$(jq -nr 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||||
src_seq="$(jq -nr 'first(inputs | select(.locators.kind == "repo") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
src_seq="$(jq -r 'select(.locators.kind == "repo") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||||
[ -n "$pre_seq" ] || fail_msg "P3: no preimage cause entry enqueued"
|
[ -n "$pre_seq" ] || fail_msg "P3: no preimage cause entry enqueued"
|
||||||
[ -n "$src_seq" ] || fail_msg "P3: no source delta entry enqueued"
|
[ -n "$src_seq" ] || fail_msg "P3: no source delta entry enqueued"
|
||||||
if [ -n "$pre_seq" ] && [ -n "$src_seq" ]; then
|
if [ -n "$pre_seq" ] && [ -n "$src_seq" ]; then
|
||||||
@@ -343,8 +343,8 @@ echo "== P11: reconcile surfaces the cause line before its enumerations =="
|
|||||||
printf '# adapter changed while detector down\n' >>"$fx/adapter.sh"
|
printf '# adapter changed while detector down\n' >>"$fx/adapter.sh"
|
||||||
"$RECON" reconcile >/dev/null 2>&1 # rc 1 expected (unaccounted enumerated)
|
"$RECON" reconcile >/dev/null 2>&1 # rc 1 expected (unaccounted enumerated)
|
||||||
sd="$(state_dir)"
|
sd="$(state_dir)"
|
||||||
pre_seq="$(jq -nr 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||||
enum_seq="$(jq -nr 'first(inputs | select(.locators.reconciled == true) | .observed_seq) // empty' "$sd/pending.jsonl")"
|
enum_seq="$(jq -r 'select(.locators.reconciled == true) | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||||
[ -n "$pre_seq" ] || fail_msg "P11: reconcile must enqueue the preimage cause line"
|
[ -n "$pre_seq" ] || fail_msg "P11: reconcile must enqueue the preimage cause line"
|
||||||
[ -n "$enum_seq" ] || fail_msg "P11: reconcile must still enumerate the unaccounted source"
|
[ -n "$enum_seq" ] || fail_msg "P11: reconcile must still enumerate the unaccounted source"
|
||||||
if [ -n "$pre_seq" ] && [ -n "$enum_seq" ]; then
|
if [ -n "$pre_seq" ] && [ -n "$enum_seq" ]; then
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ site_line() { # site_line FILE MARKER -> first physical line of that call
|
|||||||
local f="$1" marker="$2" ln
|
local f="$1" marker="$2" ln
|
||||||
ln="$(grep -n "# SITE:${marker}\$" "$f" | cut -d: -f1)"
|
ln="$(grep -n "# SITE:${marker}\$" "$f" | cut -d: -f1)"
|
||||||
# continuation marker sits on the tail line; the call starts one line up
|
# continuation marker sits on the tail line; the call starts one line up
|
||||||
source_line="$(sed -n "${ln}p" "$f")"; if ! grep -Eq 'has_match|count_lines' <<<"$source_line"; then
|
if ! sed -n "${ln}p" "$f" | grep -Eq 'has_match|count_lines'; then
|
||||||
ln=$((ln - 1))
|
ln=$((ln - 1))
|
||||||
fi
|
fi
|
||||||
printf '%s' "$ln"
|
printf '%s' "$ln"
|
||||||
@@ -167,13 +167,13 @@ rcB=$?
|
|||||||
sort "$LEDGER" >"$TMP/got-c1"
|
sort "$LEDGER" >"$TMP/got-c1"
|
||||||
n_expected="$(grep -c . "$TMP/expected-c1")"
|
n_expected="$(grep -c . "$TMP/expected-c1")"
|
||||||
if [ "$rcA" -eq 0 ] && [ "$rcB" -eq 0 ] &&
|
if [ "$rcA" -eq 0 ] && [ "$rcB" -eq 0 ] &&
|
||||||
grep -q 'mini-a: OK' <<<"$outA"&&
|
printf '%s' "$outA" | grep -q 'mini-a: OK' &&
|
||||||
grep -q 'mini-b: OK' <<<"$outB"&&
|
printf '%s' "$outB" | grep -q 'mini-b: OK' &&
|
||||||
[ "$n_expected" -gt 1 ] &&
|
[ "$n_expected" -gt 1 ] &&
|
||||||
cmp -s "$TMP/expected-c1" "$TMP/got-c1"; then
|
cmp -s "$TMP/expected-c1" "$TMP/got-c1"; then
|
||||||
check C1 0 ""
|
check C1 0 ""
|
||||||
else
|
else
|
||||||
check C1 1 "rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff "$TMP/expected-c1" "$TMP/got-c1" 2>&1 | sed -n '1,10p' | tr '\n' ' ')"
|
check C1 1 "rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff "$TMP/expected-c1" "$TMP/got-c1" 2>&1 | head -n 10 | tr '\n' ' ')"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- C2: early exit -> short ledger, comparison catches it -----------------
|
# --- C2: early exit -> short ledger, comparison catches it -----------------
|
||||||
@@ -201,12 +201,12 @@ abort_case() { # abort_case NAME MARKER HELPER
|
|||||||
bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -ne 0 ] &&
|
if [ "$rc" -ne 0 ] &&
|
||||||
! grep -q 'mini-a: OK' <<<"$out"&&
|
! printf '%s' "$out" | grep -q 'mini-a: OK' &&
|
||||||
! grep -q 'mini-a: FAILED' <<<"$out"&&
|
! printf '%s' "$out" | grep -q 'mini-a: FAILED' &&
|
||||||
grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" <<<"$out"&&
|
printf '%s' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" &&
|
||||||
grep -q "WAKE-ASSERT ABORT" <<<"$out"&&
|
printf '%s' "$out" | grep -q "WAKE-ASSERT ABORT" &&
|
||||||
grep -q "$site" <<<"$out"&&
|
printf '%s' "$out" | grep -q "$site" &&
|
||||||
grep -q "grep exit 2" <<<"$out"&&
|
printf '%s' "$out" | grep -q "grep exit 2" &&
|
||||||
grep -q "^${helper} ${site}\$" "$ledger"; then
|
grep -q "^${helper} ${site}\$" "$ledger"; then
|
||||||
check "$name" 0 ""
|
check "$name" 0 ""
|
||||||
else
|
else
|
||||||
@@ -230,8 +230,8 @@ if [ "$got" = "1" ]; then check C8 0 ""; else check C8 1 "env-prefix did not rea
|
|||||||
out="$(WAKE_ASSERT_FORCE_GREP_ERROR_AT="mini-a.sh:9999" bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
out="$(WAKE_ASSERT_FORCE_GREP_ERROR_AT="mini-a.sh:9999" bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -eq 0 ] &&
|
if [ "$rc" -eq 0 ] &&
|
||||||
grep -q 'mini-a: OK' <<<"$out"&&
|
printf '%s' "$out" | grep -q 'mini-a: OK' &&
|
||||||
! grep -q 'WAKE-ASSERT ARMED' <<<"$out"; then
|
! printf '%s' "$out" | grep -q 'WAKE-ASSERT ARMED'; then
|
||||||
check C9 0 ""
|
check C9 0 ""
|
||||||
else
|
else
|
||||||
check C9 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
|
check C9 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
|
||||||
@@ -248,8 +248,8 @@ chmod +x "$TMP/fake-bash"
|
|||||||
out="$(WAKE_ASSERT_PIN_BASH="$TMP/fake-bash" bash -c '. "$WAKE_COMMON" && wake_assert_init && echo REACHED-PAST-INIT' 2>&1)"
|
out="$(WAKE_ASSERT_PIN_BASH="$TMP/fake-bash" bash -c '. "$WAKE_COMMON" && wake_assert_init && echo REACHED-PAST-INIT' 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -ne 0 ] &&
|
if [ "$rc" -ne 0 ] &&
|
||||||
! grep -q 'REACHED-PAST-INIT' <<<"$out"&&
|
! printf '%s' "$out" | grep -q 'REACHED-PAST-INIT' &&
|
||||||
grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated' <<<"$out"; then
|
printf '%s' "$out" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then
|
||||||
check C10 0 ""
|
check C10 0 ""
|
||||||
else
|
else
|
||||||
check C10 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 2 | tr '\n' ' ')"
|
check C10 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 2 | tr '\n' ' ')"
|
||||||
@@ -283,8 +283,8 @@ out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$TMP/mini-c.sh" "$TMP" 2>&1)"
|
|||||||
rc=$?
|
rc=$?
|
||||||
summary_ln="$(site_line "$TMP/mini-c.sh" c-summary)"
|
summary_ln="$(site_line "$TMP/mini-c.sh" c-summary)"
|
||||||
if [ "$rc" -eq 1 ] &&
|
if [ "$rc" -eq 1 ] &&
|
||||||
grep -q 'wake mini-c harness: FAILED (1 assertion(s))' <<<"$out"&&
|
printf '%s' "$out" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&
|
||||||
! grep -q 'all invariants passed' <<<"$out"&&
|
! printf '%s' "$out" | grep -q 'all invariants passed' &&
|
||||||
grep -q "^count_lines mini-c.sh:${summary_ln}\$" "$LEDGER"; then
|
grep -q "^count_lines mini-c.sh:${summary_ln}\$" "$LEDGER"; then
|
||||||
check C11 0 ""
|
check C11 0 ""
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ if cmp -s "$TMP/expected.txt" "$TMP/static.txt"; then
|
|||||||
echo "STATIC-INVENTORY equals expected set ($(grep -c . "$TMP/static.txt") rows from source text)"
|
echo "STATIC-INVENTORY equals expected set ($(grep -c . "$TMP/static.txt") rows from source text)"
|
||||||
else
|
else
|
||||||
flag "static inventory (source text) differs from expected set (artifact):"
|
flag "static inventory (source text) differs from expected set (artifact):"
|
||||||
diff "$TMP/expected.txt" "$TMP/static.txt" | sed -n '1,20p' | sed 's/^/ /'
|
diff "$TMP/expected.txt" "$TMP/static.txt" | head -n 20 | sed 's/^/ /'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 3: green instrumented run ----------------------------------------------
|
# --- 3: green instrumented run ----------------------------------------------
|
||||||
@@ -116,7 +116,7 @@ LEDGER="$TMP/ledger"
|
|||||||
for s in "${SUITES[@]}"; do
|
for s in "${SUITES[@]}"; do
|
||||||
out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$WAKE/$s" 2>&1)"
|
out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$WAKE/$s" 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if grep -Eq "$(sentinel_for "$s")" <<<"$out"; then
|
if printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$s")"; then
|
||||||
sent="present"
|
sent="present"
|
||||||
else
|
else
|
||||||
sent="ABSENT"
|
sent="ABSENT"
|
||||||
@@ -177,14 +177,14 @@ while read -r helper site form; do
|
|||||||
rc=$?
|
rc=$?
|
||||||
bad=""
|
bad=""
|
||||||
[ "$rc" -ne 0 ] || bad="$bad exit=0"
|
[ "$rc" -ne 0 ] || bad="$bad exit=0"
|
||||||
grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" <<<"$out"||
|
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" ||
|
||||||
bad="$bad no-ARMED-line"
|
bad="$bad no-ARMED-line"
|
||||||
grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" <<<"$out"||
|
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" ||
|
||||||
bad="$bad no-ABORT-line"
|
bad="$bad no-ABORT-line"
|
||||||
# AND-polarity check (a match is the defect): a grep error (rc>=2) must be
|
# AND-polarity check (a match is the defect): a grep error (rc>=2) must be
|
||||||
# its own loud arm — it cannot fall through as "no sentinel = pass".
|
# its own loud arm — it cannot fall through as "no sentinel = pass".
|
||||||
rc_sent=0
|
rc_sent=0
|
||||||
grep -Eq "$(sentinel_for "$f")" <<<"$out"|| rc_sent=$?
|
printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$f")" || rc_sent=$?
|
||||||
case "$rc_sent" in
|
case "$rc_sent" in
|
||||||
0) bad="$bad sentinel-emitted" ;;
|
0) bad="$bad sentinel-emitted" ;;
|
||||||
1) : ;;
|
1) : ;;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { registerSkillCommand } from './commands/skill.js';
|
|||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
||||||
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
||||||
|
import { registerBrainProvisionCommand } from './commands/brain-provision-command.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
import { registerFederationCommand } from './commands/federation.js';
|
import { registerFederationCommand } from './commands/federation.js';
|
||||||
import { registerGatewayCommand } from './commands/gateway.js';
|
import { registerGatewayCommand } from './commands/gateway.js';
|
||||||
@@ -85,6 +86,10 @@ registerLeaseCapabilityProbe(program);
|
|||||||
|
|
||||||
registerInstallOrderingGuardCommand(program);
|
registerInstallOrderingGuardCommand(program);
|
||||||
|
|
||||||
|
// ─── durable brain P7 provisioner (hidden; #1051) ───────────────────────
|
||||||
|
|
||||||
|
registerBrainProvisionCommand(program);
|
||||||
|
|
||||||
// ─── login ──────────────────────────────────────────────────────────────
|
// ─── login ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
interface CommandRequest {
|
||||||
|
readonly program: 'git' | 'mosaic';
|
||||||
|
readonly args: readonly string[];
|
||||||
|
readonly env: Readonly<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandResult {
|
||||||
|
readonly status: number;
|
||||||
|
readonly stdout: string;
|
||||||
|
readonly stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstalledDoctorResult {
|
||||||
|
readonly status: 'ok' | 'warn' | 'error';
|
||||||
|
readonly findings: readonly {
|
||||||
|
readonly code: string;
|
||||||
|
readonly reasonCode: string | null;
|
||||||
|
}[];
|
||||||
|
readonly lines: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrainDoctorModule {
|
||||||
|
runInstalledBrainDoctorCheck(
|
||||||
|
options: {
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly home: string;
|
||||||
|
readonly identity?: string;
|
||||||
|
readonly fix: boolean;
|
||||||
|
},
|
||||||
|
run: (request: CommandRequest) => CommandResult,
|
||||||
|
): InstalledDoctorResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODULE_PATH = './brain-doctor-check.js';
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
async function loadDoctor(requirement: string): Promise<BrainDoctorModule> {
|
||||||
|
try {
|
||||||
|
return (await import(MODULE_PATH)) as BrainDoctorModule;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`${requirement}: installed brain doctor check is absent (${detail})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tempRoot(): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-doctor-'));
|
||||||
|
roots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installConfig(root: string): { readonly home: string; readonly mosaicHome: string } {
|
||||||
|
const home = join(root, 'home');
|
||||||
|
const mosaicHome = join(home, '.config', 'mosaic');
|
||||||
|
mkdirSync(join(mosaicHome, 'cred'), { recursive: true });
|
||||||
|
mkdirSync(join(mosaicHome, 'brain'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(mosaicHome, 'cred', 'estates.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
estates: [
|
||||||
|
{
|
||||||
|
name: 'homelab',
|
||||||
|
readOnlyControlIdentity: 'read-control',
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
provider: 'gitea',
|
||||||
|
apiBaseUrl: 'https://git.example.invalid',
|
||||||
|
tokenPrefix: 'gitea-example',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(mosaicHome, 'brain', 'owners.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
estates: [
|
||||||
|
{
|
||||||
|
estate: 'homelab',
|
||||||
|
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||||
|
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||||
|
controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(mosaicHome, '.install-manifest.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
status: 'committed',
|
||||||
|
sourceRepo: 'https://git.example.invalid/example/stack.git',
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
return { home, mosaicHome };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateResult(outcome: 'ok' | 'refused' | 'indeterminate', reasonCode: string): string {
|
||||||
|
const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30;
|
||||||
|
return JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'validate',
|
||||||
|
outcome,
|
||||||
|
exitCode,
|
||||||
|
retryable: false,
|
||||||
|
subject: {
|
||||||
|
identity: 'seat-a',
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
repo: 'durable-owner/mosaic-brain',
|
||||||
|
},
|
||||||
|
mutation: 'none',
|
||||||
|
reason: { code: reasonCode, message: 'non-secret' },
|
||||||
|
evidence: {
|
||||||
|
providerIdentity:
|
||||||
|
outcome === 'ok'
|
||||||
|
? {
|
||||||
|
login: 'seat-a',
|
||||||
|
endpoint: 'GET /api/v1/user',
|
||||||
|
contentType: 'application/json',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
repositoryPermission:
|
||||||
|
outcome === 'ok'
|
||||||
|
? {
|
||||||
|
requested: 'write',
|
||||||
|
effective: 'write',
|
||||||
|
endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain',
|
||||||
|
contentType: 'application/json',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
writeDifferential:
|
||||||
|
outcome === 'ok'
|
||||||
|
? {
|
||||||
|
state: 'can-write',
|
||||||
|
credentialBinding: 'same-resolution',
|
||||||
|
transportPrincipal: 'seat-a',
|
||||||
|
authenticatedReceivePack: 'advertised',
|
||||||
|
readOnlyControl: {
|
||||||
|
identity: 'read-control',
|
||||||
|
providerPermission: 'read',
|
||||||
|
receivePack: 'refused',
|
||||||
|
},
|
||||||
|
unauthenticatedReceivePack: 'refused',
|
||||||
|
artifactCreated: false,
|
||||||
|
proves: 'non-secret evidence',
|
||||||
|
doesNotProve: 'branch update acceptance',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
audit: { journalId: 'opaque', state: 'sealed' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('installed mosaic doctor brain checks', (): void => {
|
||||||
|
it('derives the target from the committed install manifest and surfaces a missing clone plus refusal', async (): Promise<void> => {
|
||||||
|
const doctor = await loadDoctor('MB-REQ-08 installed doctor missing clone');
|
||||||
|
const config = installConfig(tempRoot());
|
||||||
|
const requests: CommandRequest[] = [];
|
||||||
|
|
||||||
|
const result = doctor.runInstalledBrainDoctorCheck(
|
||||||
|
{ ...config, identity: 'seat-a', fix: false },
|
||||||
|
(request): CommandResult => {
|
||||||
|
requests.push(request);
|
||||||
|
return {
|
||||||
|
status: 10,
|
||||||
|
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||||
|
stderr: 'refused reason=no-token-for-identity',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('warn');
|
||||||
|
expect(result.findings.map((finding) => finding.code)).toEqual(
|
||||||
|
expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']),
|
||||||
|
);
|
||||||
|
expect(result.lines.join('\n')).toMatch(/brain-clone-missing/);
|
||||||
|
expect(result.lines.join('\n')).toMatch(/no-token-for-identity/);
|
||||||
|
expect(requests[0]?.args).toContain('durable-owner/mosaic-brain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed without an explicit identity and performs no command', async (): Promise<void> => {
|
||||||
|
const doctor = await loadDoctor('MB-REQ-08 explicit identity');
|
||||||
|
const config = installConfig(tempRoot());
|
||||||
|
let calls = 0;
|
||||||
|
|
||||||
|
const result = doctor.runInstalledBrainDoctorCheck(
|
||||||
|
{ ...config, fix: false },
|
||||||
|
(): CommandResult => {
|
||||||
|
calls += 1;
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
findings: [{ code: 'brain-identity-required', reasonCode: 'identity-required' }],
|
||||||
|
});
|
||||||
|
expect(calls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats identity-not-measured as an error, not no-write refusal and not a repairable grant case', async (): Promise<void> => {
|
||||||
|
const doctor = await loadDoctor('MB-REQ-08 identity measurement axis');
|
||||||
|
const config = installConfig(tempRoot());
|
||||||
|
const requests: CommandRequest[] = [];
|
||||||
|
|
||||||
|
const result = doctor.runInstalledBrainDoctorCheck(
|
||||||
|
{ ...config, identity: 'seat-a', fix: true },
|
||||||
|
(request): CommandResult => {
|
||||||
|
requests.push(request);
|
||||||
|
return {
|
||||||
|
status: 30,
|
||||||
|
stdout: validateResult('indeterminate', 'identity-not-measured'),
|
||||||
|
stderr: 'identity not measured',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'brain-write-access-indeterminate',
|
||||||
|
reasonCode: 'identity-not-measured',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is wired into the top-level mosaic doctor path before the shell audit runs', (): void => {
|
||||||
|
const launch = readFileSync(join(process.cwd(), 'src', 'commands', 'launch.ts'), 'utf8');
|
||||||
|
|
||||||
|
expect(launch).toContain('runInstalledBrainDoctorCheck');
|
||||||
|
expect(launch).toContain('defaultInstalledBrainDoctorOptions');
|
||||||
|
expect(launch).toContain('systemCommandRunner');
|
||||||
|
expect(launch).toMatch(/brainCheckFailed[\s\S]*runDoctorScriptAndExit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a missing or unsafe registry/manifest as configuration error rather than defaulting estate', async (): Promise<void> => {
|
||||||
|
const doctor = await loadDoctor('MB-REQ-02 missing mapping fail-closed');
|
||||||
|
const root = tempRoot();
|
||||||
|
const home = join(root, 'home');
|
||||||
|
const mosaicHome = join(home, '.config', 'mosaic');
|
||||||
|
mkdirSync(mosaicHome, { recursive: true });
|
||||||
|
|
||||||
|
const result = doctor.runInstalledBrainDoctorCheck(
|
||||||
|
{ home, mosaicHome, identity: 'seat-a', fix: false },
|
||||||
|
(): CommandResult => ({ status: 0, stdout: '', stderr: '' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.findings[0]?.code).toMatch(/brain-(estate-registry|install-manifest)-/);
|
||||||
|
expect(result.lines.join('\n')).not.toMatch(/homelab|usc/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { readBrainConfigSecure } from './brain-secure-config.js';
|
||||||
|
import { resolveBrainOwnerPolicy } from './brain-owner-resolver.js';
|
||||||
|
import { deriveBrainTarget } from './brain-store.js';
|
||||||
|
import {
|
||||||
|
collectBrainDoctorReport,
|
||||||
|
repairBrainDoctor,
|
||||||
|
type CommandRunner,
|
||||||
|
type DoctorRuntimeReport,
|
||||||
|
} from './brain-store-runtime.js';
|
||||||
|
|
||||||
|
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||||
|
const manifestSchema = z
|
||||||
|
.object({
|
||||||
|
version: z.literal(2),
|
||||||
|
status: z.literal('committed'),
|
||||||
|
sourceRepo: z.string().min(1),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export interface InstalledDoctorFinding {
|
||||||
|
readonly code: string;
|
||||||
|
readonly reasonCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstalledDoctorResult {
|
||||||
|
readonly status: 'ok' | 'warn' | 'error';
|
||||||
|
readonly findings: readonly InstalledDoctorFinding[];
|
||||||
|
readonly lines: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationError(code: string, reasonCode = code): InstalledDoctorResult {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
findings: [{ code, reasonCode }],
|
||||||
|
lines: [`[mosaic-doctor] [ERROR] ${code}`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReport(report: DoctorRuntimeReport): InstalledDoctorResult {
|
||||||
|
const findings = report.findings.map(
|
||||||
|
(finding): InstalledDoctorFinding => ({
|
||||||
|
code: finding.code,
|
||||||
|
reasonCode: finding.reasonCode,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const hard = findings.some(
|
||||||
|
(finding): boolean =>
|
||||||
|
finding.code.endsWith('-error') ||
|
||||||
|
finding.code.endsWith('-indeterminate') ||
|
||||||
|
finding.code === 'brain-not-git-repository' ||
|
||||||
|
finding.code === 'brain-root-permissions-unsafe',
|
||||||
|
);
|
||||||
|
const status: InstalledDoctorResult['status'] =
|
||||||
|
findings.length === 0 ? 'ok' : hard ? 'error' : 'warn';
|
||||||
|
const severity = status === 'error' ? 'ERROR' : status === 'warn' ? 'WARN' : 'OK';
|
||||||
|
const lines =
|
||||||
|
findings.length === 0
|
||||||
|
? ['[mosaic-doctor] [OK] mosaic-brain ready']
|
||||||
|
: findings.map(
|
||||||
|
(finding): string =>
|
||||||
|
`[mosaic-doctor] [${severity}] ${finding.code}${
|
||||||
|
finding.reasonCode === null ? '' : ` reason=${finding.reasonCode}`
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return { status, findings, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runInstalledBrainDoctorCheck(
|
||||||
|
options: {
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly home: string;
|
||||||
|
readonly identity?: string;
|
||||||
|
readonly fix: boolean;
|
||||||
|
},
|
||||||
|
run: CommandRunner,
|
||||||
|
): InstalledDoctorResult {
|
||||||
|
if (options.identity === undefined || !IDENTITY.test(options.identity)) {
|
||||||
|
return configurationError('brain-identity-required', 'identity-required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryPath = join(options.mosaicHome, 'cred', 'estates.json');
|
||||||
|
const manifestPath = join(options.mosaicHome, '.install-manifest.json');
|
||||||
|
const ownerPolicyPath = join(options.mosaicHome, 'brain', 'owners.json');
|
||||||
|
let registrySource: string;
|
||||||
|
try {
|
||||||
|
registrySource = readBrainConfigSecure(registryPath, options.mosaicHome);
|
||||||
|
} catch {
|
||||||
|
return configurationError('brain-estate-registry-unavailable');
|
||||||
|
}
|
||||||
|
let manifestSource: string;
|
||||||
|
try {
|
||||||
|
manifestSource = readBrainConfigSecure(manifestPath, options.mosaicHome);
|
||||||
|
} catch {
|
||||||
|
return configurationError('brain-install-manifest-unavailable');
|
||||||
|
}
|
||||||
|
let manifestRaw: unknown;
|
||||||
|
try {
|
||||||
|
manifestRaw = JSON.parse(manifestSource);
|
||||||
|
} catch {
|
||||||
|
return configurationError('brain-install-manifest-invalid');
|
||||||
|
}
|
||||||
|
const manifest = manifestSchema.safeParse(manifestRaw);
|
||||||
|
if (!manifest.success) return configurationError('brain-install-manifest-invalid');
|
||||||
|
let ownerPolicySource: string;
|
||||||
|
try {
|
||||||
|
ownerPolicySource = readBrainConfigSecure(ownerPolicyPath, options.mosaicHome);
|
||||||
|
} catch {
|
||||||
|
return configurationError('brain-owner-policy-unavailable');
|
||||||
|
}
|
||||||
|
let preliminaryTarget: ReturnType<typeof deriveBrainTarget>;
|
||||||
|
try {
|
||||||
|
preliminaryTarget = deriveBrainTarget(registrySource, manifest.data.sourceRepo, 'policy-probe');
|
||||||
|
} catch {
|
||||||
|
return configurationError('brain-estate-registry-invalid');
|
||||||
|
}
|
||||||
|
const ownerPolicy = resolveBrainOwnerPolicy(ownerPolicySource, preliminaryTarget.estate);
|
||||||
|
if (ownerPolicy === undefined) return configurationError('brain-owner-policy-invalid');
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
registrySource,
|
||||||
|
targetGitUrl: manifest.data.sourceRepo,
|
||||||
|
brainNamespace: ownerPolicy.brainNamespace,
|
||||||
|
identity: options.identity,
|
||||||
|
root: join(options.home, '.mosaic'),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return renderReport(
|
||||||
|
options.fix ? repairBrainDoctor(input, run) : collectBrainDoctorReport(input, run),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return configurationError('brain-estate-registry-invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultInstalledBrainDoctorOptions(fix: boolean): {
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly home: string;
|
||||||
|
readonly identity?: string;
|
||||||
|
readonly fix: boolean;
|
||||||
|
} {
|
||||||
|
const home = homedir();
|
||||||
|
const identity = process.env['MOSAIC_GIT_IDENTITY'];
|
||||||
|
return {
|
||||||
|
mosaicHome: process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic'),
|
||||||
|
home,
|
||||||
|
...(identity === undefined ? {} : { identity }),
|
||||||
|
fix,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
function installerSource(): string {
|
||||||
|
return readFileSync(join(process.cwd(), '..', '..', 'tools', 'install.sh'), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('root installer P7 durable-brain integration', (): void => {
|
||||||
|
it('records the configured source repository in the committed manifest for doctor derivation', (): void => {
|
||||||
|
const installer = installerSource();
|
||||||
|
|
||||||
|
expect(installer).toContain('sourceRepo:');
|
||||||
|
expect(installer).toMatch(/sourceRepo:\s*process\.argv\[/);
|
||||||
|
expect(installer).toMatch(/MANIFEST_SOURCE_REPO/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes the broker-only provision command in P7 with explicit owner and refusal controls', (): void => {
|
||||||
|
const installer = installerSource();
|
||||||
|
const provision = installer.indexOf('__brain-provision');
|
||||||
|
const p7 = installer.indexOf('state_phase_begin P7');
|
||||||
|
|
||||||
|
expect(provision).toBeGreaterThan(-1);
|
||||||
|
expect(p7).toBeGreaterThan(-1);
|
||||||
|
expect(provision).toBeGreaterThan(p7);
|
||||||
|
for (const flag of [
|
||||||
|
'--identity',
|
||||||
|
'--target-url',
|
||||||
|
'--owner',
|
||||||
|
'--refusal-identity',
|
||||||
|
'--lane',
|
||||||
|
'--owner-policy',
|
||||||
|
]) {
|
||||||
|
expect(installer).toContain(flag);
|
||||||
|
}
|
||||||
|
expect(installer).not.toMatch(/__brain-provision[^\n]*(?:token|password|authorization)/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('journals ~/.mosaic and the owner policy as P7 mutations and checks the resulting object', (): void => {
|
||||||
|
const installer = installerSource();
|
||||||
|
|
||||||
|
expect(installer).toContain('state_record_mutation P7 "$HOME/.mosaic"');
|
||||||
|
expect(installer).toContain('state_record_mutation P7 "$MOSAIC_HOME/brain/owners.json"');
|
||||||
|
expect(installer).toMatch(/P7\)[\s\S]*\.mosaic[\s\S]*(?:remote|get-url)[\s\S]*main/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discovers every legacy lane directory rather than silently migrating only one lane', (): void => {
|
||||||
|
const installer = installerSource();
|
||||||
|
|
||||||
|
expect(installer).toMatch(/memory\/lanes/);
|
||||||
|
expect(installer).toMatch(/for\s+[^\n]*lane/);
|
||||||
|
expect(installer).toMatch(/__brain-provision[\s\S]*--lane/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Red-first owner-authority resolver contract for #1051.
|
||||||
|
*
|
||||||
|
* Fixtures are operator-agnostic. The HOMELAB owner name belongs in the local
|
||||||
|
* estate policy, never in framework source. Anonymous lookup is intentional:
|
||||||
|
* the ruled owner class is PUBLIC and least-privilege seats may lack read:user.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface MigrationOwnerResolution {
|
||||||
|
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
||||||
|
readonly reasonCode: string;
|
||||||
|
readonly principal: {
|
||||||
|
readonly name: string;
|
||||||
|
readonly kind: 'durable-human';
|
||||||
|
} | null;
|
||||||
|
readonly authority: {
|
||||||
|
readonly system: 'gitea';
|
||||||
|
readonly endpoint: string;
|
||||||
|
readonly contentType: 'application/json';
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
interface OwnerResolverModule {
|
||||||
|
resolveProviderDurableOwner(
|
||||||
|
input: {
|
||||||
|
readonly estateRegistrySource: string;
|
||||||
|
readonly ownerPolicySource: string;
|
||||||
|
readonly host: string;
|
||||||
|
readonly requestedOwner: string;
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
readonly fetch: FetchLike;
|
||||||
|
readonly absentControlName: () => string;
|
||||||
|
},
|
||||||
|
): Promise<MigrationOwnerResolution>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODULE_PATH = './brain-owner-resolver.js';
|
||||||
|
|
||||||
|
async function loadResolver(requirement: string): Promise<OwnerResolverModule> {
|
||||||
|
try {
|
||||||
|
return (await import(MODULE_PATH)) as OwnerResolverModule;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`${requirement}: brain owner resolver is absent (${detail})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function estateRegistry(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
estates: [
|
||||||
|
{
|
||||||
|
name: 'homelab',
|
||||||
|
readOnlyControlIdentity: 'read-control',
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
provider: 'gitea',
|
||||||
|
apiBaseUrl: 'https://git.example.invalid',
|
||||||
|
tokenPrefix: 'gitea-example',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerPolicy(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
estates: [
|
||||||
|
{
|
||||||
|
estate: 'homelab',
|
||||||
|
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||||
|
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||||
|
controls: {
|
||||||
|
publicIdentity: 'public-control',
|
||||||
|
privateIdentity: 'private-control',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(status: number, body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicUser(login: string, active = false): Response {
|
||||||
|
return jsonResponse(200, {
|
||||||
|
id: 42,
|
||||||
|
login,
|
||||||
|
visibility: 'public',
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityFromUrl(input: string | URL | Request): string {
|
||||||
|
const value = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
return decodeURIComponent(new URL(value).pathname.split('/').at(-1) ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlledFetch(
|
||||||
|
overrides: Readonly<Record<string, Response>> = {},
|
||||||
|
calls: Array<{ identity: string; authorization: string | null }> = [],
|
||||||
|
): FetchLike {
|
||||||
|
return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||||
|
const identity = identityFromUrl(input);
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
calls.push({ identity, authorization: headers.get('authorization') });
|
||||||
|
const override = overrides[identity];
|
||||||
|
if (override !== undefined) return override.clone();
|
||||||
|
if (identity === 'public-control') return publicUser('public-control');
|
||||||
|
if (identity === 'private-control' || identity === 'generated-absent-control') {
|
||||||
|
return jsonResponse(404, { message: 'not found' });
|
||||||
|
}
|
||||||
|
if (identity === 'durable-owner') return publicUser('durable-owner', false);
|
||||||
|
return jsonResponse(404, { message: 'not found' });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('provider-backed durable owner resolver', (): void => {
|
||||||
|
it('resolves an allowlisted PUBLIC owner by exact login with public/private/absent controls and ignores active=false', async (): Promise<void> => {
|
||||||
|
const resolver = await loadResolver('MB-REQ-09 provider owner resolution');
|
||||||
|
const calls: Array<{ identity: string; authorization: string | null }> = [];
|
||||||
|
|
||||||
|
const result = await resolver.resolveProviderDurableOwner(
|
||||||
|
{
|
||||||
|
estateRegistrySource: estateRegistry(),
|
||||||
|
ownerPolicySource: ownerPolicy(),
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
requestedOwner: 'user:durable-owner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetch: controlledFetch({}, calls),
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
verdict: 'resolved',
|
||||||
|
reasonCode: 'owner-verified',
|
||||||
|
principal: { name: 'user:durable-owner', kind: 'durable-human' },
|
||||||
|
authority: {
|
||||||
|
system: 'gitea',
|
||||||
|
endpoint: 'GET /api/v1/users/durable-owner',
|
||||||
|
contentType: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(calls.map((call) => call.identity)).toEqual([
|
||||||
|
'public-control',
|
||||||
|
'private-control',
|
||||||
|
'generated-absent-control',
|
||||||
|
'durable-owner',
|
||||||
|
]);
|
||||||
|
expect(calls.every((call) => call.authorization === null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses provider redirects and configures a bounded no-redirect request', async (): Promise<void> => {
|
||||||
|
const resolver = await loadResolver('MB-REQ-09 owner lookup SSRF boundary');
|
||||||
|
const requests: RequestInit[] = [];
|
||||||
|
|
||||||
|
const result = await resolver.resolveProviderDurableOwner(
|
||||||
|
{
|
||||||
|
estateRegistrySource: estateRegistry(),
|
||||||
|
ownerPolicySource: ownerPolicy(),
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
requestedOwner: 'user:durable-owner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetch: async (_input, init): Promise<Response> => {
|
||||||
|
requests.push(init ?? {});
|
||||||
|
return new Response(JSON.stringify({ message: 'redirect' }), {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
location: 'http://127.0.0.1/internal',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ verdict: 'not-measured', reasonCode: 'owner-control-invalid' });
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0]?.redirect).toBe('manual');
|
||||||
|
expect(requests[0]?.signal).toBeInstanceOf(AbortSignal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a chunked provider body as soon as it exceeds the byte ceiling', async (): Promise<void> => {
|
||||||
|
const resolver = await loadResolver('MB-REQ-09 bounded owner response stream');
|
||||||
|
let cancelled = false;
|
||||||
|
const oversized = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller): void {
|
||||||
|
controller.enqueue(new Uint8Array(200_000));
|
||||||
|
controller.enqueue(new Uint8Array(100_000));
|
||||||
|
},
|
||||||
|
cancel(): void {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolver.resolveProviderDurableOwner(
|
||||||
|
{
|
||||||
|
estateRegistrySource: estateRegistry(),
|
||||||
|
ownerPolicySource: ownerPolicy(),
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
requestedOwner: 'user:durable-owner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetch: async (): Promise<Response> =>
|
||||||
|
new Response(oversized, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
verdict: 'not-measured',
|
||||||
|
reasonCode: 'owner-unexpected-provider-shape',
|
||||||
|
});
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the GLPI standing remediation queue in the local estate policy', async (): Promise<void> => {
|
||||||
|
const resolver = await loadResolver('MB-REQ-09 standing process policy');
|
||||||
|
const raw = JSON.parse(ownerPolicy()) as { estates: Array<Record<string, unknown>> };
|
||||||
|
delete raw.estates[0]?.['standingProcess'];
|
||||||
|
let fetchCalls = 0;
|
||||||
|
|
||||||
|
const result = await resolver.resolveProviderDurableOwner(
|
||||||
|
{
|
||||||
|
estateRegistrySource: estateRegistry(),
|
||||||
|
ownerPolicySource: JSON.stringify(raw),
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
requestedOwner: 'user:durable-owner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetch: async (): Promise<Response> => {
|
||||||
|
fetchCalls += 1;
|
||||||
|
return publicUser('durable-owner');
|
||||||
|
},
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-policy-invalid' });
|
||||||
|
expect(fetchCalls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a provider-valid but unlisted principal before provider lookup', async (): Promise<void> => {
|
||||||
|
const resolver = await loadResolver('MB-REQ-09 provider-valid unlisted owner refusal');
|
||||||
|
const calls: Array<{ identity: string; authorization: string | null }> = [];
|
||||||
|
|
||||||
|
const result = await resolver.resolveProviderDurableOwner(
|
||||||
|
{
|
||||||
|
estateRegistrySource: estateRegistry(),
|
||||||
|
ownerPolicySource: ownerPolicy(),
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
requestedOwner: 'user:other-public-user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetch: controlledFetch({ 'other-public-user': publicUser('other-public-user') }, calls),
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-not-allowlisted' });
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['user:durable–owner', 'owner-name-invalid'],
|
||||||
|
[' user:durable-owner ', 'owner-name-invalid'],
|
||||||
|
['user:durable.owner', 'owner-not-allowlisted'],
|
||||||
|
['user:durable owner', 'owner-name-invalid'],
|
||||||
|
['user:durable-owner', 'owner-name-invalid'],
|
||||||
|
['user:be-coder-07@mission-seat', 'owner-name-invalid'],
|
||||||
|
] as const)(
|
||||||
|
'rejects non-canonical, unlisted, or transient-seat presentation %s before lookup',
|
||||||
|
async (name, reasonCode): Promise<void> => {
|
||||||
|
const resolver = await loadResolver('MB-REQ-09 owner allowlist grammar');
|
||||||
|
let fetchCalls = 0;
|
||||||
|
|
||||||
|
const result = await resolver.resolveProviderDurableOwner(
|
||||||
|
{
|
||||||
|
estateRegistrySource: estateRegistry(),
|
||||||
|
ownerPolicySource: ownerPolicy(),
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
requestedOwner: name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetch: async (): Promise<Response> => {
|
||||||
|
fetchCalls += 1;
|
||||||
|
return publicUser('durable-owner');
|
||||||
|
},
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ verdict: 'refused', reasonCode });
|
||||||
|
expect(fetchCalls).toBe(0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('fails closed as not-resolvable rather than claiming a private-or-absent owner does not exist', async (): Promise<void> => {
|
||||||
|
const resolver = await loadResolver('MB-REQ-09 private/absent ambiguity');
|
||||||
|
|
||||||
|
const result = await resolver.resolveProviderDurableOwner(
|
||||||
|
{
|
||||||
|
estateRegistrySource: estateRegistry(),
|
||||||
|
ownerPolicySource: ownerPolicy(),
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
requestedOwner: 'user:durable-owner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetch: controlledFetch({ 'durable-owner': jsonResponse(404, { message: 'hidden' }) }),
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
verdict: 'not-measured',
|
||||||
|
reasonCode: 'owner-not-resolvable',
|
||||||
|
principal: null,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['public control hidden', { 'public-control': jsonResponse(404, {}) }],
|
||||||
|
['public control login mismatch', { 'public-control': publicUser('other') }],
|
||||||
|
['private control unexpectedly public', { 'private-control': publicUser('private-control') }],
|
||||||
|
[
|
||||||
|
'generated absent control unexpectedly resolves',
|
||||||
|
{ 'generated-absent-control': publicUser('generated-absent-control') },
|
||||||
|
],
|
||||||
|
] as const)(
|
||||||
|
'makes the whole result not-measured when %s',
|
||||||
|
async (_caseName, overrides): Promise<void> => {
|
||||||
|
const resolver = await loadResolver('MB-REQ-09 owner resolver controls');
|
||||||
|
|
||||||
|
const result = await resolver.resolveProviderDurableOwner(
|
||||||
|
{
|
||||||
|
estateRegistrySource: estateRegistry(),
|
||||||
|
ownerPolicySource: ownerPolicy(),
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
requestedOwner: 'user:durable-owner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetch: controlledFetch(overrides),
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
verdict: 'not-measured',
|
||||||
|
reasonCode: 'owner-control-invalid',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||||
|
import type { MigrationOwnerResolution } from './brain-store.js';
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 256 * 1024;
|
||||||
|
const LOGIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/;
|
||||||
|
const REQUESTED_OWNER = /^user:(.+)$/;
|
||||||
|
|
||||||
|
const ownerPolicySchema = z
|
||||||
|
.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
estates: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
estate: z.string().min(1),
|
||||||
|
laneArchiveOwners: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
kind: z.literal('provider-user'),
|
||||||
|
login: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
standingProcess: z
|
||||||
|
.object({
|
||||||
|
kind: z.literal('glpi-queue'),
|
||||||
|
queue: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
controls: z
|
||||||
|
.object({
|
||||||
|
publicIdentity: z.string().min(1),
|
||||||
|
privateIdentity: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const providerUserSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
login: z.string().min(1),
|
||||||
|
visibility: z.literal('public'),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export type OwnerFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
function unresolved(reasonCode: string): MigrationOwnerResolution {
|
||||||
|
return {
|
||||||
|
verdict: 'not-measured',
|
||||||
|
reasonCode,
|
||||||
|
principal: null,
|
||||||
|
authority: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function refused(reasonCode: string): MigrationOwnerResolution {
|
||||||
|
return {
|
||||||
|
verdict: 'refused',
|
||||||
|
reasonCode,
|
||||||
|
principal: null,
|
||||||
|
authority: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactCanonicalLogin(value: string): boolean {
|
||||||
|
return value.normalize('NFKC') === value && LOGIN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boundedJson(response: Response): Promise<unknown> {
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
if (!contentType.toLowerCase().startsWith('application/json')) {
|
||||||
|
throw new Error('owner-unexpected-content-type');
|
||||||
|
}
|
||||||
|
const declared = response.headers.get('content-length');
|
||||||
|
let declaredSize: number | null = null;
|
||||||
|
if (declared !== null) {
|
||||||
|
if (!/^\d+$/.test(declared)) throw new Error('owner-unexpected-provider-shape');
|
||||||
|
declaredSize = Number.parseInt(declared, 10);
|
||||||
|
if (!Number.isSafeInteger(declaredSize) || declaredSize > MAX_BODY_BYTES) {
|
||||||
|
throw new Error('owner-unexpected-provider-shape');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (response.body === null) throw new Error('owner-unexpected-provider-shape');
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
while (true) {
|
||||||
|
const next = await reader.read();
|
||||||
|
if (next.done) break;
|
||||||
|
total += next.value.byteLength;
|
||||||
|
if (total > MAX_BODY_BYTES) {
|
||||||
|
await reader.cancel('owner response exceeds byte ceiling');
|
||||||
|
throw new Error('owner-unexpected-provider-shape');
|
||||||
|
}
|
||||||
|
chunks.push(next.value);
|
||||||
|
}
|
||||||
|
if (declaredSize !== null && declaredSize !== total) {
|
||||||
|
throw new Error('owner-unexpected-provider-shape');
|
||||||
|
}
|
||||||
|
const body = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
body.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
|
||||||
|
} catch {
|
||||||
|
throw new Error('owner-unexpected-provider-shape');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPublicIdentity(
|
||||||
|
origin: string,
|
||||||
|
identity: string,
|
||||||
|
fetchImpl: OwnerFetch,
|
||||||
|
): Promise<{ readonly status: number; readonly user: unknown }> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetchImpl(`${origin}/api/v1/users/${encodeURIComponent(identity)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'mosaic-brain-owner/1',
|
||||||
|
},
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: AbortSignal.timeout(5_000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error('owner-provider-unavailable');
|
||||||
|
}
|
||||||
|
return { status: response.status, user: await boundedJson(response) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicIdentityMatches(value: unknown, identity: string): boolean {
|
||||||
|
const parsed = providerUserSchema.safeParse(value);
|
||||||
|
return parsed.success && parsed.data.login === identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrainOwnerPolicyBinding {
|
||||||
|
readonly brainNamespace: string;
|
||||||
|
readonly publicControl: string;
|
||||||
|
readonly privateControl: string;
|
||||||
|
readonly standingQueue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBrainOwnerPolicy(
|
||||||
|
ownerPolicySource: string,
|
||||||
|
estate: string,
|
||||||
|
): BrainOwnerPolicyBinding | undefined {
|
||||||
|
let rawPolicy: unknown;
|
||||||
|
try {
|
||||||
|
rawPolicy = JSON.parse(ownerPolicySource);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const policy = ownerPolicySchema.safeParse(rawPolicy);
|
||||||
|
if (!policy.success) return undefined;
|
||||||
|
const estatePolicies = policy.data.estates.filter(
|
||||||
|
(candidate): boolean => candidate.estate === estate,
|
||||||
|
);
|
||||||
|
if (estatePolicies.length !== 1) return undefined;
|
||||||
|
const estatePolicy = estatePolicies[0];
|
||||||
|
if (estatePolicy === undefined || estatePolicy.laneArchiveOwners.length !== 1) return undefined;
|
||||||
|
const brainNamespace = estatePolicy.laneArchiveOwners[0]?.login;
|
||||||
|
if (brainNamespace === undefined || !exactCanonicalLogin(brainNamespace)) return undefined;
|
||||||
|
return {
|
||||||
|
brainNamespace,
|
||||||
|
publicControl: estatePolicy.controls.publicIdentity,
|
||||||
|
privateControl: estatePolicy.controls.privateIdentity,
|
||||||
|
standingQueue: estatePolicy.standingProcess.queue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRequestedOwner(requestedOwner: string): string | null {
|
||||||
|
if (requestedOwner.normalize('NFKC') !== requestedOwner) return null;
|
||||||
|
const match = REQUESTED_OWNER.exec(requestedOwner);
|
||||||
|
const login = match?.[1];
|
||||||
|
if (login === undefined || !exactCanonicalLogin(login)) return null;
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveProviderDurableOwner(
|
||||||
|
input: {
|
||||||
|
readonly estateRegistrySource: string;
|
||||||
|
readonly ownerPolicySource: string;
|
||||||
|
readonly host: string;
|
||||||
|
readonly requestedOwner: string;
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
readonly fetch: OwnerFetch;
|
||||||
|
readonly absentControlName: () => string;
|
||||||
|
},
|
||||||
|
): Promise<MigrationOwnerResolution> {
|
||||||
|
const requestedLogin = parseRequestedOwner(input.requestedOwner);
|
||||||
|
if (requestedLogin === null) return refused('owner-name-invalid');
|
||||||
|
|
||||||
|
const target = parseCredentialEstateRegistry(input.estateRegistrySource).resolveByHost(
|
||||||
|
input.host,
|
||||||
|
);
|
||||||
|
if (target === undefined) return refused('estate-host-unmapped');
|
||||||
|
|
||||||
|
const policy = resolveBrainOwnerPolicy(input.ownerPolicySource, target.estate);
|
||||||
|
if (policy === undefined) return refused('owner-policy-invalid');
|
||||||
|
if (policy.brainNamespace !== requestedLogin) return refused('owner-not-allowlisted');
|
||||||
|
|
||||||
|
const publicControl = policy.publicControl;
|
||||||
|
const privateControl = policy.privateControl;
|
||||||
|
const absentControl = dependencies.absentControlName();
|
||||||
|
if (
|
||||||
|
!exactCanonicalLogin(publicControl) ||
|
||||||
|
!exactCanonicalLogin(privateControl) ||
|
||||||
|
!exactCanonicalLogin(absentControl) ||
|
||||||
|
new Set([publicControl, privateControl, absentControl, requestedLogin]).size !== 4
|
||||||
|
) {
|
||||||
|
return refused('owner-policy-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const publicResult = await readPublicIdentity(
|
||||||
|
target.host.apiBaseUrl,
|
||||||
|
publicControl,
|
||||||
|
dependencies.fetch,
|
||||||
|
);
|
||||||
|
if (publicResult.status !== 200 || !publicIdentityMatches(publicResult.user, publicControl)) {
|
||||||
|
return unresolved('owner-control-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateResult = await readPublicIdentity(
|
||||||
|
target.host.apiBaseUrl,
|
||||||
|
privateControl,
|
||||||
|
dependencies.fetch,
|
||||||
|
);
|
||||||
|
if (privateResult.status !== 404) return unresolved('owner-control-invalid');
|
||||||
|
|
||||||
|
const absentResult = await readPublicIdentity(
|
||||||
|
target.host.apiBaseUrl,
|
||||||
|
absentControl,
|
||||||
|
dependencies.fetch,
|
||||||
|
);
|
||||||
|
if (absentResult.status !== 404) return unresolved('owner-control-invalid');
|
||||||
|
|
||||||
|
const ownerResult = await readPublicIdentity(
|
||||||
|
target.host.apiBaseUrl,
|
||||||
|
requestedLogin,
|
||||||
|
dependencies.fetch,
|
||||||
|
);
|
||||||
|
if (ownerResult.status === 401 || ownerResult.status === 403 || ownerResult.status === 404) {
|
||||||
|
return unresolved('owner-not-resolvable');
|
||||||
|
}
|
||||||
|
if (ownerResult.status !== 200) return unresolved('owner-provider-unavailable');
|
||||||
|
if (!publicIdentityMatches(ownerResult.user, requestedLogin)) {
|
||||||
|
return unresolved('owner-provider-identity-mismatch');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
verdict: 'resolved',
|
||||||
|
reasonCode: 'owner-verified',
|
||||||
|
principal: { name: `user:${requestedLogin}`, kind: 'durable-human' },
|
||||||
|
authority: {
|
||||||
|
system: 'gitea',
|
||||||
|
endpoint: `GET /api/v1/users/${requestedLogin}`,
|
||||||
|
contentType: 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const reason = error instanceof Error ? error.message : 'owner-provider-unavailable';
|
||||||
|
if (reason === 'owner-unexpected-content-type') return unresolved(reason);
|
||||||
|
if (reason === 'owner-unexpected-provider-shape') return unresolved(reason);
|
||||||
|
return unresolved('owner-provider-unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
interface BrainProvisionCommandModule {
|
||||||
|
readonly BRAIN_PROVISION_COMMAND: string;
|
||||||
|
registerBrainProvisionCommand(program: Command): void;
|
||||||
|
executeBrainProvisionCommand(
|
||||||
|
options: {
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly home: string;
|
||||||
|
readonly identity: string;
|
||||||
|
readonly refusalIdentity: string;
|
||||||
|
readonly targetUrl: string;
|
||||||
|
readonly owner: string;
|
||||||
|
readonly lane: string;
|
||||||
|
readonly sourceRoot?: string;
|
||||||
|
readonly brainRoot?: string;
|
||||||
|
readonly ownerPolicy?: string;
|
||||||
|
readonly registry?: string;
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
readonly run: () => never;
|
||||||
|
readonly fetch: typeof fetch;
|
||||||
|
readonly absentControlName: () => string;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||||
|
readonly reasonCode: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODULE_PATH = './brain-provision-command.js';
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
async function loadCommand(requirement: string): Promise<BrainProvisionCommandModule> {
|
||||||
|
try {
|
||||||
|
return (await import(MODULE_PATH)) as BrainProvisionCommandModule;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`${requirement}: brain provision command is absent (${detail})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tempRoot(): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-command-'));
|
||||||
|
roots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('internal P7 brain provision command', (): void => {
|
||||||
|
it('registers only explicit non-secret contract inputs and no credential/token lookup switches', async (): Promise<void> => {
|
||||||
|
const module = await loadCommand('MB-REQ-03 broker-only provision command');
|
||||||
|
const program = new Command();
|
||||||
|
module.registerBrainProvisionCommand(program);
|
||||||
|
const command = program.commands.find(
|
||||||
|
(candidate) => candidate.name() === module.BRAIN_PROVISION_COMMAND,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(command).toBeDefined();
|
||||||
|
const flags = command?.options.map((option) => option.flags) ?? [];
|
||||||
|
expect(flags.join(' ')).toContain('--identity');
|
||||||
|
expect(flags.join(' ')).toContain('--target-url');
|
||||||
|
expect(flags.join(' ')).toContain('--refusal-identity');
|
||||||
|
expect(flags.join(' ')).toContain('--owner-policy');
|
||||||
|
expect(flags.join(' ')).toContain('--owner');
|
||||||
|
expect(flags.join(' ')).toContain('--lane');
|
||||||
|
expect(flags.join(' ')).not.toMatch(/token|password|authorization|grant-authority/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is registered by the shipped CLI', async (): Promise<void> => {
|
||||||
|
const module = await loadCommand('MB-REQ-10 shipped P7 command');
|
||||||
|
const cli = readFileSync(join(process.cwd(), 'src', 'cli.ts'), 'utf8');
|
||||||
|
|
||||||
|
expect(cli).toContain('registerBrainProvisionCommand');
|
||||||
|
expect(cli).toContain(`registerBrainProvisionCommand(program)`);
|
||||||
|
expect(module.BRAIN_PROVISION_COMMAND).toBe('__brain-provision');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed before commands when the local owner policy is absent', async (): Promise<void> => {
|
||||||
|
const module = await loadCommand('MB-REQ-09 owner policy required');
|
||||||
|
const root = tempRoot();
|
||||||
|
const home = join(root, 'home');
|
||||||
|
const mosaicHome = join(home, '.config', 'mosaic');
|
||||||
|
mkdirSync(join(mosaicHome, 'cred'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(mosaicHome, 'cred', 'estates.json'),
|
||||||
|
JSON.stringify({ version: 1, estates: [] }),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
let commands = 0;
|
||||||
|
|
||||||
|
const result = await module.executeBrainProvisionCommand(
|
||||||
|
{
|
||||||
|
mosaicHome,
|
||||||
|
home,
|
||||||
|
identity: 'seat-a',
|
||||||
|
refusalIdentity: 'outside-seat',
|
||||||
|
targetUrl: 'https://git.example.invalid/example/stack.git',
|
||||||
|
owner: 'user:durable-owner',
|
||||||
|
lane: 'lane-a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
run: (): never => {
|
||||||
|
commands += 1;
|
||||||
|
throw new Error('must not run');
|
||||||
|
},
|
||||||
|
fetch,
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'failed', reasonCode: 'owner-policy-unavailable' });
|
||||||
|
expect(commands).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import { readBrainConfigSecure } from './brain-secure-config.js';
|
||||||
|
import { provisionBrain, type ProvisionResult } from './brain-provision.js';
|
||||||
|
import { systemCommandRunner, type CommandRunner } from './brain-store-runtime.js';
|
||||||
|
import type { OwnerFetch } from './brain-owner-resolver.js';
|
||||||
|
|
||||||
|
export const BRAIN_PROVISION_COMMAND = '__brain-provision';
|
||||||
|
|
||||||
|
interface BrainProvisionCommandOptions {
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly home: string;
|
||||||
|
readonly identity: string;
|
||||||
|
readonly refusalIdentity: string;
|
||||||
|
readonly targetUrl: string;
|
||||||
|
readonly owner: string;
|
||||||
|
readonly lane: string;
|
||||||
|
readonly sourceRoot?: string;
|
||||||
|
readonly brainRoot?: string;
|
||||||
|
readonly ownerPolicy?: string;
|
||||||
|
readonly registry?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrainProvisionCommandDependencies {
|
||||||
|
readonly run: CommandRunner;
|
||||||
|
readonly fetch: OwnerFetch;
|
||||||
|
readonly absentControlName: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configFailure(reasonCode: string): ProvisionResult {
|
||||||
|
return {
|
||||||
|
status: 'failed',
|
||||||
|
reasonCode,
|
||||||
|
findings: [{ code: `brain-${reasonCode}`, reasonCode }],
|
||||||
|
owner: null,
|
||||||
|
migration: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBrainProvisionCommand(
|
||||||
|
options: BrainProvisionCommandOptions,
|
||||||
|
dependencies: BrainProvisionCommandDependencies,
|
||||||
|
): Promise<ProvisionResult> {
|
||||||
|
const registry = options.registry ?? join(options.mosaicHome, 'cred', 'estates.json');
|
||||||
|
const ownerPolicy = options.ownerPolicy ?? join(options.mosaicHome, 'brain', 'owners.json');
|
||||||
|
let estateRegistrySource: string;
|
||||||
|
try {
|
||||||
|
estateRegistrySource = readBrainConfigSecure(registry, options.mosaicHome);
|
||||||
|
} catch {
|
||||||
|
return configFailure('estate-registry-unavailable');
|
||||||
|
}
|
||||||
|
let ownerPolicySource: string;
|
||||||
|
try {
|
||||||
|
ownerPolicySource = readBrainConfigSecure(ownerPolicy, options.mosaicHome);
|
||||||
|
} catch {
|
||||||
|
return configFailure('owner-policy-unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await provisionBrain(
|
||||||
|
{
|
||||||
|
estateRegistrySource,
|
||||||
|
ownerPolicySource,
|
||||||
|
targetGitUrl: options.targetUrl,
|
||||||
|
requestedOwner: options.owner,
|
||||||
|
identity: options.identity,
|
||||||
|
refusalIdentity: options.refusalIdentity,
|
||||||
|
root: options.brainRoot ?? join(options.home, '.mosaic'),
|
||||||
|
sourceRoot: options.sourceRoot ?? join(options.mosaicHome, 'memory'),
|
||||||
|
seat: options.identity,
|
||||||
|
lane: options.lane,
|
||||||
|
laneActive: false,
|
||||||
|
},
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return configFailure('brain-provision-exception');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerBrainProvisionCommand(program: Command): void {
|
||||||
|
program
|
||||||
|
.command(BRAIN_PROVISION_COMMAND, { hidden: true })
|
||||||
|
.description('Internal installer P7 durable-brain provisioner')
|
||||||
|
.requiredOption('--identity <name>', 'explicit fleet identity')
|
||||||
|
.requiredOption('--target-url <url>', 'configured target git URL')
|
||||||
|
.requiredOption('--refusal-identity <name>', 'explicit out-of-estate negative control')
|
||||||
|
.requiredOption('--owner <owner>', 'policy-bound durable owner candidate')
|
||||||
|
.requiredOption('--lane <name>', 'source lane to migrate')
|
||||||
|
.option('--mosaic-home <path>', 'installed Mosaic home')
|
||||||
|
.option('--home <path>', 'principal home')
|
||||||
|
.option('--source-root <path>', 'legacy memory root')
|
||||||
|
.option('--brain-root <path>', 'per-estate brain checkout root')
|
||||||
|
.option('--owner-policy <path>', 'durable-owner allowlist policy')
|
||||||
|
.option('--registry <path>', 'estate registry path')
|
||||||
|
.action(async (raw: Record<string, string | undefined>): Promise<void> => {
|
||||||
|
const home = raw['home'] ?? homedir();
|
||||||
|
const mosaicHome =
|
||||||
|
raw['mosaicHome'] ?? process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic');
|
||||||
|
const result = await executeBrainProvisionCommand(
|
||||||
|
{
|
||||||
|
mosaicHome,
|
||||||
|
home,
|
||||||
|
identity: raw['identity']!,
|
||||||
|
targetUrl: raw['targetUrl']!,
|
||||||
|
refusalIdentity: raw['refusalIdentity']!,
|
||||||
|
owner: raw['owner']!,
|
||||||
|
lane: raw['lane']!,
|
||||||
|
...(raw['sourceRoot'] === undefined ? {} : { sourceRoot: raw['sourceRoot'] }),
|
||||||
|
...(raw['brainRoot'] === undefined ? {} : { brainRoot: raw['brainRoot'] }),
|
||||||
|
...(raw['ownerPolicy'] === undefined ? {} : { ownerPolicy: raw['ownerPolicy'] }),
|
||||||
|
...(raw['registry'] === undefined ? {} : { registry: raw['registry'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
run: systemCommandRunner,
|
||||||
|
fetch,
|
||||||
|
absentControlName: (): string => `mosaic-absent-${randomUUID()}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify({
|
||||||
|
status: result.status,
|
||||||
|
reasonCode: result.reasonCode,
|
||||||
|
findings: result.findings,
|
||||||
|
owner: result.owner,
|
||||||
|
migration: result.migration,
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
if (result.status !== 'provisioned') process.exitCode = result.status === 'blocked' ? 30 : 20;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
interface CommandRequest {
|
||||||
|
readonly program: 'git' | 'mosaic';
|
||||||
|
readonly args: readonly string[];
|
||||||
|
readonly env: Readonly<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandResult {
|
||||||
|
readonly status: number;
|
||||||
|
readonly stdout: string;
|
||||||
|
readonly stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandRunner = (request: CommandRequest) => CommandResult;
|
||||||
|
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
interface ProvisionResult {
|
||||||
|
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||||
|
readonly reasonCode: string;
|
||||||
|
readonly findings: readonly { code: string; reasonCode: string | null }[];
|
||||||
|
readonly owner: {
|
||||||
|
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
||||||
|
readonly reasonCode: string;
|
||||||
|
} | null;
|
||||||
|
readonly migration: {
|
||||||
|
readonly status: 'migrated' | 'reported' | 'failed';
|
||||||
|
readonly reported: readonly { path: string; reason: string }[];
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProvisionModule {
|
||||||
|
provisionBrain(
|
||||||
|
input: {
|
||||||
|
readonly estateRegistrySource: string;
|
||||||
|
readonly ownerPolicySource: string;
|
||||||
|
readonly targetGitUrl: string;
|
||||||
|
readonly requestedOwner: string;
|
||||||
|
readonly identity: string;
|
||||||
|
readonly refusalIdentity: string;
|
||||||
|
readonly root: string;
|
||||||
|
readonly sourceRoot: string;
|
||||||
|
readonly seat: string;
|
||||||
|
readonly lane: string;
|
||||||
|
readonly laneActive: boolean;
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
readonly run: CommandRunner;
|
||||||
|
readonly fetch: FetchLike;
|
||||||
|
readonly absentControlName: () => string;
|
||||||
|
readonly approveMigrationContent?: (path: string, content: Uint8Array) => boolean;
|
||||||
|
},
|
||||||
|
): Promise<ProvisionResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODULE_PATH = './brain-provision.js';
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
async function loadProvisioner(requirement: string): Promise<ProvisionModule> {
|
||||||
|
try {
|
||||||
|
return (await import(MODULE_PATH)) as ProvisionModule;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`${requirement}: brain provisioner is absent (${detail})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tempRoot(): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-provision-'));
|
||||||
|
roots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estateRegistry(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
estates: [
|
||||||
|
{
|
||||||
|
name: 'homelab',
|
||||||
|
readOnlyControlIdentity: 'read-control',
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
provider: 'gitea',
|
||||||
|
apiBaseUrl: 'https://git.example.invalid',
|
||||||
|
tokenPrefix: 'gitea-example',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerPolicy(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
estates: [
|
||||||
|
{
|
||||||
|
estate: 'homelab',
|
||||||
|
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||||
|
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||||
|
controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateResult(
|
||||||
|
outcome: 'ok' | 'refused' | 'indeterminate',
|
||||||
|
reasonCode: string,
|
||||||
|
identity = 'seat-a',
|
||||||
|
): string {
|
||||||
|
const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30;
|
||||||
|
return JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'validate',
|
||||||
|
outcome,
|
||||||
|
exitCode,
|
||||||
|
retryable: false,
|
||||||
|
subject: {
|
||||||
|
identity,
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
repo: 'durable-owner/mosaic-brain',
|
||||||
|
},
|
||||||
|
mutation: 'none',
|
||||||
|
reason: { code: reasonCode, message: 'non-secret' },
|
||||||
|
evidence: {
|
||||||
|
providerIdentity:
|
||||||
|
outcome === 'ok'
|
||||||
|
? {
|
||||||
|
login: identity,
|
||||||
|
endpoint: 'GET /api/v1/user',
|
||||||
|
contentType: 'application/json',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
repositoryPermission:
|
||||||
|
outcome === 'ok'
|
||||||
|
? {
|
||||||
|
requested: 'write',
|
||||||
|
effective: 'write',
|
||||||
|
endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain',
|
||||||
|
contentType: 'application/json',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
writeDifferential:
|
||||||
|
outcome === 'ok'
|
||||||
|
? {
|
||||||
|
state: 'can-write',
|
||||||
|
credentialBinding: 'same-resolution',
|
||||||
|
transportPrincipal: identity,
|
||||||
|
authenticatedReceivePack: 'advertised',
|
||||||
|
readOnlyControl: {
|
||||||
|
identity: 'read-control',
|
||||||
|
providerPermission: 'read',
|
||||||
|
receivePack: 'refused',
|
||||||
|
},
|
||||||
|
unauthenticatedReceivePack: 'refused',
|
||||||
|
artifactCreated: false,
|
||||||
|
proves: 'non-secret evidence',
|
||||||
|
doesNotProve: 'branch update acceptance',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
audit: { journalId: 'opaque', state: 'sealed' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicUser(login: string): Response {
|
||||||
|
return new Response(JSON.stringify({ id: 1, login, visibility: 'public', active: false }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerFetch(ownerStatus = 200): FetchLike {
|
||||||
|
return async (input): Promise<Response> => {
|
||||||
|
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
const identity = decodeURIComponent(new URL(raw).pathname.split('/').at(-1) ?? '');
|
||||||
|
if (identity === 'public-control') return publicUser(identity);
|
||||||
|
if (identity === 'private-control' || identity === 'generated-absent-control') {
|
||||||
|
return new Response(JSON.stringify({ message: 'hidden or absent' }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (identity === 'durable-owner' && ownerStatus === 200) return publicUser(identity);
|
||||||
|
return new Response(JSON.stringify({ message: 'hidden or absent' }), {
|
||||||
|
status: ownerStatus,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseInput(root: string): {
|
||||||
|
readonly estateRegistrySource: string;
|
||||||
|
readonly ownerPolicySource: string;
|
||||||
|
readonly targetGitUrl: string;
|
||||||
|
readonly requestedOwner: string;
|
||||||
|
readonly identity: string;
|
||||||
|
readonly refusalIdentity: string;
|
||||||
|
readonly root: string;
|
||||||
|
readonly sourceRoot: string;
|
||||||
|
readonly seat: string;
|
||||||
|
readonly lane: string;
|
||||||
|
readonly laneActive: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
estateRegistrySource: estateRegistry(),
|
||||||
|
ownerPolicySource: ownerPolicy(),
|
||||||
|
targetGitUrl: 'https://git.example.invalid/example/stack.git',
|
||||||
|
requestedOwner: 'user:durable-owner',
|
||||||
|
identity: 'seat-a',
|
||||||
|
refusalIdentity: 'outside-seat',
|
||||||
|
root: join(root, 'brain'),
|
||||||
|
sourceRoot: join(root, 'local-memory'),
|
||||||
|
seat: 'seat-a',
|
||||||
|
lane: 'lane-a',
|
||||||
|
laneActive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('P7 brain provisioning orchestration', (): void => {
|
||||||
|
it('requires the P5 write-capability postcondition and never grants or clones on refusal', async (): Promise<void> => {
|
||||||
|
const provisioner = await loadProvisioner('MB-REQ-10 P5 before P7');
|
||||||
|
const root = tempRoot();
|
||||||
|
const requests: CommandRequest[] = [];
|
||||||
|
|
||||||
|
const result = await provisioner.provisionBrain(baseInput(root), {
|
||||||
|
run: (request): CommandResult => {
|
||||||
|
requests.push(request);
|
||||||
|
return {
|
||||||
|
status: 10,
|
||||||
|
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||||
|
stderr: 'refused reason=no-token-for-identity',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
fetch: ownerFetch(),
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'blocked',
|
||||||
|
reasonCode: 'credential-postcondition-failed',
|
||||||
|
});
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0]?.program).toBe('mosaic');
|
||||||
|
expect(requests[0]?.args.slice(0, 3)).toEqual(['cred', 'validate', 'seat-a']);
|
||||||
|
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||||
|
expect(requests.some((request) => request.args.includes('clone'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks before clone when the out-of-estate Git and API axes disagree', async (): Promise<void> => {
|
||||||
|
const provisioner = await loadProvisioner('MB-REQ-05 P7 refusal control gate');
|
||||||
|
const root = tempRoot();
|
||||||
|
const requests: CommandRequest[] = [];
|
||||||
|
|
||||||
|
const result = await provisioner.provisionBrain(baseInput(root), {
|
||||||
|
run: (request): CommandResult => {
|
||||||
|
requests.push(request);
|
||||||
|
if (request.program === 'mosaic' && request.args[2] === 'outside-seat') {
|
||||||
|
return {
|
||||||
|
status: 10,
|
||||||
|
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||||
|
stderr: 'refused reason=no-token-for-identity',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.program === 'mosaic') {
|
||||||
|
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||||
|
}
|
||||||
|
if (request.args.includes('ls-remote')) {
|
||||||
|
return { status: 0, stdout: 'refs are visible', stderr: '' };
|
||||||
|
}
|
||||||
|
return { status: 99, stdout: '', stderr: 'unexpected command' };
|
||||||
|
},
|
||||||
|
fetch: ownerFetch(),
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'blocked',
|
||||||
|
reasonCode: 'refusal-control-failed',
|
||||||
|
});
|
||||||
|
expect(requests.some((request) => request.args.includes('clone'))).toBe(false);
|
||||||
|
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks before skeleton publication when the existing checkout is dirty', async (): Promise<void> => {
|
||||||
|
const provisioner = await loadProvisioner('MB-REQ-06 dirty checkout publication gate');
|
||||||
|
const root = tempRoot();
|
||||||
|
const input = baseInput(root);
|
||||||
|
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||||
|
const requests: CommandRequest[] = [];
|
||||||
|
|
||||||
|
const result = await provisioner.provisionBrain(input, {
|
||||||
|
run: (request): CommandResult => {
|
||||||
|
requests.push(request);
|
||||||
|
if (request.program === 'mosaic') {
|
||||||
|
return request.args[2] === 'outside-seat'
|
||||||
|
? {
|
||||||
|
status: 10,
|
||||||
|
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||||
|
stderr: 'refused reason=no-token-for-identity',
|
||||||
|
}
|
||||||
|
: { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||||
|
}
|
||||||
|
const command = request.args.join(' ');
|
||||||
|
if (command.includes('ls-remote')) {
|
||||||
|
return {
|
||||||
|
status: 128,
|
||||||
|
stdout: '',
|
||||||
|
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||||
|
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('remote get-url origin')) {
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command.includes('branch --show-current')) {
|
||||||
|
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('status --porcelain')) {
|
||||||
|
return { status: 0, stdout: '?? .gitignore\n', stderr: '' };
|
||||||
|
}
|
||||||
|
return { status: 99, stdout: '', stderr: 'unexpected publication command' };
|
||||||
|
},
|
||||||
|
fetch: ownerFetch(),
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'blocked',
|
||||||
|
reasonCode: 'brain-postcondition-failed',
|
||||||
|
});
|
||||||
|
expect(requests.some((request) => request.args.includes('commit'))).toBe(false);
|
||||||
|
expect(requests.some((request) => request.args.includes('push'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clones, seeds, resolves owner, migrates, pushes on each write, and archives source only after reachability', async (): Promise<void> => {
|
||||||
|
const provisioner = await loadProvisioner('MB-REQ-07 complete migration transaction');
|
||||||
|
const root = tempRoot();
|
||||||
|
const input = baseInput(root);
|
||||||
|
mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||||
|
const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||||
|
writeFileSync(source, 'durable finding\n');
|
||||||
|
const requests: CommandRequest[] = [];
|
||||||
|
let commitOrdinal = 0;
|
||||||
|
let approvedPaths: string[] = [];
|
||||||
|
let privateAtClone = false;
|
||||||
|
const runner: CommandRunner = (request): CommandResult => {
|
||||||
|
requests.push(request);
|
||||||
|
if (request.program === 'mosaic') {
|
||||||
|
if (request.args[2] === 'outside-seat') {
|
||||||
|
return {
|
||||||
|
status: 10,
|
||||||
|
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||||
|
stderr: 'refused reason=no-token-for-identity',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||||
|
}
|
||||||
|
const command = request.args.join(' ');
|
||||||
|
if (command.includes('ls-remote')) {
|
||||||
|
return {
|
||||||
|
status: 128,
|
||||||
|
stdout: '',
|
||||||
|
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.args[0] === 'clone') {
|
||||||
|
privateAtClone = existsSync(input.root) && (lstatSync(input.root).mode & 0o077) === 0;
|
||||||
|
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('read-tree')) {
|
||||||
|
approvedPaths = [];
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('hash-object')) {
|
||||||
|
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('update-index')) {
|
||||||
|
const path = request.args.at(-1);
|
||||||
|
if (path !== undefined) approvedPaths.push(path);
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) {
|
||||||
|
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('diff --cached --quiet')) {
|
||||||
|
return { status: 1, stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('diff-tree')) {
|
||||||
|
return { status: 0, stdout: `${approvedPaths.join('\0')}\0`, stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('show -s')) {
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout: 'seat-a\[email protected]\0seat-a\[email protected]\n',
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||||
|
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('remote get-url origin')) {
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command.includes('branch --show-current')) {
|
||||||
|
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('status --porcelain')) {
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse HEAD')) {
|
||||||
|
commitOrdinal += 1;
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout: `${commitOrdinal === 1 ? 'a' : 'c'.repeat(1)}`.repeat(40) + '\n',
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse origin/main')) {
|
||||||
|
const value = commitOrdinal === 1 ? 'b' : 'd';
|
||||||
|
return { status: 0, stdout: `${value.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await provisioner.provisionBrain(input, {
|
||||||
|
run: runner,
|
||||||
|
fetch: ownerFetch(),
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
approveMigrationContent: (): boolean => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'provisioned',
|
||||||
|
reasonCode: 'brain-provisioned',
|
||||||
|
owner: { verdict: 'resolved', reasonCode: 'owner-verified' },
|
||||||
|
migration: { status: 'reported' },
|
||||||
|
});
|
||||||
|
expect(privateAtClone).toBe(true);
|
||||||
|
expect(existsSync(source)).toBe(true);
|
||||||
|
const imported = result.migration?.reported ?? [];
|
||||||
|
expect(imported).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ path: source, reason: expect.stringMatching(/retained/i) }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const laneImports = join(input.root, 'lanes', 'lane-a', 'findings', 'imports');
|
||||||
|
const archiveImports = join(input.root, 'archives', 'imports', 'lane');
|
||||||
|
expect(existsSync(laneImports)).toBe(true);
|
||||||
|
expect(existsSync(archiveImports)).toBe(true);
|
||||||
|
expect(
|
||||||
|
requests.filter((request) => request.program === 'git' && request.args.includes('push')),
|
||||||
|
).toHaveLength(2);
|
||||||
|
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every source and reports the owner ambiguity when the public owner cannot be resolved', async (): Promise<void> => {
|
||||||
|
const provisioner = await loadProvisioner('MB-REQ-07 owner-blocked detection/reporting');
|
||||||
|
const root = tempRoot();
|
||||||
|
const input = baseInput(root);
|
||||||
|
mkdirSync(input.root, { recursive: true });
|
||||||
|
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||||
|
mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||||
|
const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||||
|
writeFileSync(source, 'retain me\n');
|
||||||
|
let commitOrdinal = 0;
|
||||||
|
|
||||||
|
const result = await provisioner.provisionBrain(input, {
|
||||||
|
run: (request): CommandResult => {
|
||||||
|
if (request.program === 'mosaic') {
|
||||||
|
if (request.args[2] === 'outside-seat') {
|
||||||
|
return {
|
||||||
|
status: 10,
|
||||||
|
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||||
|
stderr: 'refused reason=no-token-for-identity',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||||
|
}
|
||||||
|
const command = request.args.join(' ');
|
||||||
|
if (command.includes('ls-remote')) {
|
||||||
|
return {
|
||||||
|
status: 128,
|
||||||
|
stdout: '',
|
||||||
|
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||||
|
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('remote get-url origin')) {
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command.includes('branch --show-current')) {
|
||||||
|
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('status --porcelain')) {
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse HEAD')) {
|
||||||
|
commitOrdinal += 1;
|
||||||
|
return { status: 0, stdout: `${'a'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse origin/main')) {
|
||||||
|
return { status: 0, stdout: `${'b'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
},
|
||||||
|
fetch: ownerFetch(404),
|
||||||
|
absentControlName: (): string => 'generated-absent-control',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'blocked',
|
||||||
|
reasonCode: 'owner-not-resolvable',
|
||||||
|
owner: { verdict: 'not-measured', reasonCode: 'owner-not-resolvable' },
|
||||||
|
migration: { status: 'reported' },
|
||||||
|
});
|
||||||
|
expect(readFileSync(source, 'utf8')).toBe('retain me\n');
|
||||||
|
expect(result.migration?.reported).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ path: source, reason: expect.stringMatching(/owner/i) }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/);
|
||||||
|
expect(commitOrdinal).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { parseRequestedOwner, resolveProviderDurableOwner } from './brain-owner-resolver.js';
|
||||||
|
import {
|
||||||
|
createBrainSkeleton,
|
||||||
|
deriveBrainTarget,
|
||||||
|
discoverBrainMigration,
|
||||||
|
ensureBrainRootPrivate,
|
||||||
|
migrateBrainState,
|
||||||
|
type MigrationResult,
|
||||||
|
type MigrationOwnerResolution,
|
||||||
|
type MigrationPublishEntry,
|
||||||
|
} from './brain-store.js';
|
||||||
|
import {
|
||||||
|
collectBrainDoctorReport,
|
||||||
|
collectBrainRefusalControl,
|
||||||
|
publishBrainPaths,
|
||||||
|
type CommandRequest,
|
||||||
|
type CommandResult,
|
||||||
|
type CommandRunner,
|
||||||
|
} from './brain-store-runtime.js';
|
||||||
|
import type { OwnerFetch } from './brain-owner-resolver.js';
|
||||||
|
|
||||||
|
export interface ProvisionResult {
|
||||||
|
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||||
|
readonly reasonCode: string;
|
||||||
|
readonly findings: readonly {
|
||||||
|
readonly code: string;
|
||||||
|
readonly reasonCode: string | null;
|
||||||
|
}[];
|
||||||
|
readonly owner: Pick<MigrationOwnerResolution, 'verdict' | 'reasonCode'> | null;
|
||||||
|
readonly migration: Pick<MigrationResult, 'status' | 'reported'> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandEnv(identity: string): Readonly<Record<string, string>> {
|
||||||
|
return { MOSAIC_GIT_IDENTITY: identity, GIT_TERMINAL_PROMPT: '0' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingView(
|
||||||
|
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||||
|
): readonly { readonly code: string; readonly reasonCode: string | null }[] {
|
||||||
|
return findings.map((finding): { readonly code: string; readonly reasonCode: string | null } => ({
|
||||||
|
code: finding.code,
|
||||||
|
reasonCode: finding.reasonCode,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocked(
|
||||||
|
reasonCode: string,
|
||||||
|
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||||
|
owner: MigrationOwnerResolution | null = null,
|
||||||
|
migration: MigrationResult | null = null,
|
||||||
|
): ProvisionResult {
|
||||||
|
return {
|
||||||
|
status: 'blocked',
|
||||||
|
reasonCode,
|
||||||
|
findings: findingView(findings),
|
||||||
|
owner: owner === null ? null : { verdict: owner.verdict, reasonCode: owner.reasonCode },
|
||||||
|
migration:
|
||||||
|
migration === null ? null : { status: migration.status, reported: migration.reported },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function failed(
|
||||||
|
reasonCode: string,
|
||||||
|
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||||
|
owner: MigrationOwnerResolution | null = null,
|
||||||
|
migration: MigrationResult | null = null,
|
||||||
|
): ProvisionResult {
|
||||||
|
return {
|
||||||
|
...blocked(reasonCode, findings, owner, migration),
|
||||||
|
status: 'failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function provisionBrain(
|
||||||
|
input: {
|
||||||
|
readonly estateRegistrySource: string;
|
||||||
|
readonly ownerPolicySource: string;
|
||||||
|
readonly targetGitUrl: string;
|
||||||
|
readonly requestedOwner: string;
|
||||||
|
readonly identity: string;
|
||||||
|
readonly refusalIdentity: string;
|
||||||
|
readonly root: string;
|
||||||
|
readonly sourceRoot: string;
|
||||||
|
readonly seat: string;
|
||||||
|
readonly lane: string;
|
||||||
|
readonly laneActive: boolean;
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
readonly run: CommandRunner;
|
||||||
|
readonly fetch: OwnerFetch;
|
||||||
|
readonly absentControlName: () => string;
|
||||||
|
readonly approveMigrationContent?: (path: string, content: Uint8Array) => boolean;
|
||||||
|
},
|
||||||
|
): Promise<ProvisionResult> {
|
||||||
|
const brainNamespace = parseRequestedOwner(input.requestedOwner);
|
||||||
|
if (brainNamespace === null) return blocked('owner-name-invalid', []);
|
||||||
|
const target = deriveBrainTarget(input.estateRegistrySource, input.targetGitUrl, brainNamespace);
|
||||||
|
if (existsSync(input.root)) {
|
||||||
|
try {
|
||||||
|
ensureBrainRootPrivate(input.root);
|
||||||
|
} catch {
|
||||||
|
return blocked('brain-root-permissions-unsafe', []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const doctorInput = {
|
||||||
|
registrySource: input.estateRegistrySource,
|
||||||
|
targetGitUrl: input.targetGitUrl,
|
||||||
|
brainNamespace,
|
||||||
|
identity: input.identity,
|
||||||
|
root: input.root,
|
||||||
|
};
|
||||||
|
let report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||||
|
if (report.access.outcome !== 'ok') {
|
||||||
|
return blocked('credential-postcondition-failed', report.findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refusalControl = collectBrainRefusalControl(
|
||||||
|
{
|
||||||
|
registrySource: input.estateRegistrySource,
|
||||||
|
targetGitUrl: input.targetGitUrl,
|
||||||
|
brainNamespace,
|
||||||
|
refusalIdentity: input.refusalIdentity,
|
||||||
|
},
|
||||||
|
dependencies.run,
|
||||||
|
);
|
||||||
|
if (!refusalControl.observed) {
|
||||||
|
return blocked('refusal-control-failed', [
|
||||||
|
...report.findings,
|
||||||
|
{
|
||||||
|
code: 'brain-refusal-control-indeterminate',
|
||||||
|
reasonCode: refusalControl.reasonCode,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = await resolveProviderDurableOwner(
|
||||||
|
{
|
||||||
|
estateRegistrySource: input.estateRegistrySource,
|
||||||
|
ownerPolicySource: input.ownerPolicySource,
|
||||||
|
host: target.host,
|
||||||
|
requestedOwner: input.requestedOwner,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetch: dependencies.fetch,
|
||||||
|
absentControlName: dependencies.absentControlName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (owner.verdict !== 'resolved') {
|
||||||
|
const plan = discoverBrainMigration(
|
||||||
|
{
|
||||||
|
sourceRoot: input.sourceRoot,
|
||||||
|
brainRoot: input.root,
|
||||||
|
seat: input.seat,
|
||||||
|
lane: input.lane,
|
||||||
|
laneActive: input.laneActive,
|
||||||
|
},
|
||||||
|
(): MigrationOwnerResolution => owner,
|
||||||
|
);
|
||||||
|
const migration = migrateBrainState(
|
||||||
|
plan,
|
||||||
|
(): never => {
|
||||||
|
throw new Error('blocked owner cannot publish');
|
||||||
|
},
|
||||||
|
input.root,
|
||||||
|
);
|
||||||
|
return blocked(owner.reasonCode, report.findings, owner, migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.findings.some((finding): boolean => finding.code === 'brain-clone-missing')) {
|
||||||
|
try {
|
||||||
|
ensureBrainRootPrivate(input.root);
|
||||||
|
} catch {
|
||||||
|
return failed('brain-root-permissions-unsafe', report.findings);
|
||||||
|
}
|
||||||
|
const clone: CommandRequest = {
|
||||||
|
program: 'git',
|
||||||
|
args: ['clone', '--branch', 'main', '--single-branch', target.cloneUrl, input.root],
|
||||||
|
env: commandEnv(input.identity),
|
||||||
|
};
|
||||||
|
const cloneResult: CommandResult = dependencies.run(clone);
|
||||||
|
if (cloneResult.status !== 0) return failed('brain-clone-failed', report.findings);
|
||||||
|
try {
|
||||||
|
ensureBrainRootPrivate(input.root);
|
||||||
|
} catch {
|
||||||
|
return failed('brain-root-permissions-unsafe', report.findings);
|
||||||
|
}
|
||||||
|
report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockingCloneFindings = report.findings.filter(
|
||||||
|
(finding): boolean =>
|
||||||
|
finding.code === 'brain-clone-missing' ||
|
||||||
|
finding.code === 'brain-not-git-repository' ||
|
||||||
|
finding.code === 'brain-remote-mismatch' ||
|
||||||
|
finding.code === 'brain-branch-mismatch' ||
|
||||||
|
finding.code === 'brain-uncommitted-state' ||
|
||||||
|
finding.code === 'brain-git-state-indeterminate' ||
|
||||||
|
finding.code === 'brain-root-permissions-unsafe' ||
|
||||||
|
finding.code.startsWith('brain-write-access-'),
|
||||||
|
);
|
||||||
|
if (blockingCloneFindings.length > 0) {
|
||||||
|
return blocked('brain-postcondition-failed', report.findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
let skeletonEntries: readonly MigrationPublishEntry[];
|
||||||
|
try {
|
||||||
|
const skeleton = createBrainSkeleton(input.root);
|
||||||
|
skeletonEntries = skeleton.publicationEntries;
|
||||||
|
} catch {
|
||||||
|
return failed('brain-skeleton-failed', report.findings);
|
||||||
|
}
|
||||||
|
if (skeletonEntries.length > 0) {
|
||||||
|
try {
|
||||||
|
const evidence = publishBrainPaths(
|
||||||
|
{
|
||||||
|
root: input.root,
|
||||||
|
identity: input.identity,
|
||||||
|
entries: skeletonEntries,
|
||||||
|
message: 'chore: seed durable brain layout',
|
||||||
|
},
|
||||||
|
dependencies.run,
|
||||||
|
);
|
||||||
|
if (!evidence.reachable) return failed('brain-skeleton-not-reachable', report.findings);
|
||||||
|
} catch {
|
||||||
|
return failed('brain-skeleton-publish-failed', report.findings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = discoverBrainMigration(
|
||||||
|
{
|
||||||
|
sourceRoot: input.sourceRoot,
|
||||||
|
brainRoot: input.root,
|
||||||
|
seat: input.seat,
|
||||||
|
lane: input.lane,
|
||||||
|
laneActive: input.laneActive,
|
||||||
|
},
|
||||||
|
(): MigrationOwnerResolution => owner,
|
||||||
|
dependencies.approveMigrationContent,
|
||||||
|
);
|
||||||
|
const migration = migrateBrainState(
|
||||||
|
plan,
|
||||||
|
(brainRoot: string, entries: readonly MigrationPublishEntry[]) =>
|
||||||
|
publishBrainPaths(
|
||||||
|
{
|
||||||
|
root: brainRoot,
|
||||||
|
identity: input.identity,
|
||||||
|
entries,
|
||||||
|
message: `migrate: archive ${input.lane} working memory`,
|
||||||
|
},
|
||||||
|
dependencies.run,
|
||||||
|
),
|
||||||
|
input.root,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (migration.status === 'failed') {
|
||||||
|
return failed('brain-migration-publish-failed', report.findings, owner, migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||||
|
if (report.findings.length > 0) {
|
||||||
|
return blocked('brain-final-postcondition-failed', report.findings, owner, migration);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'provisioned',
|
||||||
|
reasonCode: 'brain-provisioned',
|
||||||
|
findings: [],
|
||||||
|
owner: { verdict: owner.verdict, reasonCode: owner.reasonCode },
|
||||||
|
migration: { status: migration.status, reported: migration.reported },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
interface SecureConfigModule {
|
||||||
|
readBrainConfigSecure(path: string, root: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
async function loadSecureConfig(): Promise<SecureConfigModule> {
|
||||||
|
try {
|
||||||
|
return (await import('./brain-secure-config.js')) as SecureConfigModule;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`MB-REQ-06 secure brain config reader is absent (${detail})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(): { readonly root: string; readonly directory: string; readonly file: string } {
|
||||||
|
const outer = mkdtempSync(join(tmpdir(), 'mosaic-brain-secure-config-'));
|
||||||
|
roots.push(outer);
|
||||||
|
const root = join(outer, 'mosaic');
|
||||||
|
const directory = join(root, 'brain');
|
||||||
|
const file = join(directory, 'owners.json');
|
||||||
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
|
writeFileSync(file, '{"version":1}\n', { mode: 0o600 });
|
||||||
|
return { root, directory, file };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('security-critical brain configuration reads', (): void => {
|
||||||
|
it('reads a principal-owned non-writable regular file through the secure descriptor path', async (): Promise<void> => {
|
||||||
|
const secure = await loadSecureConfig();
|
||||||
|
const config = fixture();
|
||||||
|
|
||||||
|
expect(secure.readBrainConfigSecure(config.file, config.root)).toBe('{"version":1}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a managed root owned by a UID other than the running principal', async (): Promise<void> => {
|
||||||
|
const secure = await loadSecureConfig();
|
||||||
|
const config = fixture();
|
||||||
|
if (typeof process.getuid !== 'function') throw new Error('test requires POSIX getuid');
|
||||||
|
const processWithUid = process as typeof process & { getuid: () => number };
|
||||||
|
const actualUid = processWithUid.getuid();
|
||||||
|
vi.spyOn(processWithUid, 'getuid').mockReturnValue(actualUid + 1);
|
||||||
|
|
||||||
|
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
|
||||||
|
/config-ancestor-owner-unsafe/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a group/world-writable policy file', async (): Promise<void> => {
|
||||||
|
const secure = await loadSecureConfig();
|
||||||
|
const config = fixture();
|
||||||
|
chmodSync(config.file, 0o666);
|
||||||
|
|
||||||
|
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
|
||||||
|
/config-file-permissions-unsafe/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a group/world-writable managed ancestor', async (): Promise<void> => {
|
||||||
|
const secure = await loadSecureConfig();
|
||||||
|
const config = fixture();
|
||||||
|
chmodSync(config.directory, 0o777);
|
||||||
|
|
||||||
|
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
|
||||||
|
/config-ancestor-permissions-unsafe/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { lstatSync } from 'node:fs';
|
||||||
|
import { dirname, relative, resolve, sep } from 'node:path';
|
||||||
|
import { assertCanonicalContainment, readRegularFileSecure } from '../fleet/secure-file.js';
|
||||||
|
|
||||||
|
const MAX_CONFIG_BYTES = 256 * 1024;
|
||||||
|
const GROUP_OR_OTHER_WRITE = 0o022;
|
||||||
|
|
||||||
|
function currentUid(): number {
|
||||||
|
if (typeof process.getuid !== 'function') {
|
||||||
|
throw new Error('config-owner-check-unsupported');
|
||||||
|
}
|
||||||
|
return process.getuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertOwnedNonWritableDirectory(path: string, uid: number): void {
|
||||||
|
const status = lstatSync(path);
|
||||||
|
if (!status.isDirectory() || status.isSymbolicLink() || status.uid !== uid) {
|
||||||
|
throw new Error('config-ancestor-owner-unsafe');
|
||||||
|
}
|
||||||
|
if ((status.mode & GROUP_OR_OTHER_WRITE) !== 0) {
|
||||||
|
throw new Error('config-ancestor-permissions-unsafe');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBrainConfigSecure(path: string, root: string): string {
|
||||||
|
const canonicalRoot = resolve(root);
|
||||||
|
const canonicalPath = resolve(path);
|
||||||
|
assertCanonicalContainment(canonicalRoot, canonicalPath);
|
||||||
|
const uid = currentUid();
|
||||||
|
assertOwnedNonWritableDirectory(canonicalRoot, uid);
|
||||||
|
let cursor = canonicalRoot;
|
||||||
|
for (const component of relative(canonicalRoot, dirname(canonicalPath))
|
||||||
|
.split(sep)
|
||||||
|
.filter(Boolean)) {
|
||||||
|
cursor = resolve(cursor, component);
|
||||||
|
assertOwnedNonWritableDirectory(cursor, uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = readRegularFileSecure(canonicalPath, {
|
||||||
|
root: canonicalRoot,
|
||||||
|
maxBytes: MAX_CONFIG_BYTES,
|
||||||
|
});
|
||||||
|
if (snapshot.uid !== uid) throw new Error('config-file-owner-unsafe');
|
||||||
|
if ((snapshot.mode & GROUP_OR_OTHER_WRITE) !== 0) {
|
||||||
|
throw new Error('config-file-permissions-unsafe');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(snapshot.content);
|
||||||
|
} catch {
|
||||||
|
throw new Error('config-file-not-utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,569 @@
|
|||||||
|
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||||
|
import {
|
||||||
|
assessCredentialResult,
|
||||||
|
brainRootIsPrivate,
|
||||||
|
deriveBrainTarget,
|
||||||
|
ensureBrainRootPrivate,
|
||||||
|
evaluateBrainDoctor,
|
||||||
|
planBrainDoctorFix,
|
||||||
|
type BrainDoctorFinding,
|
||||||
|
type BrainDoctorObservation,
|
||||||
|
type CredentialAssessment,
|
||||||
|
} from './brain-store.js';
|
||||||
|
|
||||||
|
const COMMIT = /^[0-9a-f]{40}$/;
|
||||||
|
const GIT_OBJECT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
||||||
|
const MAX_PUBLISH_ENTRY_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
export interface CommandRequest {
|
||||||
|
readonly program: 'git' | 'mosaic';
|
||||||
|
readonly args: readonly string[];
|
||||||
|
readonly cwd?: string;
|
||||||
|
readonly env: Readonly<Record<string, string>>;
|
||||||
|
readonly stdin?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandResult {
|
||||||
|
readonly status: number;
|
||||||
|
readonly stdout: string;
|
||||||
|
readonly stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandRunner = (request: CommandRequest) => CommandResult;
|
||||||
|
|
||||||
|
export const systemCommandRunner: CommandRunner = (request: CommandRequest): CommandResult => {
|
||||||
|
const result = spawnSync(request.program, request.args, {
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: { ...process.env, ...request.env },
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
input: request.stdin,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: result.status ?? 127,
|
||||||
|
stdout: result.stdout ?? '',
|
||||||
|
stderr: result.stderr ?? result.error?.message ?? '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DoctorRuntimeReport {
|
||||||
|
readonly findings: readonly BrainDoctorFinding[];
|
||||||
|
readonly access: CredentialAssessment;
|
||||||
|
readonly refusalControl: {
|
||||||
|
readonly observed: boolean;
|
||||||
|
readonly reasonCode: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrainRefusalControlResult {
|
||||||
|
readonly observed: boolean;
|
||||||
|
readonly reasonCode: string | null;
|
||||||
|
readonly gitReasonCode: string;
|
||||||
|
readonly apiReasonCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishEvidence {
|
||||||
|
readonly commit: string;
|
||||||
|
readonly remoteHead: string;
|
||||||
|
readonly reachable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandEnv(identity: string): Readonly<Record<string, string>> {
|
||||||
|
return {
|
||||||
|
MOSAIC_GIT_IDENTITY: identity,
|
||||||
|
GIT_TERMINAL_PROMPT: '0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function integrationFailure(): CredentialAssessment {
|
||||||
|
return {
|
||||||
|
outcome: 'indeterminate',
|
||||||
|
exitCode: 30,
|
||||||
|
reasonCode: 'unexpected-provider-shape',
|
||||||
|
diagnostic: 'indeterminate: unexpected-provider-shape',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGit(run: CommandRunner, identity: string, args: readonly string[]): CommandResult {
|
||||||
|
return run({ program: 'git', args, env: commandEnv(identity) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGitWithEnv(
|
||||||
|
run: CommandRunner,
|
||||||
|
identity: string,
|
||||||
|
args: readonly string[],
|
||||||
|
env: Readonly<Record<string, string>>,
|
||||||
|
stdin?: Uint8Array,
|
||||||
|
): CommandResult {
|
||||||
|
return run({ program: 'git', args, env: { ...commandEnv(identity), ...env }, stdin });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectBrainDoctorReport(
|
||||||
|
input: {
|
||||||
|
readonly registrySource: string;
|
||||||
|
readonly targetGitUrl: string;
|
||||||
|
readonly brainNamespace: string;
|
||||||
|
readonly identity: string;
|
||||||
|
readonly root: string;
|
||||||
|
},
|
||||||
|
run: CommandRunner,
|
||||||
|
): DoctorRuntimeReport {
|
||||||
|
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
||||||
|
const validation = run({
|
||||||
|
program: 'mosaic',
|
||||||
|
args: [
|
||||||
|
'cred',
|
||||||
|
'validate',
|
||||||
|
input.identity,
|
||||||
|
'--estate',
|
||||||
|
target.estate,
|
||||||
|
'--host',
|
||||||
|
target.host,
|
||||||
|
'--repo',
|
||||||
|
target.repo,
|
||||||
|
'--require',
|
||||||
|
'write',
|
||||||
|
'--json',
|
||||||
|
],
|
||||||
|
env: commandEnv(input.identity),
|
||||||
|
});
|
||||||
|
let access = assessCredentialResult(validation.stdout, {
|
||||||
|
identity: input.identity,
|
||||||
|
estate: target.estate,
|
||||||
|
host: target.host,
|
||||||
|
repo: target.repo,
|
||||||
|
});
|
||||||
|
if (validation.status !== access.exitCode) access = integrationFailure();
|
||||||
|
|
||||||
|
const rootExists = existsSync(input.root);
|
||||||
|
let gitRepository = false;
|
||||||
|
let remote: string | null = null;
|
||||||
|
let branch: string | null = null;
|
||||||
|
let worktreeState: BrainDoctorObservation['worktreeState'] = 'unmeasurable';
|
||||||
|
if (rootExists) {
|
||||||
|
const repository = runGit(run, input.identity, [
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'rev-parse',
|
||||||
|
'--is-inside-work-tree',
|
||||||
|
]);
|
||||||
|
gitRepository = repository.status === 0 && repository.stdout.trim() === 'true';
|
||||||
|
if (gitRepository) {
|
||||||
|
const remoteResult = runGit(run, input.identity, [
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'remote',
|
||||||
|
'get-url',
|
||||||
|
'origin',
|
||||||
|
]);
|
||||||
|
const branchResult = runGit(run, input.identity, [
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'branch',
|
||||||
|
'--show-current',
|
||||||
|
]);
|
||||||
|
const statusResult = runGit(run, input.identity, ['-C', input.root, 'status', '--porcelain']);
|
||||||
|
if (remoteResult.status === 0) remote = remoteResult.stdout.trim();
|
||||||
|
if (branchResult.status === 0) branch = branchResult.stdout.trim();
|
||||||
|
if (statusResult.status === 0) {
|
||||||
|
worktreeState = statusResult.stdout.trim().length > 0 ? 'dirty' : 'clean';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const observation: BrainDoctorObservation = {
|
||||||
|
rootExists,
|
||||||
|
rootPrivate: rootExists && brainRootIsPrivate(input.root),
|
||||||
|
gitRepository,
|
||||||
|
remote,
|
||||||
|
branch,
|
||||||
|
worktreeState,
|
||||||
|
access,
|
||||||
|
};
|
||||||
|
const refusalMarker = `refused reason=${access.reasonCode}`;
|
||||||
|
const refusalObserved =
|
||||||
|
validation.status === 10 &&
|
||||||
|
access.outcome === 'refused' &&
|
||||||
|
access.reasonCode === 'no-token-for-identity' &&
|
||||||
|
validation.stderr.includes(refusalMarker);
|
||||||
|
return {
|
||||||
|
findings: evaluateBrainDoctor(observation, target.cloneUrl),
|
||||||
|
access,
|
||||||
|
refusalControl: {
|
||||||
|
observed: refusalObserved,
|
||||||
|
reasonCode: refusalObserved ? access.reasonCode : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectBrainRefusalControl(
|
||||||
|
input: {
|
||||||
|
readonly registrySource: string;
|
||||||
|
readonly targetGitUrl: string;
|
||||||
|
readonly brainNamespace: string;
|
||||||
|
readonly refusalIdentity: string;
|
||||||
|
},
|
||||||
|
run: CommandRunner,
|
||||||
|
): BrainRefusalControlResult {
|
||||||
|
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(input.refusalIdentity)) {
|
||||||
|
return {
|
||||||
|
observed: false,
|
||||||
|
reasonCode: 'permission-evidence-disagrees',
|
||||||
|
gitReasonCode: 'invalid-control-identity',
|
||||||
|
apiReasonCode: 'invalid-control-identity',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const apiResult = run({
|
||||||
|
program: 'mosaic',
|
||||||
|
args: [
|
||||||
|
'cred',
|
||||||
|
'validate',
|
||||||
|
input.refusalIdentity,
|
||||||
|
'--estate',
|
||||||
|
target.estate,
|
||||||
|
'--host',
|
||||||
|
target.host,
|
||||||
|
'--repo',
|
||||||
|
target.repo,
|
||||||
|
'--require',
|
||||||
|
'write',
|
||||||
|
'--json',
|
||||||
|
],
|
||||||
|
env: commandEnv(input.refusalIdentity),
|
||||||
|
});
|
||||||
|
let api = assessCredentialResult(apiResult.stdout, {
|
||||||
|
identity: input.refusalIdentity,
|
||||||
|
estate: target.estate,
|
||||||
|
host: target.host,
|
||||||
|
repo: target.repo,
|
||||||
|
});
|
||||||
|
if (apiResult.status !== api.exitCode) api = integrationFailure();
|
||||||
|
|
||||||
|
const gitResult = runGit(run, input.refusalIdentity, ['ls-remote', target.cloneUrl, 'HEAD']);
|
||||||
|
const marker = /(?:^|\s)reason=([a-z0-9-]+)(?:\s|$)/.exec(gitResult.stderr)?.[1];
|
||||||
|
const stableRefusals = new Set([
|
||||||
|
'identity-required',
|
||||||
|
'estate-required',
|
||||||
|
'estate-host-mismatch',
|
||||||
|
'cross-estate-resolution',
|
||||||
|
'no-token-for-identity',
|
||||||
|
'tea-login-missing',
|
||||||
|
'tea-login-host-mismatch',
|
||||||
|
'provider-identity-mismatch',
|
||||||
|
'credential-rejected',
|
||||||
|
'permission-denied',
|
||||||
|
'organization-membership-required',
|
||||||
|
'team-membership-required',
|
||||||
|
]);
|
||||||
|
const gitReasonCode =
|
||||||
|
gitResult.status === 0
|
||||||
|
? 'transport-accepted'
|
||||||
|
: marker !== undefined && stableRefusals.has(marker) && gitResult.stdout.length === 0
|
||||||
|
? marker
|
||||||
|
: 'transport-indeterminate';
|
||||||
|
const observed =
|
||||||
|
api.outcome === 'refused' &&
|
||||||
|
gitReasonCode !== 'transport-accepted' &&
|
||||||
|
gitReasonCode !== 'transport-indeterminate' &&
|
||||||
|
gitReasonCode === api.reasonCode;
|
||||||
|
return {
|
||||||
|
observed,
|
||||||
|
reasonCode: observed ? api.reasonCode : 'permission-evidence-disagrees',
|
||||||
|
gitReasonCode,
|
||||||
|
apiReasonCode: api.reasonCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repairBrainDoctor(
|
||||||
|
input: {
|
||||||
|
readonly registrySource: string;
|
||||||
|
readonly targetGitUrl: string;
|
||||||
|
readonly brainNamespace: string;
|
||||||
|
readonly identity: string;
|
||||||
|
readonly root: string;
|
||||||
|
},
|
||||||
|
run: CommandRunner,
|
||||||
|
): DoctorRuntimeReport {
|
||||||
|
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
||||||
|
let report = collectBrainDoctorReport(input, run);
|
||||||
|
const actions = planBrainDoctorFix({
|
||||||
|
findings: report.findings,
|
||||||
|
target,
|
||||||
|
identity: input.identity,
|
||||||
|
root: input.root,
|
||||||
|
});
|
||||||
|
for (const action of actions) {
|
||||||
|
if (action.program === 'mosaic') {
|
||||||
|
run({ program: 'mosaic', args: action.args, env: commandEnv(input.identity) });
|
||||||
|
report = collectBrainDoctorReport(input, run);
|
||||||
|
if (report.access.outcome !== 'ok') return report;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (action.findingCode === 'brain-clone-missing') {
|
||||||
|
try {
|
||||||
|
ensureBrainRootPrivate(input.root);
|
||||||
|
} catch {
|
||||||
|
return collectBrainDoctorReport(input, run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = runGit(run, input.identity, action.args);
|
||||||
|
if (result.status !== 0) return collectBrainDoctorReport(input, run);
|
||||||
|
}
|
||||||
|
return collectBrainDoctorReport(input, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSuccess(result: CommandResult, operation: string): void {
|
||||||
|
if (result.status !== 0) throw new Error(`${operation}-failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function containedRelative(root: string, path: string): string {
|
||||||
|
if (isAbsolute(path) === false) throw new Error('brain-publish-path-must-be-absolute');
|
||||||
|
const absoluteRoot = resolve(root);
|
||||||
|
const absolutePath = resolve(path);
|
||||||
|
if (absolutePath === absoluteRoot || !absolutePath.startsWith(`${absoluteRoot}${sep}`)) {
|
||||||
|
throw new Error('brain-publish-path-escaped-root');
|
||||||
|
}
|
||||||
|
return relative(absoluteRoot, absolutePath).split(sep).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishBrainPaths(
|
||||||
|
input: {
|
||||||
|
readonly root: string;
|
||||||
|
readonly identity: string;
|
||||||
|
readonly entries: readonly {
|
||||||
|
readonly path: string;
|
||||||
|
readonly content: Uint8Array;
|
||||||
|
}[];
|
||||||
|
readonly message: string;
|
||||||
|
},
|
||||||
|
run: CommandRunner,
|
||||||
|
): PublishEvidence {
|
||||||
|
if (input.entries.length === 0) throw new Error('brain-publish-paths-empty');
|
||||||
|
if (input.message.trim().length === 0) throw new Error('brain-publish-message-empty');
|
||||||
|
const entries = input.entries.map((entry) => {
|
||||||
|
if (entry.content.byteLength > MAX_PUBLISH_ENTRY_BYTES) {
|
||||||
|
throw new Error('brain-publish-entry-too-large');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: containedRelative(input.root, entry.path),
|
||||||
|
content: Uint8Array.from(entry.content),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const paths = entries.map((entry): string => entry.path);
|
||||||
|
if (new Set(paths).size !== paths.length) throw new Error('brain-publish-path-duplicate');
|
||||||
|
|
||||||
|
const readHead = (): string => {
|
||||||
|
const result = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'HEAD']);
|
||||||
|
requireSuccess(result, 'brain-git-read-commit');
|
||||||
|
const value = result.stdout.trim();
|
||||||
|
if (!COMMIT.test(value)) throw new Error('brain-git-commit-shape-invalid');
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const verifyCommitIdentity = (commit: string): void => {
|
||||||
|
const result = runGit(run, input.identity, [
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'show',
|
||||||
|
'-s',
|
||||||
|
'--format=%an%x00%ae%x00%cn%x00%ce',
|
||||||
|
commit,
|
||||||
|
]);
|
||||||
|
requireSuccess(result, 'brain-git-read-commit-identity');
|
||||||
|
const expectedEmail = `${input.identity}@fleet.mosaicstack.dev`;
|
||||||
|
const [author, authorEmail, committer, committerEmail] = result.stdout.trimEnd().split('\0');
|
||||||
|
if (
|
||||||
|
author !== input.identity ||
|
||||||
|
authorEmail !== expectedEmail ||
|
||||||
|
committer !== input.identity ||
|
||||||
|
committerEmail !== expectedEmail
|
||||||
|
) {
|
||||||
|
throw new Error('brain-git-commit-identity-mismatch');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const expectedObjects = new Map<string, string>();
|
||||||
|
const verifyCommitObjects = (commit: string): void => {
|
||||||
|
for (const [path, expected] of expectedObjects) {
|
||||||
|
const result = runGit(run, input.identity, [
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'rev-parse',
|
||||||
|
`${commit}:${path}`,
|
||||||
|
]);
|
||||||
|
requireSuccess(result, 'brain-git-read-commit-object');
|
||||||
|
if (result.stdout.trim() !== expected) throw new Error('brain-git-commit-content-mismatch');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const reconcileRealIndex = (): void => {
|
||||||
|
for (const [path, objectId] of expectedObjects) {
|
||||||
|
requireSuccess(
|
||||||
|
runGit(run, input.identity, [
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'update-index',
|
||||||
|
'--add',
|
||||||
|
'--cacheinfo',
|
||||||
|
'100644',
|
||||||
|
objectId,
|
||||||
|
path,
|
||||||
|
]),
|
||||||
|
'brain-git-reconcile-checkout-index',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const verifyCommitPaths = (commit: string): void => {
|
||||||
|
const changed = runGit(run, input.identity, [
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'diff-tree',
|
||||||
|
'--root',
|
||||||
|
'--no-commit-id',
|
||||||
|
'--name-only',
|
||||||
|
'-r',
|
||||||
|
'-z',
|
||||||
|
commit,
|
||||||
|
]);
|
||||||
|
requireSuccess(changed, 'brain-git-read-commit-paths');
|
||||||
|
const names = changed.stdout.split('\0').filter(Boolean);
|
||||||
|
const approved = new Set(paths);
|
||||||
|
if (names.length === 0 || names.some((name: string): boolean => !approved.has(name))) {
|
||||||
|
throw new Error('brain-git-commit-paths-unapproved');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const base = readHead();
|
||||||
|
const indexRoot = mkdtempSync(join(tmpdir(), 'mosaic-brain-index-'));
|
||||||
|
const isolatedEnv = { GIT_INDEX_FILE: join(indexRoot, 'index') };
|
||||||
|
let commit = base;
|
||||||
|
let createdCommit = false;
|
||||||
|
try {
|
||||||
|
requireSuccess(
|
||||||
|
runGitWithEnv(run, input.identity, ['-C', input.root, 'read-tree', base], isolatedEnv),
|
||||||
|
'brain-git-isolated-index-init',
|
||||||
|
);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const object = runGitWithEnv(
|
||||||
|
run,
|
||||||
|
input.identity,
|
||||||
|
['-C', input.root, 'hash-object', '-w', '--stdin'],
|
||||||
|
isolatedEnv,
|
||||||
|
entry.content,
|
||||||
|
);
|
||||||
|
requireSuccess(object, 'brain-git-write-approved-object');
|
||||||
|
const objectId = object.stdout.trim();
|
||||||
|
if (!GIT_OBJECT.test(objectId)) throw new Error('brain-git-object-shape-invalid');
|
||||||
|
expectedObjects.set(entry.path, objectId);
|
||||||
|
requireSuccess(
|
||||||
|
runGitWithEnv(
|
||||||
|
run,
|
||||||
|
input.identity,
|
||||||
|
[
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'update-index',
|
||||||
|
'--add',
|
||||||
|
'--cacheinfo',
|
||||||
|
'100644',
|
||||||
|
objectId,
|
||||||
|
entry.path,
|
||||||
|
],
|
||||||
|
isolatedEnv,
|
||||||
|
),
|
||||||
|
'brain-git-stage-approved-object',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const difference = runGitWithEnv(
|
||||||
|
run,
|
||||||
|
input.identity,
|
||||||
|
['-C', input.root, 'diff', '--cached', '--quiet', '--exit-code', base, '--', ...paths],
|
||||||
|
isolatedEnv,
|
||||||
|
);
|
||||||
|
if (difference.status === 1) {
|
||||||
|
requireSuccess(
|
||||||
|
runGitWithEnv(
|
||||||
|
run,
|
||||||
|
input.identity,
|
||||||
|
[
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'-c',
|
||||||
|
`user.name=${input.identity}`,
|
||||||
|
'-c',
|
||||||
|
`user.email=${input.identity}@fleet.mosaicstack.dev`,
|
||||||
|
'commit',
|
||||||
|
'-m',
|
||||||
|
input.message,
|
||||||
|
],
|
||||||
|
isolatedEnv,
|
||||||
|
),
|
||||||
|
'brain-git-commit',
|
||||||
|
);
|
||||||
|
commit = readHead();
|
||||||
|
verifyCommitPaths(commit);
|
||||||
|
verifyCommitObjects(commit);
|
||||||
|
verifyCommitIdentity(commit);
|
||||||
|
reconcileRealIndex();
|
||||||
|
createdCommit = true;
|
||||||
|
} else if (difference.status !== 0) {
|
||||||
|
throw new Error('brain-git-isolated-diff-failed');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(indexRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let pushed = !createdCommit;
|
||||||
|
for (let attempt = 0; createdCommit && attempt < 3; attempt += 1) {
|
||||||
|
const push = runGit(run, input.identity, ['-C', input.root, 'push', 'origin', 'HEAD:main']);
|
||||||
|
if (push.status === 0) {
|
||||||
|
pushed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const concurrentUpdate = /non-fast-forward|fetch first|\[rejected\]/i.test(push.stderr);
|
||||||
|
if (!concurrentUpdate || attempt === 2) throw new Error('brain-git-push-failed');
|
||||||
|
requireSuccess(
|
||||||
|
runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']),
|
||||||
|
'brain-git-fetch-concurrent',
|
||||||
|
);
|
||||||
|
requireSuccess(
|
||||||
|
runGit(run, input.identity, [
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'-c',
|
||||||
|
`user.name=${input.identity}`,
|
||||||
|
'-c',
|
||||||
|
`user.email=${input.identity}@fleet.mosaicstack.dev`,
|
||||||
|
'rebase',
|
||||||
|
'origin/main',
|
||||||
|
]),
|
||||||
|
'brain-git-rebase-concurrent',
|
||||||
|
);
|
||||||
|
commit = readHead();
|
||||||
|
verifyCommitPaths(commit);
|
||||||
|
verifyCommitObjects(commit);
|
||||||
|
verifyCommitIdentity(commit);
|
||||||
|
}
|
||||||
|
if (!pushed) throw new Error('brain-git-push-failed');
|
||||||
|
requireSuccess(
|
||||||
|
runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']),
|
||||||
|
'brain-git-fetch-readback',
|
||||||
|
);
|
||||||
|
const reachableResult = runGit(run, input.identity, [
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'merge-base',
|
||||||
|
'--is-ancestor',
|
||||||
|
commit,
|
||||||
|
'origin/main',
|
||||||
|
]);
|
||||||
|
if (reachableResult.status !== 0 && reachableResult.status !== 1) {
|
||||||
|
throw new Error('brain-git-reachability-check-failed');
|
||||||
|
}
|
||||||
|
const remoteResult = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'origin/main']);
|
||||||
|
requireSuccess(remoteResult, 'brain-git-read-remote-head');
|
||||||
|
const remoteHead = remoteResult.stdout.trim();
|
||||||
|
if (!COMMIT.test(remoteHead)) throw new Error('brain-git-remote-head-shape-invalid');
|
||||||
|
return { commit, remoteHead, reachable: reachableResult.status === 0 };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -131,14 +131,13 @@ async function exists(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
||||||
it('maps a roster-v2 agent to exactly the nine generated projection keys', (): void => {
|
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => {
|
||||||
const roster = parseRosterV2(rosterYaml, 'yaml');
|
const roster = parseRosterV2(rosterYaml, 'yaml');
|
||||||
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
||||||
expect(agent).toBeDefined();
|
expect(agent).toBeDefined();
|
||||||
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
||||||
expect(values).toEqual({
|
expect(values).toEqual({
|
||||||
MOSAIC_AGENT_NAME: 'coder0',
|
MOSAIC_AGENT_NAME: 'coder0',
|
||||||
MOSAIC_GIT_IDENTITY: 'coder0',
|
|
||||||
MOSAIC_AGENT_CLASS: 'code',
|
MOSAIC_AGENT_CLASS: 'code',
|
||||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
||||||
|
|||||||
@@ -422,7 +422,6 @@ describe('fleet roster parsing', () => {
|
|||||||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||||||
[
|
[
|
||||||
'MOSAIC_AGENT_NAME=coder0',
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
'MOSAIC_GIT_IDENTITY=coder0',
|
|
||||||
// Reflects the roster's canonicalized compatibility class (A3a).
|
// Reflects the roster's canonicalized compatibility class (A3a).
|
||||||
'MOSAIC_AGENT_CLASS=code',
|
'MOSAIC_AGENT_CLASS=code',
|
||||||
'MOSAIC_AGENT_RUNTIME=codex',
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
@@ -3800,7 +3799,6 @@ describe('fleet add command', () => {
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||||
expect(envContent).toContain('MOSAIC_GIT_IDENTITY=coder0');
|
|
||||||
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -484,7 +484,6 @@ function generateAgentEnvValues(
|
|||||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
MOSAIC_GIT_IDENTITY: agent.name,
|
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
|||||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||||
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
||||||
|
import {
|
||||||
|
defaultInstalledBrainDoctorOptions,
|
||||||
|
runInstalledBrainDoctorCheck,
|
||||||
|
} from './brain-doctor-check.js';
|
||||||
|
import { systemCommandRunner } from './brain-store-runtime.js';
|
||||||
|
|
||||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||||
@@ -1257,8 +1262,11 @@ export function registerLaunchCommands(program: Command): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// `doctor` — the framework drift audit (bash script) PLUS the #869
|
// `doctor` — the framework drift audit (bash script), the #869
|
||||||
// Point-1 C5 lease-enforcement activation check (TS, reusing C1's
|
// Point-1 C5 lease-enforcement activation check, and the #1051 per-estate
|
||||||
|
// durable brain check. Both TS checks run before the bash audit and can
|
||||||
|
// force a non-zero result for hard/indeterminate failures.
|
||||||
|
// The lease check reuses C1's
|
||||||
// `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`).
|
// `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`).
|
||||||
// Kept out of the generic `directCommands` loop above because this check
|
// Kept out of the generic `directCommands` loop above because this check
|
||||||
// must run and report BEFORE the bash script's own exit, and must be able
|
// must run and report BEFORE the bash script's own exit, and must be able
|
||||||
@@ -1267,14 +1275,29 @@ export function registerLaunchCommands(program: Command): void {
|
|||||||
// undiagnosed (see lease-doctor-check.ts docstring).
|
// undiagnosed (see lease-doctor-check.ts docstring).
|
||||||
program
|
program
|
||||||
.command('doctor')
|
.command('doctor')
|
||||||
.description('Health audit — detect drift, missing files, and #869 lease-activation gaps')
|
.description('Health audit — detect drift, lease gaps, and per-estate brain defects')
|
||||||
.allowUnknownOption(true)
|
.allowUnknownOption(true)
|
||||||
.allowExcessArguments(true)
|
.allowExcessArguments(true)
|
||||||
.action(async (_opts: unknown, cmd: Command) => {
|
.action(async (_opts: unknown, cmd: Command) => {
|
||||||
checkMosaicHome();
|
checkMosaicHome();
|
||||||
const leaseCheck = await runLeaseEnforcementDoctorCheck();
|
const leaseCheck = await runLeaseEnforcementDoctorCheck();
|
||||||
const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck);
|
const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck);
|
||||||
runDoctorScriptAndExit(fwScript('mosaic-doctor'), cmd.args, leaseCheckFailed);
|
const fix = cmd.args.includes('--fix');
|
||||||
|
const brainCheck = runInstalledBrainDoctorCheck(
|
||||||
|
defaultInstalledBrainDoctorOptions(fix),
|
||||||
|
systemCommandRunner,
|
||||||
|
);
|
||||||
|
for (const line of brainCheck.lines) {
|
||||||
|
(brainCheck.status === 'ok' ? console.log : console.error)(line);
|
||||||
|
}
|
||||||
|
const brainCheckFailed =
|
||||||
|
brainCheck.status === 'error' ||
|
||||||
|
(brainCheck.status === 'warn' && cmd.args.includes('--fail-on-warn'));
|
||||||
|
runDoctorScriptAndExit(
|
||||||
|
fwScript('mosaic-doctor'),
|
||||||
|
cmd.args,
|
||||||
|
leaseCheckFailed || brainCheckFailed,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type {
|
||||||
|
ProviderIdentityEvidenceDto,
|
||||||
|
ReceivePackEvidenceDto,
|
||||||
|
RepositoryPermission,
|
||||||
|
RepositoryPermissionEvidenceDto,
|
||||||
|
} from './credential-result.dto.js';
|
||||||
|
|
||||||
|
export interface ResolvedCredential {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly estate: string;
|
||||||
|
readonly host: string;
|
||||||
|
readonly resolutionId: string;
|
||||||
|
readonly secret: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialResolver {
|
||||||
|
resolve(identity: string, estate: string, host: string): Promise<ResolvedCredential | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaCredentialProvider {
|
||||||
|
readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto>;
|
||||||
|
readRepositoryPermission(
|
||||||
|
resolved: ResolvedCredential,
|
||||||
|
repo: string,
|
||||||
|
): Promise<RepositoryPermissionEvidenceDto>;
|
||||||
|
probeReceivePack(
|
||||||
|
resolved: ResolvedCredential | undefined,
|
||||||
|
repo: string,
|
||||||
|
): Promise<ReceivePackEvidenceDto>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialEstateRegistry {
|
||||||
|
matches(estate: string, host: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialValidationDependencies {
|
||||||
|
readonly resolver: CredentialResolver;
|
||||||
|
readonly provider: GiteaCredentialProvider;
|
||||||
|
readonly estateRegistry: CredentialEstateRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaReadValidationRequestDto {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly estate: string;
|
||||||
|
readonly host: string;
|
||||||
|
readonly repo: string;
|
||||||
|
readonly requiredPermission?: RepositoryPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto {
|
||||||
|
readonly readOnlyControlIdentity: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate';
|
||||||
|
export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown';
|
||||||
|
export type RepositoryPermission = 'none' | 'read' | 'write' | 'admin';
|
||||||
|
export type ReceivePackState = 'advertised' | 'refused';
|
||||||
|
|
||||||
|
export interface CredentialReasonDto {
|
||||||
|
readonly code: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialSubjectDto {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly estate: string;
|
||||||
|
readonly host: string;
|
||||||
|
readonly repo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderIdentityEvidenceDto {
|
||||||
|
readonly login: string;
|
||||||
|
readonly endpoint: string;
|
||||||
|
readonly contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepositoryPermissionEvidenceDto {
|
||||||
|
readonly effective: RepositoryPermission;
|
||||||
|
readonly endpoint: string;
|
||||||
|
readonly contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceivePackEvidenceDto {
|
||||||
|
readonly state: ReceivePackState;
|
||||||
|
readonly principal: string | null;
|
||||||
|
readonly resolutionId: string | null;
|
||||||
|
readonly contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadOnlyControlEvidenceDto {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly providerPermission: RepositoryPermission;
|
||||||
|
readonly receivePack: ReceivePackState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteDifferentialEvidenceDto {
|
||||||
|
readonly state: 'can-write';
|
||||||
|
readonly credentialBinding: 'same-resolution';
|
||||||
|
readonly transportPrincipal: string;
|
||||||
|
readonly authenticatedReceivePack: 'advertised';
|
||||||
|
readonly readOnlyControl: ReadOnlyControlEvidenceDto;
|
||||||
|
readonly unauthenticatedReceivePack: 'refused';
|
||||||
|
readonly artifactCreated: false;
|
||||||
|
readonly proves: string;
|
||||||
|
readonly doesNotProve: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenCapabilitiesEvidenceDto {
|
||||||
|
readonly state: 'measured' | 'not-measured';
|
||||||
|
readonly scopes: readonly string[];
|
||||||
|
readonly source: 'provider-token-object' | 'runtime-not-authorized';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialValidationEvidenceDto {
|
||||||
|
readonly providerIdentity: ProviderIdentityEvidenceDto | null;
|
||||||
|
readonly tokenCapabilities: TokenCapabilitiesEvidenceDto;
|
||||||
|
readonly repositoryPermission: RepositoryPermissionEvidenceDto | null;
|
||||||
|
readonly writeDifferential: WriteDifferentialEvidenceDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialAuditResultDto {
|
||||||
|
readonly journalId: string | null;
|
||||||
|
readonly state: 'not-started' | 'open' | 'sealed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialValidationResultDto {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'validate' | 'whoami';
|
||||||
|
readonly outcome: CredentialOutcome;
|
||||||
|
readonly exitCode: 0 | 10 | 20 | 30;
|
||||||
|
readonly retryable: boolean;
|
||||||
|
readonly subject: CredentialSubjectDto;
|
||||||
|
readonly mutation: CredentialMutationState;
|
||||||
|
readonly reason: CredentialReasonDto;
|
||||||
|
readonly evidence: CredentialValidationEvidenceDto;
|
||||||
|
readonly audit: CredentialAuditResultDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export type CredentialProviderKind = 'gitea';
|
||||||
|
|
||||||
|
export interface CredentialHostConfigDto {
|
||||||
|
readonly host: string;
|
||||||
|
readonly provider: CredentialProviderKind;
|
||||||
|
readonly apiBaseUrl: string;
|
||||||
|
readonly tokenPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialEstateConfigDto {
|
||||||
|
readonly name: string;
|
||||||
|
readonly readOnlyControlIdentity?: string;
|
||||||
|
readonly inventoryAuthorityIdentity?: string;
|
||||||
|
readonly hosts: readonly CredentialHostConfigDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
||||||
|
|
||||||
|
const validRegistry = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
estates: [
|
||||||
|
{
|
||||||
|
name: 'homelab',
|
||||||
|
readOnlyControlIdentity: 'read-control',
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
provider: 'gitea',
|
||||||
|
apiBaseUrl: 'https://git.example.invalid',
|
||||||
|
tokenPrefix: 'gitea-example',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('credential estate registry', (): void => {
|
||||||
|
it('requires an exact declared estate-host pair', (): void => {
|
||||||
|
const registry = parseCredentialEstateRegistry(validRegistry);
|
||||||
|
|
||||||
|
expect(registry.matches('homelab', 'git.example.invalid')).toBe(true);
|
||||||
|
expect(registry.matches('usc', 'git.example.invalid')).toBe(false);
|
||||||
|
expect(registry.matches('homelab', 'other.example.invalid')).toBe(false);
|
||||||
|
expect(registry.resolveByHost('git.example.invalid')).toMatchObject({
|
||||||
|
estate: 'homelab',
|
||||||
|
host: { host: 'git.example.invalid', provider: 'gitea' },
|
||||||
|
});
|
||||||
|
expect(registry.resolveByHost('other.example.invalid')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a provider URL whose host differs from the declared host', (): void => {
|
||||||
|
const source = validRegistry.replace(
|
||||||
|
'https://git.example.invalid',
|
||||||
|
'https://other.example.invalid',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => parseCredentialEstateRegistry(source)).toThrow(/api-host-mismatch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects one host assigned to multiple estates', (): void => {
|
||||||
|
const source = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
estates: [
|
||||||
|
{
|
||||||
|
name: 'homelab',
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
provider: 'gitea',
|
||||||
|
apiBaseUrl: 'https://git.example.invalid',
|
||||||
|
tokenPrefix: 'gitea-example',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'other',
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
provider: 'gitea',
|
||||||
|
apiBaseUrl: 'https://git.example.invalid',
|
||||||
|
tokenPrefix: 'gitea-other',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => parseCredentialEstateRegistry(source)).toThrow(/duplicate-host/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects URLs with userinfo, path, query, fragment, trailing slash, or non-HTTPS scheme', (): void => {
|
||||||
|
for (const apiBaseUrl of [
|
||||||
|
'http://git.example.invalid',
|
||||||
|
'https://[email protected]',
|
||||||
|
'https://git.example.invalid/',
|
||||||
|
'https://git.example.invalid/api',
|
||||||
|
'https://git.example.invalid?x=1',
|
||||||
|
'https://git.example.invalid#x',
|
||||||
|
]) {
|
||||||
|
const source = validRegistry.replace('https://git.example.invalid', apiBaseUrl);
|
||||||
|
expect(() => parseCredentialEstateRegistry(source), apiBaseUrl).toThrow(/invalid-api-url/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a configured read-only control for write validation', (): void => {
|
||||||
|
const registry = parseCredentialEstateRegistry(validRegistry);
|
||||||
|
const withoutControl = parseCredentialEstateRegistry(
|
||||||
|
validRegistry.replace('"readOnlyControlIdentity":"read-control",', ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registry.readOnlyControl('homelab')).toBe('read-control');
|
||||||
|
expect(() => withoutControl.readOnlyControl('homelab')).toThrow(/read-only-control-missing/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { CredentialEstateRegistry } from './credential-provider.dto.js';
|
||||||
|
import type { CredentialEstateConfigDto, CredentialHostConfigDto } from './estate-registry.dto.js';
|
||||||
|
|
||||||
|
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
|
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||||
|
const HOST = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
||||||
|
|
||||||
|
const hostSchema = z
|
||||||
|
.object({
|
||||||
|
host: z.string().regex(HOST),
|
||||||
|
provider: z.literal('gitea'),
|
||||||
|
apiBaseUrl: z.string(),
|
||||||
|
tokenPrefix: z.string().regex(NAME),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const estateSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().regex(NAME),
|
||||||
|
readOnlyControlIdentity: z.string().regex(IDENTITY).optional(),
|
||||||
|
inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(),
|
||||||
|
hosts: z.array(hostSchema).min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const registrySchema = z
|
||||||
|
.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
estates: z.array(estateSchema).min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export class CredentialEstateRegistryError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(`Credential estate registry rejected: code=${code} ${message}`);
|
||||||
|
this.name = 'CredentialEstateRegistryError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateApiUrl(host: CredentialHostConfigDto): void {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(host.apiBaseUrl);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new CredentialEstateRegistryError('invalid-api-url', detail);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url.protocol !== 'https:' ||
|
||||||
|
url.username !== '' ||
|
||||||
|
url.password !== '' ||
|
||||||
|
url.pathname !== '/' ||
|
||||||
|
host.apiBaseUrl !== url.origin ||
|
||||||
|
url.search !== '' ||
|
||||||
|
url.hash !== ''
|
||||||
|
) {
|
||||||
|
throw new CredentialEstateRegistryError(
|
||||||
|
'invalid-api-url',
|
||||||
|
'provider API URL must be an HTTPS origin without userinfo, path, query, or fragment',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.hostname !== host.host) {
|
||||||
|
throw new CredentialEstateRegistryError(
|
||||||
|
'api-host-mismatch',
|
||||||
|
'provider API URL hostname does not equal the declared host',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry {
|
||||||
|
private readonly estates: ReadonlyMap<string, CredentialEstateConfigDto>;
|
||||||
|
|
||||||
|
constructor(estates: readonly CredentialEstateConfigDto[]) {
|
||||||
|
this.estates = new Map(
|
||||||
|
estates.map(
|
||||||
|
(estate: CredentialEstateConfigDto): readonly [string, CredentialEstateConfigDto] => [
|
||||||
|
estate.name,
|
||||||
|
estate,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
matches(estate: string, host: string): boolean {
|
||||||
|
return this.resolve(estate, host) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(estate: string, host: string): CredentialHostConfigDto | undefined {
|
||||||
|
return this.estates
|
||||||
|
.get(estate)
|
||||||
|
?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveByHost(
|
||||||
|
host: string,
|
||||||
|
): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined {
|
||||||
|
for (const [estate, config] of this.estates) {
|
||||||
|
const match = config.hosts.find(
|
||||||
|
(candidate: CredentialHostConfigDto): boolean => candidate.host === host,
|
||||||
|
);
|
||||||
|
if (match !== undefined) return { estate, host: match };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
inventoryAuthority(estate: string): string {
|
||||||
|
const identity = this.estates.get(estate)?.inventoryAuthorityIdentity;
|
||||||
|
if (identity === undefined) {
|
||||||
|
throw new CredentialEstateRegistryError(
|
||||||
|
'inventory-authority-missing',
|
||||||
|
`estate ${estate} has no delegated inventory authority identity`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
readOnlyControl(estate: string): string {
|
||||||
|
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
|
||||||
|
if (identity === undefined) {
|
||||||
|
throw new CredentialEstateRegistryError(
|
||||||
|
'read-only-control-missing',
|
||||||
|
`estate ${estate} has no provider-confirmed read-only control identity`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCredentialEstateRegistry(source: string): ParsedCredentialEstateRegistry {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(source);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new CredentialEstateRegistryError('invalid-json', detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = registrySchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new CredentialEstateRegistryError(
|
||||||
|
'invalid-schema',
|
||||||
|
parsed.error.issues[0]?.message ?? 'invalid',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const estateNames = new Set<string>();
|
||||||
|
const hostNames = new Set<string>();
|
||||||
|
for (const estate of parsed.data.estates) {
|
||||||
|
if (estateNames.has(estate.name)) {
|
||||||
|
throw new CredentialEstateRegistryError('duplicate-estate', estate.name);
|
||||||
|
}
|
||||||
|
estateNames.add(estate.name);
|
||||||
|
for (const host of estate.hosts) {
|
||||||
|
validateApiUrl(host);
|
||||||
|
if (hostNames.has(host.host)) {
|
||||||
|
throw new CredentialEstateRegistryError('duplicate-host', host.host);
|
||||||
|
}
|
||||||
|
hostNames.add(host.host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedCredentialEstateRegistry(parsed.data.estates);
|
||||||
|
}
|
||||||
@@ -358,7 +358,6 @@ function generatedValues(
|
|||||||
): Readonly<Record<string, string>> {
|
): Readonly<Record<string, string>> {
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
MOSAIC_GIT_IDENTITY: agent.name,
|
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.model,
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ const COMMAND_RECORDS: Readonly<Record<string, RegExp>> = {
|
|||||||
|
|
||||||
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
||||||
'DATA.DOTENV.FLEET_LAUNCH':
|
'DATA.DOTENV.FLEET_LAUNCH':
|
||||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||||
'DATA.TEXT_TABLE.FLEET_TASKS':
|
'DATA.TEXT_TABLE.FLEET_TASKS':
|
||||||
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
|
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
|
||||||
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
|
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
|
||||||
@@ -406,7 +406,7 @@ const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
|||||||
'DATA.JSON.MUTATION_RESULT':
|
'DATA.JSON.MUTATION_RESULT':
|
||||||
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
|
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
|
||||||
'DATA.DOTENV.GENERATED_ENV':
|
'DATA.DOTENV.GENERATED_ENV':
|
||||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||||
'DATA.YAML.ROSTER_FIELDS':
|
'DATA.YAML.ROSTER_FIELDS':
|
||||||
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
|
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
|
||||||
};
|
};
|
||||||
@@ -922,8 +922,8 @@ describe('fleet operator documentation', (): void => {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
|
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
|
||||||
).toHaveLength(863);
|
).toHaveLength(858);
|
||||||
expect(surfaces).toHaveLength(887);
|
expect(surfaces).toHaveLength(882);
|
||||||
|
|
||||||
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
||||||
const auxiliary: CodeSurface = {
|
const auxiliary: CodeSurface = {
|
||||||
|
|||||||
@@ -597,7 +597,6 @@ export function projectRosterV2AgentGeneratedEnv(
|
|||||||
): Readonly<Record<string, string>> {
|
): Readonly<Record<string, string>> {
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
MOSAIC_GIT_IDENTITY: agent.name,
|
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.model,
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
|
|
||||||
const generatedValues = {
|
const generatedValues = {
|
||||||
MOSAIC_AGENT_NAME: 'coder0',
|
MOSAIC_AGENT_NAME: 'coder0',
|
||||||
MOSAIC_GIT_IDENTITY: 'coder0',
|
|
||||||
MOSAIC_AGENT_CLASS: 'code',
|
MOSAIC_AGENT_CLASS: 'code',
|
||||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
||||||
@@ -46,7 +45,6 @@ describe('generated fleet agent environment boundary', (): void => {
|
|||||||
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
||||||
[
|
[
|
||||||
'MOSAIC_AGENT_NAME=coder0',
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
'MOSAIC_GIT_IDENTITY=coder0',
|
|
||||||
'MOSAIC_AGENT_CLASS=code',
|
'MOSAIC_AGENT_CLASS=code',
|
||||||
'MOSAIC_AGENT_RUNTIME=pi',
|
'MOSAIC_AGENT_RUNTIME=pi',
|
||||||
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
||||||
@@ -80,22 +78,6 @@ describe('generated fleet agent environment boundary', (): void => {
|
|||||||
expect(String(error)).toMatch(/key=.*sha256=/);
|
expect(String(error)).toMatch(/key=.*sha256=/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
|
||||||
['unsafe-git-identity', 'other/identity'],
|
|
||||||
['git-identity-mismatch', 'reviewer0'],
|
|
||||||
])('rejects %s before any launch consumer can use it', (code: string, identity: string): void => {
|
|
||||||
expect((): void => {
|
|
||||||
renderGeneratedAgentEnvironment({
|
|
||||||
...generatedValues,
|
|
||||||
MOSAIC_GIT_IDENTITY: identity,
|
|
||||||
});
|
|
||||||
}).toThrow(
|
|
||||||
expect.objectContaining({
|
|
||||||
diagnostic: expect.objectContaining({ code, key: 'MOSAIC_GIT_IDENTITY' }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
||||||
expect((): void => {
|
expect((): void => {
|
||||||
renderGeneratedAgentEnvironment({
|
renderGeneratedAgentEnvironment({
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export class AgentEnvBoundaryError extends Error {
|
|||||||
|
|
||||||
export const GENERATED_AGENT_ENV_KEYS = [
|
export const GENERATED_AGENT_ENV_KEYS = [
|
||||||
'MOSAIC_AGENT_NAME',
|
'MOSAIC_AGENT_NAME',
|
||||||
'MOSAIC_GIT_IDENTITY',
|
|
||||||
'MOSAIC_AGENT_CLASS',
|
'MOSAIC_AGENT_CLASS',
|
||||||
'MOSAIC_AGENT_RUNTIME',
|
'MOSAIC_AGENT_RUNTIME',
|
||||||
'MOSAIC_AGENT_MODEL',
|
'MOSAIC_AGENT_MODEL',
|
||||||
@@ -403,7 +402,6 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
|||||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||||
}
|
}
|
||||||
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
||||||
const gitIdentity = requiredGeneratedValue(values, 'MOSAIC_GIT_IDENTITY');
|
|
||||||
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
||||||
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
||||||
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
||||||
@@ -414,12 +412,6 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
|||||||
|
|
||||||
if (!AGENT_NAME.test(name))
|
if (!AGENT_NAME.test(name))
|
||||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
||||||
if (!AGENT_NAME.test(gitIdentity)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-git-identity', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
|
||||||
}
|
|
||||||
if (gitIdentity !== name) {
|
|
||||||
throw new AgentEnvBoundaryError('git-identity-mismatch', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
|
||||||
}
|
|
||||||
if (!POLICY_NAME.test(className)) {
|
if (!POLICY_NAME.test(className)) {
|
||||||
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface SecureFileSnapshot {
|
|||||||
mode: number;
|
mode: number;
|
||||||
dev: number | bigint;
|
dev: number | bigint;
|
||||||
ino: number | bigint;
|
ino: number | bigint;
|
||||||
|
uid: number;
|
||||||
|
gid: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameIdentity(
|
function sameIdentity(
|
||||||
@@ -235,6 +237,8 @@ export function readRegularFileSecure(
|
|||||||
mode: Number(opened.mode),
|
mode: Number(opened.mode),
|
||||||
dev: opened.dev,
|
dev: opened.dev,
|
||||||
ino: opened.ino,
|
ino: opened.ino,
|
||||||
|
uid: opened.uid,
|
||||||
|
gid: opened.gid,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
closeDescriptors(openedFile.descriptors);
|
closeDescriptors(openedFile.descriptors);
|
||||||
|
|||||||
@@ -1405,7 +1405,6 @@ function generatedValues(
|
|||||||
): Readonly<Record<string, string>> {
|
): Readonly<Record<string, string>> {
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
MOSAIC_GIT_IDENTITY: agent.name,
|
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.model,
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
|||||||
@@ -72,9 +72,8 @@ elif [[ -n "$DATA_DIR" ]]; then
|
|||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
[[ -z "$file" ]] && continue
|
[[ -z "$file" ]] && continue
|
||||||
done_total=$((done_total + 1))
|
done_total=$((done_total + 1))
|
||||||
history_rc=0
|
if git -C "$DATA_DIR" log --since="${WINDOW_DAYS} days ago" --pretty='%s' -- "$file" 2>/dev/null \
|
||||||
history="$(git -C "$DATA_DIR" log --since="${WINDOW_DAYS} days ago" --pretty='%s' -- "$file" 2>/dev/null)" || history_rc=$?
|
| grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo'; then
|
||||||
if [[ "$history_rc" -eq 0 ]] && grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo' <<<"$history"; then
|
|
||||||
detectable=$((detectable + 1))
|
detectable=$((detectable + 1))
|
||||||
fi
|
fi
|
||||||
done < <(find "$DATA_DIR" -type f -name '*.json' 2>/dev/null)
|
done < <(find "$DATA_DIR" -type f -name '*.json' 2>/dev/null)
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ for line in "${LINES[@]}"; do
|
|||||||
# - build/test/lint/type/ci signals → CI would have caught it
|
# - build/test/lint/type/ci signals → CI would have caught it
|
||||||
# - security/auth/permission/data/migration → human review would flag it
|
# - security/auth/permission/data/migration → human review would flag it
|
||||||
# - everything else (logic/UX/assumption/edge) → only-self-reflection bucket
|
# - everything else (logic/UX/assumption/edge) → only-self-reflection bucket
|
||||||
if grep -qiE 'test|lint|type|build|ci|compile|typo' <<<"$subj"; then
|
if printf '%s' "$subj" | grep -qiE 'test|lint|type|build|ci|compile|typo'; then
|
||||||
ci=$((ci + 1))
|
ci=$((ci + 1))
|
||||||
elif grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection' <<<"$subj"; then
|
elif printf '%s' "$subj" | grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection'; then
|
||||||
human=$((human + 1))
|
human=$((human + 1))
|
||||||
else
|
else
|
||||||
selfonly=$((selfonly + 1))
|
selfonly=$((selfonly + 1))
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
[
|
|
||||||
"tools/matrix-presence-harness/run.sh:TSX_CLI=\"$(ls -d \"${REPO}\"/node_modules/.pnpm/tsx@*/node_modules/tsx/dist/cli.mjs 2>/dev/null | head -1)\"",
|
|
||||||
"tools/e2e-install-test.sh:if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then",
|
|
||||||
"tools/install.sh:EXTRACTED_DIR=\"$(find \"$WORK_DIR\" -maxdepth 1 -mindepth 1 -type d | head -1)\"",
|
|
||||||
"scripts/analysis/reflect-board-history.sh:if git -C \"$DATA_DIR\" log --since=\"${WINDOW_DAYS} days ago\" --pretty='%s' -- \"$file\" 2>/dev/null | grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo'; then",
|
|
||||||
"scripts/analysis/reflect-git-history.sh:if printf '%s' \"$subj\" | grep -qiE 'test|lint|type|build|ci|compile|typo'; then",
|
|
||||||
"scripts/analysis/reflect-git-history.sh:elif printf '%s' \"$subj\" | grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection'; then",
|
|
||||||
"packages/mosaic/framework/tools/authentik/user-create.sh:group_pk=$(echo \"$group_response\" | jq -r \".results[] | select(.name == \\\"$GROUP\\\") | .pk\" | head -1)",
|
|
||||||
"packages/mosaic/framework/tools/git/mutate-push-guard.sh:PROSE_LO=\"$(grep -n '^usage() {' \"$BAK\" | head -1 | cut -d: -f1)\"",
|
|
||||||
"packages/mosaic/framework/tools/orchestrator/session-resume.sh:echo \"$dirty_files\" | head -20 | while IFS= read -r line; do",
|
|
||||||
"packages/mosaic/framework/tools/prdy/prdy-status.sh:if echo \"$PRD_CONTENT\" | grep -qiE \"$pattern\"; then",
|
|
||||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'auth|login|session|token|permission|rbac|credential|secret'; then echo auth; return; fi",
|
|
||||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'migration|prisma|schema|\\.sql|entity|repository|seed'; then echo data; return; fi",
|
|
||||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'docker|\\.woodpecker|compose|traefik|deploy|helm|k8s|terraform'; then echo infra; return; fi",
|
|
||||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'package\\.json|tsconfig|turbo\\.json|pnpm-|\\.config\\.|eslint|vite'; then echo build; return; fi",
|
|
||||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.tsx|\\.css|components/|apps/web/'; then echo ui; return; fi",
|
|
||||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.spec\\.|\\.test\\.|__tests__/'; then echo test; return; fi",
|
|
||||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.md$|docs/'; then echo docs; return; fi",
|
|
||||||
"packages/mosaic/framework/tools/qa/typecheck-hook.sh:FILE_PATH=$(echo \"$JSON_INPUT\" | grep -o '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | sed 's/.*\"\\([^\"]*\\)\"$/\\1/' | head -1)",
|
|
||||||
"packages/mosaic/framework/tools/qa/typecheck-hook.sh:RELEVANT=$(echo \"$OUTPUT\" | grep -A2 \"$BASENAME\" 2>/dev/null || echo \"$OUTPUT\" | head -20)",
|
|
||||||
"packages/mosaic/framework/tools/tmux/send-message.sh:if printf '%s' \"$pane\" | grep -qF \"$QUEUED_RE\"; then",
|
|
||||||
"packages/mosaic/framework/tools/tmux/send-message.sh:if [ -n \"$snippet\" ] && printf '%s' \"$promptline\" | grep -qF \"$snippet\"; then",
|
|
||||||
"packages/mosaic/framework/tools/wake/detector.sh:sed -n \"s/^${key}=//p\" \"$MANIFEST\" | head -n1 | tr -d '[:space:]'",
|
|
||||||
"packages/mosaic/framework/tools/wake/detector.sh:if [ -n \"$snap_sha\" ] && ! printf '%s' \"$snap_sha\" | grep -Eq '^[0-9a-f]{7,64}$'; then",
|
|
||||||
"packages/mosaic/framework/tools/wake/detector.sh:if [ -n \"$snap_ts\" ] && ! printf '%s' \"$snap_ts\" | grep -Eq '^[0-9]{1,12}$'; then",
|
|
||||||
"packages/mosaic/framework/tools/wake/digest.sh:olabel=\"$(_locator_line \"$oloc\" | head -n1)\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/reconcile.sh:sed -n \"s/^${key}=//p\" \"$MANIFEST\" | head -n1 | tr -d '[:space:]'"
|
|
||||||
]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
[
|
|
||||||
"packages/mosaic/framework/systemd/user/test-fleet-units.sh:if tmux -L \"$TEST_SOCKET\" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then",
|
|
||||||
"packages/mosaic/framework/tools/git/test-issue-comment-readback.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
|
|
||||||
"packages/mosaic/framework/tools/git/test-issue-comment-readback.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
|
|
||||||
"packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh:contains() { printf '%s\\n' \"$1\" | grep -qx \"$2\"; }",
|
|
||||||
"packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
|
|
||||||
"packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh:echo \"$HELP_TEXT\" | grep -q -- '-r, --repo'",
|
|
||||||
"packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh:echo \"$HELP_TEXT\" | grep -q -- '-H, --host'",
|
|
||||||
"packages/mosaic/framework/tools/orchestrator/smoke-test.sh:if [[ \"$(printf '%s\\n' \"$codex_run_prompt\" | head -n1)\" == \"Now initiating Orchestrator mode...\" ]]; then pass_case \"codex run prompt first line is mode declaration\"; else fail_case \"codex run prompt first line is mode declaration\"; fi",
|
|
||||||
"packages/mosaic/framework/tools/orchestrator/smoke-test.sh:if [[ \"$(printf '%s\\n' \"$claude_run_prompt\" | head -n1)\" == \"## Continuation Mission\" ]]; then pass_case \"claude run prompt remains continuation prompt format\"; else fail_case \"claude run prompt remains continuation prompt format\"; fi",
|
|
||||||
"packages/mosaic/framework/tools/orchestrator/test-board-roll.sh:echo \"$out\" | grep -qi \"dry run\" || note \"dry-run did not announce itself\"",
|
|
||||||
"packages/mosaic/framework/tools/orchestrator/test-board-roll.sh:echo \"$out\" | grep -q \"would roll\" || note \"dry-run did not report a plan\"",
|
|
||||||
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh:find \"$1/mosaic/backups\" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null | LC_ALL=C sort -r | head -1",
|
|
||||||
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh:SNAP_E=\"$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' \"$OUTG\" | head -1)\"",
|
|
||||||
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh:grep -o '/[^ ]*mosaic-snapshot[^ ]*' \"$OUTH\" 2>/dev/null | head -1 | while read -r s; do rm -rf \"$s\"; done",
|
|
||||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"named socket hello\" || fail \"send-message.sh did not deliver to named socket\"",
|
|
||||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if capture_default | grep -qF \"named socket hello\"; then",
|
|
||||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"[tester:source ->\" || fail \"agent-send.sh did not include preamble\"",
|
|
||||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"agent socket hello\" || fail \"agent-send.sh did not deliver to named socket\"",
|
|
||||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if capture_default | grep -qF \"agent socket hello\"; then",
|
|
||||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:printf '%s' \"$pane\" | grep -qF \"CONCPAYLOAD-${i}-END\" || fail \"concurrent send dropped payload for pane conc-$i\"",
|
|
||||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if printf '%s' \"$pane\" | grep -qF \"CONCPAYLOAD-${j}-END\"; then",
|
|
||||||
"packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh:if [ \"$rc\" -eq 0 ] && printf '%s' \"$out\" | grep -qF \"✓ delivered\"; then"
|
|
||||||
]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
[
|
|
||||||
"packages/mosaic/framework/tools/wake/test-wake-digest-quarantine.sh:fixture_line=\"$(has_match -F \"\\\"id\\\":\\\"$enum_id\\\"\" \"$self\" | has_match -F '\"observed_seq\":5' | head -n1)\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:src_seq=\"$(jq -r 'select(.locators.kind == \"repo\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:enum_seq=\"$(jq -r 'select(.locators.reconciled == true) | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:if ! sed -n \"${ln}p\" \"$f\" | grep -Eq 'has_match|count_lines'; then",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outA\" | grep -q 'mini-a: OK' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outB\" | grep -q 'mini-b: OK' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:check C1 1 \"rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff \"$TMP/expected-c1\" \"$TMP/got-c1\" 2>&1 | head -n 10 | tr '\\n' ' ')\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: FAILED' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ABORT\" &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"$site\" &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"grep exit 2\" &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'WAKE-ASSERT ARMED'; then",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'REACHED-PAST-INIT' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'all invariants passed' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:diff \"$TMP/expected.txt\" \"$TMP/static.txt\" | head -n 20 | sed 's/^/ /'",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:if printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$s\")\"; then",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" ||",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit\" ||",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$f\")\" || rc_sent=$?"
|
|
||||||
]
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
const ROOT = new URL('../', import.meta.url);
|
|
||||||
const EXPECTED_BASELINE_SITES = 26;
|
|
||||||
const EXPECTED_TEST_BASELINE_SITES = 22;
|
|
||||||
const EXPECTED_WAKE_BASELINE_SITES = 26;
|
|
||||||
const TARGETS = [
|
|
||||||
'tools/matrix-presence-harness/run.sh',
|
|
||||||
'tools/e2e-install-test.sh',
|
|
||||||
'tools/install.sh',
|
|
||||||
'scripts/agent/session-start.sh',
|
|
||||||
'scripts/analysis/reflect-board-history.sh',
|
|
||||||
'scripts/analysis/reflect-git-history.sh',
|
|
||||||
'packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh',
|
|
||||||
'packages/mosaic/framework/tools/authentik/user-create.sh',
|
|
||||||
'packages/mosaic/framework/tools/git/mutate-push-guard.sh',
|
|
||||||
'packages/mosaic/framework/tools/orchestrator/session-resume.sh',
|
|
||||||
'packages/mosaic/framework/tools/prdy/prdy-status.sh',
|
|
||||||
'packages/mosaic/framework/tools/qa/reflect-stop-hook.sh',
|
|
||||||
'packages/mosaic/framework/tools/qa/typecheck-hook.sh',
|
|
||||||
'packages/mosaic/framework/tools/tmux/send-message.sh',
|
|
||||||
'packages/mosaic/framework/tools/wake/detector.sh',
|
|
||||||
'packages/mosaic/framework/tools/wake/digest.sh',
|
|
||||||
'packages/mosaic/framework/tools/wake/reconcile.sh',
|
|
||||||
'packages/mosaic/framework/systemd/user/test-fleet-units.sh',
|
|
||||||
'packages/mosaic/framework/tools/git/test-issue-comment-readback.sh',
|
|
||||||
'packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh',
|
|
||||||
'packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh',
|
|
||||||
'packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh',
|
|
||||||
'packages/mosaic/framework/tools/orchestrator/smoke-test.sh',
|
|
||||||
'packages/mosaic/framework/tools/orchestrator/test-board-roll.sh',
|
|
||||||
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh',
|
|
||||||
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh',
|
|
||||||
'packages/mosaic/framework/tools/tmux/test-send-message-socket.sh',
|
|
||||||
'packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh',
|
|
||||||
'packages/mosaic/framework/tools/wake/test-wake-digest-quarantine.sh',
|
|
||||||
'packages/mosaic/framework/tools/wake/test-wake-preimage.sh',
|
|
||||||
'packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh',
|
|
||||||
'packages/mosaic/framework/tools/wake/validate-973/validate-973.sh',
|
|
||||||
];
|
|
||||||
|
|
||||||
// These statuses are explicitly non-load-bearing or unreachable at designed input.
|
|
||||||
// They remain inventoried until the final #1099 tranche records every verdict.
|
|
||||||
const ACCEPTED = [
|
|
||||||
['tools/install.sh', 'mosaic-bak-', '|| true'],
|
|
||||||
['tools/install.sh', 'mosaicstack-mosaic-*.tgz', 'head -1'],
|
|
||||||
['tools/install.sh', 'mosaicstack-gateway-*.tgz', 'head -1'],
|
|
||||||
['scripts/agent/session-start.sh', 'docs/scratchpads/*.md', '|| true'],
|
|
||||||
[
|
|
||||||
'packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh',
|
|
||||||
'docs/scratchpads/*.md',
|
|
||||||
'|| true',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
const earlyExit =
|
|
||||||
/(?<!\|)\|(?!\|)[^;\n]*(?:grep\b[^;\n]*(?:-[A-Za-z]*q|--quiet|-m\s*1)|head\b(?:\s|$))/;
|
|
||||||
|
|
||||||
function scan(sources) {
|
|
||||||
const found = [];
|
|
||||||
for (const [file, rawSource] of sources) {
|
|
||||||
const source = rawSource.replace(/\\\n\s*/g, ' ');
|
|
||||||
for (const rawLine of source.split('\n')) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!earlyExit.test(line)) continue;
|
|
||||||
const accepted = ACCEPTED.some(
|
|
||||||
([acceptedFile, ...fragments]) =>
|
|
||||||
acceptedFile === file && fragments.every((item) => line.includes(item)),
|
|
||||||
);
|
|
||||||
if (!accepted) found.push(`${file}:${line}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function currentSources() {
|
|
||||||
return Promise.all(
|
|
||||||
TARGETS.map(async (file) => [file, await readFile(new URL(file, ROOT), 'utf8')]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertBaselineFixture(file, expectedCount, expectedUnique = expectedCount) {
|
|
||||||
const baseline = JSON.parse(await readFile(new URL(file, ROOT), 'utf8'));
|
|
||||||
assert.equal(baseline.length, expectedCount);
|
|
||||||
assert.equal(new Set(baseline).size, expectedUnique);
|
|
||||||
const fixtureSources = baseline.map((site) => {
|
|
||||||
const separator = site.indexOf(':');
|
|
||||||
assert.ok(separator > 0, `invalid baseline site: ${site}`);
|
|
||||||
return [site.slice(0, separator), site.slice(separator + 1)];
|
|
||||||
});
|
|
||||||
assert.deepEqual(scan(fixtureSources), baseline);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('the registered runtime baseline denominator is exactly 26 unsafe sites', async () => {
|
|
||||||
await assertBaselineFixture(
|
|
||||||
'scripts/fixtures/pipefail-early-exit-baseline.json',
|
|
||||||
EXPECTED_BASELINE_SITES,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the registered test baseline denominator is exactly 22 unsafe sites', async () => {
|
|
||||||
await assertBaselineFixture(
|
|
||||||
'scripts/fixtures/pipefail-early-exit-test-baseline.json',
|
|
||||||
EXPECTED_TEST_BASELINE_SITES,
|
|
||||||
21,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the registered wake baseline denominator is exactly 26 unsafe sites', async () => {
|
|
||||||
await assertBaselineFixture(
|
|
||||||
'scripts/fixtures/pipefail-early-exit-wake-baseline.json',
|
|
||||||
EXPECTED_WAKE_BASELINE_SITES,
|
|
||||||
25,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('load-bearing pipefail paths do not pipe into early-exiting consumers', async () => {
|
|
||||||
assert.deepEqual(scan(await currentSources()), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gateway verify capability preserves the complete help-probe truth table', async () => {
|
|
||||||
const directory = await mkdtemp(path.join(tmpdir(), 'gateway-help-probe-'));
|
|
||||||
const mosaic = path.join(directory, 'mosaic');
|
|
||||||
const probe = new URL('tools/e2e-gateway-verify-supported.sh', ROOT).pathname;
|
|
||||||
try {
|
|
||||||
await writeFile(
|
|
||||||
mosaic,
|
|
||||||
'#!/usr/bin/env bash\nprintf \'%s\\n\' "${MOCK_HELP_OUTPUT:-}"\nexit "${MOCK_HELP_RC:-0}"\n',
|
|
||||||
);
|
|
||||||
await chmod(mosaic, 0o755);
|
|
||||||
const run = (rc, output) =>
|
|
||||||
spawnSync('bash', [probe], {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
PATH: `${directory}:${process.env.PATH}`,
|
|
||||||
MOCK_HELP_RC: String(rc),
|
|
||||||
MOCK_HELP_OUTPUT: output,
|
|
||||||
},
|
|
||||||
}).status;
|
|
||||||
|
|
||||||
assert.equal(run(0, 'commands: verify'), 0);
|
|
||||||
assert.equal(run(0, 'commands: install'), 1);
|
|
||||||
assert.equal(run(1, 'commands: verify'), 1);
|
|
||||||
} finally {
|
|
||||||
await rm(directory, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('wake JSONL selectors take the first match across the complete input stream', async () => {
|
|
||||||
const source = await readFile(
|
|
||||||
new URL('packages/mosaic/framework/tools/wake/test-wake-preimage.sh', ROOT),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
assert.equal((source.match(/jq -nr 'first\(inputs \| select\(/g) ?? []).length, 4);
|
|
||||||
|
|
||||||
const directory = await mkdtemp(path.join(tmpdir(), 'wake-jsonl-first-'));
|
|
||||||
const input = path.join(directory, 'pending.jsonl');
|
|
||||||
const filter = 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty';
|
|
||||||
try {
|
|
||||||
await writeFile(
|
|
||||||
input,
|
|
||||||
'{"locators":{"kind":"repo"},"observed_seq":1}\n' +
|
|
||||||
'{"locators":{"kind":"preimage"},"observed_seq":4}\n' +
|
|
||||||
'{"locators":{"kind":"preimage"},"observed_seq":9}\n',
|
|
||||||
);
|
|
||||||
let result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
|
||||||
assert.equal(result.status, 0, result.stderr);
|
|
||||||
assert.equal(result.stdout, '4\n');
|
|
||||||
|
|
||||||
await writeFile(input, '{"locators":{"kind":"repo"},"observed_seq":1}\n');
|
|
||||||
result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
|
||||||
assert.equal(result.status, 0, result.stderr);
|
|
||||||
assert.equal(result.stdout, '');
|
|
||||||
|
|
||||||
await writeFile(input, '{invalid json}\n');
|
|
||||||
result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
|
||||||
assert.notEqual(result.status, 0);
|
|
||||||
} finally {
|
|
||||||
await rm(directory, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('board-history preserves non-git data-dir as a non-detectable result', async () => {
|
|
||||||
const directory = await mkdtemp(path.join(tmpdir(), 'reflect-board-non-git-'));
|
|
||||||
try {
|
|
||||||
await writeFile(path.join(directory, 'task.json'), '{}\n');
|
|
||||||
const result = spawnSync(
|
|
||||||
'bash',
|
|
||||||
[
|
|
||||||
new URL('scripts/analysis/reflect-board-history.sh', ROOT).pathname,
|
|
||||||
'--data-dir',
|
|
||||||
directory,
|
|
||||||
],
|
|
||||||
{ encoding: 'utf8' },
|
|
||||||
);
|
|
||||||
assert.equal(result.status, 0, result.stderr);
|
|
||||||
assert.match(result.stdout, /"done_tasks": 1/);
|
|
||||||
assert.match(result.stdout, /"detectable_outcomes": 0/);
|
|
||||||
} finally {
|
|
||||||
await rm(directory, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Exit 0 only when the capability probe itself succeeds and advertises verify.
|
|
||||||
# A failed help command and a successful response without verify are both
|
|
||||||
# unsupported, matching the historical e2e-install-test.sh conditional.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
gateway_help_rc=0
|
|
||||||
gateway_help="$(mosaic gateway --help 2>&1)" || gateway_help_rc=$?
|
|
||||||
[[ "$gateway_help_rc" -eq 0 ]] || exit 1
|
|
||||||
grep -q 'verify' <<<"$gateway_help"
|
|
||||||
@@ -136,7 +136,7 @@ fi
|
|||||||
echo "=== [inner] Running mosaic gateway verify ==="
|
echo "=== [inner] Running mosaic gateway verify ==="
|
||||||
# `gateway verify` was added in feat/mosaic-first-run-ux.
|
# `gateway verify` was added in feat/mosaic-first-run-ux.
|
||||||
# If the installed version pre-dates this, skip gracefully.
|
# If the installed version pre-dates this, skip gracefully.
|
||||||
if ! bash /repo/tools/e2e-gateway-verify-supported.sh; then
|
if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then
|
||||||
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
|
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
|
||||||
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
|
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
|
||||||
echo "[SKIP] Re-run after the new version is published to validate this step."
|
echo "[SKIP] Re-run after the new version is published to validate this step."
|
||||||
|
|||||||
+4
-8
@@ -308,17 +308,13 @@ ensure_monorepo() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Gitea archives extract to exactly one <repo-name>/ inside the work dir.
|
# Gitea archives extract to <repo-name>/ inside the work dir
|
||||||
# Read the complete population so a malformed multi-root archive reaches the
|
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||||||
# named diagnostic instead of aborting on an upstream SIGPIPE under pipefail.
|
if [[ -z "$EXTRACTED_DIR" ]] || [[ ! -d "$EXTRACTED_DIR" ]]; then
|
||||||
local -a extracted_dirs=()
|
fail "Could not locate extracted source in archive."
|
||||||
mapfile -d '' -t extracted_dirs < <(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d -print0)
|
|
||||||
if [[ "${#extracted_dirs[@]}" -ne 1 ]] || [[ ! -d "${extracted_dirs[0]:-}" ]]; then
|
|
||||||
fail "Could not locate exactly one extracted source directory in archive."
|
|
||||||
ls -la "$WORK_DIR" >&2
|
ls -la "$WORK_DIR" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
EXTRACTED_DIR="${extracted_dirs[0]}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
|
# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ export DARK_THRESHOLD_MS="${DARK_THRESHOLD_MS:-6000}"
|
|||||||
export AGENT_SLUGS="${AGENT_SLUGS:-alpha,bravo,charlie}"
|
export AGENT_SLUGS="${AGENT_SLUGS:-alpha,bravo,charlie}"
|
||||||
export VICTIM_SLUG="${VICTIM_SLUG:-charlie}"
|
export VICTIM_SLUG="${VICTIM_SLUG:-charlie}"
|
||||||
|
|
||||||
shopt -s nullglob
|
TSX_CLI="$(ls -d "${REPO}"/node_modules/.pnpm/tsx@*/node_modules/tsx/dist/cli.mjs 2>/dev/null | head -1)"
|
||||||
TSX_CANDIDATES=("${REPO}"/node_modules/.pnpm/tsx@*/node_modules/tsx/dist/cli.mjs)
|
|
||||||
shopt -u nullglob
|
|
||||||
TSX_CLI="${TSX_CANDIDATES[0]:-}"
|
|
||||||
if [[ -z "${TSX_CLI}" ]]; then
|
if [[ -z "${TSX_CLI}" ]]; then
|
||||||
echo "run.sh: tsx not found under node_modules — run pnpm install first" >&2
|
echo "run.sh: tsx not found under node_modules — run pnpm install first" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Reference in New Issue
Block a user