Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39312fa455 | ||
|
|
ed102bec80 | ||
|
|
449e80df18 | ||
|
|
ebf518215a | ||
|
|
aa0a7b5fa2 | ||
|
|
42ac19af48 | ||
|
|
f744f32214 | ||
|
|
8ff7aac0ca |
@@ -46,6 +46,10 @@ steps:
|
|||||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||||
# with everything it guards; this direct line keeps one instrument running.
|
# with everything it guards; this direct line keeps one instrument running.
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||||
|
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
|
||||||
|
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||||
|
# joins CI directly rather than the exclusions file.
|
||||||
|
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||||
|
|
||||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||||
|
|||||||
@@ -245,9 +245,21 @@ describe('EnrollmentService.createToken', () => {
|
|||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
|
|
||||||
const expiresMs = new Date(result.expiresAt).getTime();
|
const expiresMs = new Date(result.expiresAt).getTime();
|
||||||
// Should be at most 900s from now
|
|
||||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
// The property under test is CLAMPING: a 9999s request must come back as 900s.
|
||||||
|
// The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below
|
||||||
|
// only has to exceed CI scheduling jitter — it does not need to be tight to keep
|
||||||
|
// the assertion discriminating. A 5s allowance consumes 0.05% of that margin and
|
||||||
|
// an unclamped result still misses by three orders of magnitude.
|
||||||
|
//
|
||||||
|
// It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A
|
||||||
|
// wall-clock budget sized to a fast machine is a flake, not a tighter test.
|
||||||
|
const CI_JITTER_MS = 5_000;
|
||||||
|
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS);
|
||||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
||||||
|
// Explicitly pin the clamp itself, independent of any timing allowance:
|
||||||
|
// unclamped (9999s) would exceed this by ~9_099_000 ms.
|
||||||
|
expect(expiresMs - before).toBeLessThan(1_000_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+30
@@ -146,6 +146,36 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Exact Cross-Harness Fleet Communications Contract (#766)
|
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||||
|
|||||||
+13
-10
@@ -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. |
|
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. |
|
||||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||||
| 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,6 +24,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
|
MOSAIC_GIT_IDENTITY=<roster name>
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -33,8 +34,10 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
||||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
split runtime and repository identity authority. The generated launch contract supports `claude`,
|
||||||
|
`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or
|
||||||
|
modifies generated, local, or quarantine state.
|
||||||
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
||||||
it has no generated-launch adapter and cannot be added through this path.
|
it has no generated-launch adapter and cannot be added through this path.
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
The launcher consumes validated data, not shell configuration.
|
The launcher consumes validated data, not shell configuration.
|
||||||
|
|
||||||
1. Read and validate the canonical roster.
|
1. Read and validate the canonical roster.
|
||||||
2. Render deterministic <name>.env.generated data from that roster.
|
2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
|
||||||
3. Parse optional <name>.env.local through a strict allowlist.
|
3. Parse optional <name>.env.local through a strict allowlist.
|
||||||
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
||||||
5. Derive the runtime command from validated runtime/model/reasoning data.
|
5. Reject a Git identity that is unsafe or differs from the generated agent name.
|
||||||
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
|
||||||
|
7. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||||
|
|
||||||
## File precedence and ownership
|
## File precedence and ownership
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ values, credential material, or command text.
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
|
MOSAIC_GIT_IDENTITY=<roster name>
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -44,8 +45,9 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
|
||||||
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
|
||||||
|
`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||||
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
||||||
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
||||||
this projection path.
|
this projection path.
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# #1043 — Fleet pane git-identity propagation
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison.
|
||||||
|
|
||||||
|
## Tracking
|
||||||
|
|
||||||
|
- External issue: `mosaicstack/stack#1043`
|
||||||
|
- Branch: `fix/1043-pane-git-identity`
|
||||||
|
- Coordinator: `tl-mosaic`
|
||||||
|
- `docs/TASKS.md`: read-only by project worker contract; not modified.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- RED-first bug reproducer is mandatory.
|
||||||
|
- R7 delete-the-subject mutation must turn the behavioral test red.
|
||||||
|
- Assert launched-process environment, not source text.
|
||||||
|
- One push only; do not poll CI after push.
|
||||||
|
- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence.
|
||||||
|
- Do not modify a live host launcher or obtain/copy another credential.
|
||||||
|
- Self-post the PR, verify provider attribution, then stop.
|
||||||
|
- Final status wording: `believed-fixed, pending jarvis validation`.
|
||||||
|
|
||||||
|
## Scope inventory
|
||||||
|
|
||||||
|
Re-derived against `origin/main` at `85d2108e`:
|
||||||
|
|
||||||
|
- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
||||||
|
- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||||
|
- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts`
|
||||||
|
- Roster projection producers:
|
||||||
|
- `packages/mosaic/src/commands/fleet.ts`
|
||||||
|
- `packages/mosaic/src/fleet/fleet-reconciler.ts`
|
||||||
|
- `packages/mosaic/src/fleet/fleet-agent-crud.ts`
|
||||||
|
- `packages/mosaic/src/fleet/v1-v2-migration.ts`
|
||||||
|
- Contract and producer tests discovered by repository search.
|
||||||
|
- Generated-environment operator/developer docs and their executable documentation contract test.
|
||||||
|
|
||||||
|
Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add the process-environment set-comparison regression first and record RED.
|
||||||
|
2. Add roster-derived `MOSAIC_GIT_IDENTITY=<agent name>` to the complete generated projection contract.
|
||||||
|
3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment.
|
||||||
|
4. Update affected projection tests and generated-environment docs.
|
||||||
|
5. Run focused and baseline gates.
|
||||||
|
6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN.
|
||||||
|
7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor.
|
||||||
|
|
||||||
|
## Evidence log
|
||||||
|
|
||||||
|
### TDD and mutation evidence
|
||||||
|
|
||||||
|
- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity.
|
||||||
|
- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`.
|
||||||
|
- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||||
|
- R7 restoration: restored that launch entry; the same test returned green.
|
||||||
|
- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy.
|
||||||
|
|
||||||
|
### Situational and focused tests
|
||||||
|
|
||||||
|
- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity.
|
||||||
|
- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green.
|
||||||
|
- Focused Mosaic Vitest set: 6 files, 311 tests — green.
|
||||||
|
- `bash -n` on changed shell files — green.
|
||||||
|
- `git diff --check` — green.
|
||||||
|
|
||||||
|
### Baseline gates
|
||||||
|
|
||||||
|
- `pnpm typecheck` — 45/45 tasks green.
|
||||||
|
- `pnpm lint` — 25/25 tasks green.
|
||||||
|
- `pnpm format:check` — green.
|
||||||
|
- `pnpm test:checkout` — green.
|
||||||
|
- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase.
|
||||||
|
- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates:
|
||||||
|
1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr;
|
||||||
|
2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`).
|
||||||
|
No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter.
|
||||||
|
|
||||||
|
### Independent review
|
||||||
|
|
||||||
|
- Codex code review first pass: request changes for missing shell rejection-path coverage.
|
||||||
|
- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call.
|
||||||
|
- Codex code re-review: **approve**, no findings, confidence 0.88.
|
||||||
|
- Codex security review: risk `none`, no findings, confidence 0.97.
|
||||||
|
|
||||||
|
### Acceptance criteria mapping
|
||||||
|
|
||||||
|
| Acceptance criterion | Evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above |
|
||||||
|
| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests |
|
||||||
|
| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly |
|
||||||
|
|
||||||
|
### Documentation checklist
|
||||||
|
|
||||||
|
- PRD updated with #1043 requirements and acceptance criteria.
|
||||||
|
- Fleet launch runbook, generated-env concept, and generated-env reference updated.
|
||||||
|
- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies.
|
||||||
|
- `docs/TASKS.md` remains unmodified per its single-writer project contract.
|
||||||
|
|
||||||
|
## Round 2 — PR #1073 review 97 remediation
|
||||||
|
|
||||||
|
### Review blocker
|
||||||
|
|
||||||
|
The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression.
|
||||||
|
|
||||||
|
### RED-first and canonical wiring
|
||||||
|
|
||||||
|
1. Removed the suite's signed exclusion before adding a CI execution path.
|
||||||
|
2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18.
|
||||||
|
3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain.
|
||||||
|
4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition.
|
||||||
|
|
||||||
|
### Workflow-level R7
|
||||||
|
|
||||||
|
- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`.
|
||||||
|
- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain.
|
||||||
|
- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||||
|
- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort.
|
||||||
|
- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical.
|
||||||
|
|
||||||
|
### Workflow population
|
||||||
|
|
||||||
|
- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||||
|
- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses.
|
||||||
|
- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change.
|
||||||
|
|
||||||
|
### Independent remediation review
|
||||||
|
|
||||||
|
- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install.
|
||||||
|
- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged.
|
||||||
|
- Codex re-review: **APPROVE**, confidence 0.93, no findings.
|
||||||
|
|
||||||
|
### Vitest denominator reconciliation
|
||||||
|
|
||||||
|
The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees:
|
||||||
|
|
||||||
|
- generated environment boundary: 24
|
||||||
|
- fleet documentation: 23
|
||||||
|
- Tess service profile: 6
|
||||||
|
- fleet regen command: 27
|
||||||
|
- fleet agent CRUD command: 22
|
||||||
|
- fleet command: 209
|
||||||
|
- total: **311**
|
||||||
|
|
||||||
|
Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic.
|
||||||
|
|
||||||
|
## Round 3 — Alpine stale-marker portability
|
||||||
|
|
||||||
|
### Objective and plan
|
||||||
|
|
||||||
|
- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion.
|
||||||
|
- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review.
|
||||||
|
- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling.
|
||||||
|
- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes.
|
||||||
|
|
||||||
|
### RED-first evidence
|
||||||
|
|
||||||
|
Before the fix, the canonical CI image command
|
||||||
|
`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||||
|
exited 1 at the stale-marker setup with exact BusyBox output
|
||||||
|
`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location.
|
||||||
|
|
||||||
|
### Root cause and fix
|
||||||
|
|
||||||
|
The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata.
|
||||||
|
|
||||||
|
### Structural pattern
|
||||||
|
|
||||||
|
This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening.
|
||||||
|
|
||||||
|
### GREEN and quality evidence
|
||||||
|
|
||||||
|
- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`.
|
||||||
|
- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces.
|
||||||
|
- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed.
|
||||||
|
- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN.
|
||||||
|
- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings.
|
||||||
|
- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings.
|
||||||
|
|
||||||
|
### Re-derived inventory and denominators
|
||||||
|
|
||||||
|
- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization.
|
||||||
|
- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path.
|
||||||
|
- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||||
|
- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets.
|
||||||
|
|
||||||
|
## Round 4 — bound stale-marker observation
|
||||||
|
|
||||||
|
### Objective and plan
|
||||||
|
|
||||||
|
- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window.
|
||||||
|
- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale.
|
||||||
|
- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image.
|
||||||
|
- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling.
|
||||||
|
- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged.
|
||||||
|
|
||||||
|
### Root cause and bounded observation
|
||||||
|
|
||||||
|
The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion.
|
||||||
|
|
||||||
|
The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed.
|
||||||
|
|
||||||
|
### Two-mutant RED / restored GREEN
|
||||||
|
|
||||||
|
All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`:
|
||||||
|
|
||||||
|
1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`.
|
||||||
|
2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls.
|
||||||
|
3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`.
|
||||||
|
|
||||||
|
### Re-derived inventory
|
||||||
|
|
||||||
|
- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty.
|
||||||
|
- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path.
|
||||||
|
- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds.
|
||||||
|
- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation.
|
||||||
|
|
||||||
|
## Residual risk
|
||||||
|
|
||||||
|
- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events.
|
||||||
|
- Canonical CI result is pending and will not be polled by this seat.
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# W-B — Measure Pi's real tool registry
|
|
||||||
|
|
||||||
- **Task / internal ref:** W-B from the lease-remediation orchestrator brief (no matching `docs/TASKS.md` row; workers do not modify that file)
|
|
||||||
- **Objective:** identify the exact tool names emitted as `event.toolName` by the installed Pi runtime and compare them with the broker's Pi read-only carve-out.
|
|
||||||
- **Scope:** measurement and report only; no broker or runtime source changes. W-C is out of scope.
|
|
||||||
- **Budget:** no explicit token cap; constrained to this scratchpad and one local commit.
|
|
||||||
- **Installed runtime:** `@earendil-works/pi-coding-agent` / `pi` `0.84.1`.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
I created a throwaway extension at `/tmp/measure-pi-tool-registry.ts` (not in the worktree). On `session_start` it recorded `pi.getAllTools()` and `pi.getActiveTools()`; on every `tool_call` it appended the exact `event.toolName`. I then launched an isolated, ephemeral Pi session with all built-ins explicitly selected:
|
|
||||||
|
|
||||||
```text
|
|
||||||
PI_OFFLINE=1 pi --mode print --no-session --no-approve \
|
|
||||||
--no-context-files --no-skills --no-prompt-templates --no-extensions \
|
|
||||||
-e /tmp/measure-pi-tool-registry.ts \
|
|
||||||
--tools read,bash,edit,write,grep,find,ls <deterministic probe prompt>
|
|
||||||
```
|
|
||||||
|
|
||||||
The prompt exercised file read, content search, file search, directory listing, shell execution, file write, and file edit. Pi exited `0`; every selected tool produced one `tool_call`. The write/edit control artifact ended with exact content `after`, proving the mutating calls executed in order.
|
|
||||||
|
|
||||||
This runtime observation was cross-checked against the installed distribution's canonical registry at `dist/core/tools/index.js:17`, which declares the same seven names. The gate consumes the measured field directly at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:368`.
|
|
||||||
|
|
||||||
## Exact distinct built-in set
|
|
||||||
|
|
||||||
The installed Pi built-in registry is exactly:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{bash, edit, find, grep, ls, read, write}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Tool | Runtime registry observation | `tool_call` observation | Installed definition |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `read` | `<builtin:read>` | observed once | `dist/core/tools/read.js:138` |
|
|
||||||
| `bash` | `<builtin:bash>` | observed once | `dist/core/tools/bash.js:231` |
|
|
||||||
| `edit` | `<builtin:edit>` | observed once | `dist/core/tools/edit.js:170` |
|
|
||||||
| `write` | `<builtin:write>` | observed once | `dist/core/tools/write.js:138` |
|
|
||||||
| `grep` | `<builtin:grep>` | observed once | `dist/core/tools/grep.js:79` |
|
|
||||||
| `find` | `<builtin:find>` | observed once | `dist/core/tools/find.js:79` |
|
|
||||||
| `ls` | `<builtin:ls>` | observed once | `dist/core/tools/ls.js:61` |
|
|
||||||
|
|
||||||
The raw distinct `event.toolName` result was:
|
|
||||||
|
|
||||||
```json
|
|
||||||
["bash", "edit", "find", "grep", "ls", "read", "write"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Pi registers all seven, but its default active set is only `read`, `bash`, `edit`, and `write` (`dist/core/sdk.js:132`). The probe explicitly activated all seven so the three search/list tools could be observed at the hook.
|
|
||||||
|
|
||||||
## Positive control
|
|
||||||
|
|
||||||
The known `read` tool was the control. The method surfaced it twice:
|
|
||||||
|
|
||||||
1. `pi.getAllTools()` returned `read` with source path `<builtin:read>`.
|
|
||||||
2. Reading `/tmp/pi-registry-probe/seed.txt`, which contained `CONTROL_TOKEN`, produced one hook record with `event.toolName === "read"`.
|
|
||||||
|
|
||||||
The control was therefore positive; the seven-name result is measured, not an empty-probe inference.
|
|
||||||
|
|
||||||
## Carve-out comparison and collision result
|
|
||||||
|
|
||||||
The broker currently declares `{"read", "grep", "find", "ls"}` at `packages/mosaic/framework/tools/lease-broker/daemon.py:54`.
|
|
||||||
|
|
||||||
- `read`: real built-in.
|
|
||||||
- `grep`: real built-in.
|
|
||||||
- `find`: real built-in.
|
|
||||||
- `ls`: real built-in.
|
|
||||||
|
|
||||||
All four carve-out names are exact, case-sensitive Pi tool names.
|
|
||||||
|
|
||||||
The general execution/writing tool names are `bash`, `edit`, and `write`. Their intersection with the carve-out is empty:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{bash, edit, write} ∩ {read, grep, find, ls} = ∅
|
|
||||||
```
|
|
||||||
|
|
||||||
Therefore no general shell-exec or file-mutating Pi tool shares a name with a carve-out entry. `grep` and `find` may invoke constrained search helpers internally, but neither exposes an arbitrary command interface; the arbitrary command tool is distinctly named `bash`.
|
|
||||||
|
|
||||||
The Mosaic extension separately registers the non-built-in custom tool `mosaic_context_recover` at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:379`; the broker handles that identity through its dedicated recovery exemption rather than the read-only set (`daemon.py:722`). Unknown or third-party custom tools are not part of Pi's built-in seven-name registry and remain outside the carve-out.
|
|
||||||
|
|
||||||
## Verification evidence
|
|
||||||
|
|
||||||
- `pi --version` → `0.84.1`.
|
|
||||||
- Isolated probe exit → `0`.
|
|
||||||
- Runtime `getAllTools()` count → `7`, all with `sourceInfo.source === "builtin"`.
|
|
||||||
- Distinct hook names → `bash`, `edit`, `find`, `grep`, `ls`, `read`, `write`.
|
|
||||||
- Hook counts → exactly one call for each of the seven names.
|
|
||||||
- Mutation artifact after `write` then `edit` → exact content `after`.
|
|
||||||
- Installed registry source → `allToolNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"])`.
|
|
||||||
|
|
||||||
## Risks / limitations
|
|
||||||
|
|
||||||
- The probe deliberately disabled all other extensions, so extension-defined third-party tools were excluded from the built-in registry measurement. The production gate still receives those names and treats names outside the broker carve-out as mutating/fail-closed.
|
|
||||||
- Explicit `--tools` activation was required to exercise `grep`, `find`, and `ls`; this does not imply they are active in Pi's default four-tool configuration.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
I invoked this registered command to authorize lease promotion; follow the local seat broker's injected receipt confirmation instruction exactly.
|
|
||||||
@@ -32,18 +32,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"UserPromptSubmit": [
|
|
||||||
{
|
|
||||||
"matcher": "^/mosaic-promote$",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
|
||||||
"timeout": 15
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
{
|
{
|
||||||
"matcher": ".*",
|
"matcher": ".*",
|
||||||
@@ -93,8 +81,8 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
|
||||||
"timeout": 15
|
"timeout": 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ EOF
|
|||||||
chmod 700 "$AGENT_HOME/fleet/agents"
|
chmod 700 "$AGENT_HOME/fleet/agents"
|
||||||
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$AGENT_NAME
|
MOSAIC_AGENT_NAME=$AGENT_NAME
|
||||||
|
MOSAIC_GIT_IDENTITY=$AGENT_NAME
|
||||||
MOSAIC_AGENT_CLASS=code
|
MOSAIC_AGENT_CLASS=code
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=
|
MOSAIC_AGENT_MODEL=
|
||||||
|
|||||||
@@ -153,24 +153,7 @@ if [[ $link_only -eq 1 ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Skills are linked into the MOSAIC-OWNED harness homes, never a base install.
|
|
||||||
# Paths mirror the config-dir env vars the launcher injects (HARNESS_HOME_ENV in
|
|
||||||
# commands/launch.js):
|
|
||||||
# claude CLAUDE_CONFIG_DIR -> <home>/skills
|
|
||||||
# pi PI_CODING_AGENT_DIR -> <home>/skills (replaces ~/.pi/agent)
|
|
||||||
# codex CODEX_HOME -> <home>/skills
|
|
||||||
# opencode XDG_CONFIG_HOME -> <home>/opencode/skills (XDG adds a level)
|
|
||||||
link_targets=(
|
link_targets=(
|
||||||
"$MOSAIC_HOME/.claude/skills"
|
|
||||||
"$MOSAIC_HOME/.codex/skills"
|
|
||||||
"$MOSAIC_HOME/.opencode/opencode/skills"
|
|
||||||
"$MOSAIC_HOME/.pi/skills"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pre-isolation installs planted the same symlink farm directly in the operator's
|
|
||||||
# base installs. Those are now orphaned: the launcher no longer reads them, but
|
|
||||||
# they persist and make a "clean" base install look mosaic-managed.
|
|
||||||
legacy_link_targets=(
|
|
||||||
"$HOME/.claude/skills"
|
"$HOME/.claude/skills"
|
||||||
"$HOME/.codex/skills"
|
"$HOME/.codex/skills"
|
||||||
"$HOME/.config/opencode/skills"
|
"$HOME/.config/opencode/skills"
|
||||||
@@ -262,72 +245,13 @@ prune_stale_links_in_target() {
|
|||||||
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
||||||
# is unproven and the link must be preserved.
|
# is unproven and the link must be preserved.
|
||||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||||
# $canonical_real must be length-checked BEFORE use as a prefix: if it were
|
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
|
||||||
# ever empty, "$resolved" == "$canonical_real/"* collapses to == "/"* and
|
|
||||||
# matches every absolute path. Combined with the is_mosaic_skill_name skip
|
|
||||||
# above, that inverts the function precisely — it would delete exactly the
|
|
||||||
# FOREIGN symlinks and keep the mosaic ones. (#1087, reported by mos-claude.)
|
|
||||||
if [[ -n "$resolved" && -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
|
|
||||||
rm -f "$link_path"
|
rm -f "$link_path"
|
||||||
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
||||||
fi
|
fi
|
||||||
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Remove mosaic-owned symlinks left in a base install by a pre-isolation sync.
|
|
||||||
#
|
|
||||||
# Ownership is proven by RESOLUTION, not by name: only links resolving inside the
|
|
||||||
# canonical or local skills dirs are removed. Anything else — a real directory, a
|
|
||||||
# link elsewhere, an unresolvable link — is left untouched. This mirrors the
|
|
||||||
# refusal in commands/skill.js ("only symlinks pointing inside the Mosaic skills
|
|
||||||
# directory are managed") and preserves e.g. codex's own `.system` dir.
|
|
||||||
#
|
|
||||||
# The directory itself is kept: mosaic-doctor warns when ~/.pi/agent/skills is
|
|
||||||
# missing, and an empty dir is the correct end state, not an absent one.
|
|
||||||
cleanup_legacy_target() {
|
|
||||||
local target_dir="$1"
|
|
||||||
local removed=0 kept=0
|
|
||||||
|
|
||||||
[[ -d "$target_dir" ]] || return 0
|
|
||||||
|
|
||||||
while IFS= read -r -d '' link_path; do
|
|
||||||
local resolved owned=0
|
|
||||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
|
||||||
|
|
||||||
# Guard the empty-prefix trap: an unset *_real would make "$resolved" == "/"*
|
|
||||||
# match every absolute path and delete foreign links.
|
|
||||||
if [[ -n "$resolved" ]]; then
|
|
||||||
if [[ -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
|
|
||||||
owned=1
|
|
||||||
elif [[ -n "$local_real" && "$resolved" == "$local_real/"* ]]; then
|
|
||||||
owned=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $owned -eq 1 ]]; then
|
|
||||||
rm -f "$link_path"
|
|
||||||
removed=$((removed + 1))
|
|
||||||
else
|
|
||||||
kept=$((kept + 1))
|
|
||||||
fi
|
|
||||||
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
|
||||||
|
|
||||||
if [[ $removed -gt 0 ]]; then
|
|
||||||
echo "[mosaic-skills] Legacy cleanup: removed $removed mosaic symlink(s) from $target_dir (preserved $kept foreign)"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
for legacy in "${legacy_link_targets[@]}"; do
|
|
||||||
# Skip anything that is also a current target, so isolation can never
|
|
||||||
# self-destruct if the two lists ever overlap.
|
|
||||||
skip=0
|
|
||||||
for target in "${link_targets[@]}"; do
|
|
||||||
[[ "$legacy" == "$target" ]] && skip=1
|
|
||||||
done
|
|
||||||
[[ $skip -eq 1 ]] && continue
|
|
||||||
cleanup_legacy_target "$legacy"
|
|
||||||
done
|
|
||||||
|
|
||||||
for target in "${link_targets[@]}"; do
|
for target in "${link_targets[@]}"; do
|
||||||
mkdir -p "$target"
|
mkdir -p "$target"
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ is_sensitive_key() {
|
|||||||
|
|
||||||
is_generated_key() {
|
is_generated_key() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
MOSAIC_AGENT_NAME|MOSAIC_GIT_IDENTITY|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,7 @@ validate_generated_value() {
|
|||||||
local value="$2"
|
local value="$2"
|
||||||
case "$key" in
|
case "$key" in
|
||||||
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
||||||
|
MOSAIC_GIT_IDENTITY) safe_agent_name "$value" || fail_env unsafe-git-identity "$key" "$value" ;;
|
||||||
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
||||||
MOSAIC_AGENT_RUNTIME)
|
MOSAIC_AGENT_RUNTIME)
|
||||||
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
||||||
@@ -175,7 +176,7 @@ load_environment_file() {
|
|||||||
|
|
||||||
load_environment_file "$GENERATED_ENV" generated
|
load_environment_file "$GENERATED_ENV" generated
|
||||||
for required_key in \
|
for required_key in \
|
||||||
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
MOSAIC_AGENT_NAME MOSAIC_GIT_IDENTITY MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||||
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
||||||
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
||||||
done
|
done
|
||||||
@@ -183,12 +184,15 @@ load_environment_file "$LOCAL_ENV" local
|
|||||||
|
|
||||||
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
||||||
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
||||||
|
[ "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}" = "$AGENT_NAME" ] || \
|
||||||
|
fail_env git-identity-mismatch MOSAIC_GIT_IDENTITY "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}"
|
||||||
|
|
||||||
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
||||||
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
||||||
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
||||||
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
||||||
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
||||||
|
MOSAIC_GIT_IDENTITY=${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}
|
||||||
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
||||||
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
||||||
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
||||||
@@ -343,6 +347,7 @@ LAUNCH_ENV=(
|
|||||||
"PATH=$PANE_PATH"
|
"PATH=$PANE_PATH"
|
||||||
"MOSAIC_HOME=$MOSAIC_HOME"
|
"MOSAIC_HOME=$MOSAIC_HOME"
|
||||||
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
||||||
|
"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"
|
||||||
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
||||||
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
||||||
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
|||||||
SHIM
|
SHIM
|
||||||
chmod +x "$FAKE_BIN/mosaic"
|
chmod +x "$FAKE_BIN/mosaic"
|
||||||
|
|
||||||
|
# Freeze numeric epoch reads only when a test arm supplies an observation bound.
|
||||||
|
# Formatting reads still use the real BusyBox/POSIX date implementation.
|
||||||
|
cat > "$FAKE_BIN/date" <<'SHIM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -n "${MOSAIC_TEST_FIXED_EPOCH:-}" ] && [ "${1:-}" = '+%s' ]; then
|
||||||
|
printf '%s\n' "$MOSAIC_TEST_FIXED_EPOCH"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /bin/date "$@"
|
||||||
|
SHIM
|
||||||
|
chmod +x "$FAKE_BIN/date"
|
||||||
|
|
||||||
write_generated() {
|
write_generated() {
|
||||||
local home="$1"
|
local home="$1"
|
||||||
local agent="$2"
|
local agent="$2"
|
||||||
@@ -71,6 +84,7 @@ write_generated() {
|
|||||||
chmod 600 "$home/fleet/run/holder-owner"
|
chmod 600 "$home/fleet/run/holder-owner"
|
||||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$agent
|
MOSAIC_AGENT_NAME=$agent
|
||||||
|
MOSAIC_GIT_IDENTITY=$agent
|
||||||
MOSAIC_AGENT_CLASS=code
|
MOSAIC_AGENT_CLASS=code
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
||||||
@@ -88,6 +102,7 @@ run_start() {
|
|||||||
local agent="$2"
|
local agent="$2"
|
||||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||||
|
MOSAIC_TEST_FIXED_EPOCH="${MOSAIC_TEST_FIXED_EPOCH:-}" \
|
||||||
MOSAIC_TEST_HOME="$home" \
|
MOSAIC_TEST_HOME="$home" \
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
MOSAIC_HOME="$home" "$START" "$agent"
|
MOSAIC_HOME="$home" "$START" "$agent"
|
||||||
@@ -114,6 +129,42 @@ fi
|
|||||||
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
||||||
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
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"
|
||||||
|
echo "$output" | grep -qF "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.
|
||||||
# Validation must happen before fake tmux receives even a has-session call.
|
# Validation must happen before fake tmux receives even a has-session call.
|
||||||
@@ -274,6 +325,18 @@ after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cu
|
|||||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
||||||
fail "pane command did not clear its environment"
|
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
|
||||||
|
# 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
|
||||||
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||||
fail "runtime pane did not receive trusted HOME"
|
fail "runtime pane did not receive trusted HOME"
|
||||||
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||||
@@ -290,6 +353,7 @@ write_interaction_generated() {
|
|||||||
chmod 600 "$home/fleet/run/holder-owner"
|
chmod 600 "$home/fleet/run/holder-owner"
|
||||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$agent
|
MOSAIC_AGENT_NAME=$agent
|
||||||
|
MOSAIC_GIT_IDENTITY=$agent
|
||||||
MOSAIC_AGENT_CLASS=operator-interaction
|
MOSAIC_AGENT_CLASS=operator-interaction
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
||||||
@@ -352,8 +416,12 @@ write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
|
|||||||
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||||
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
||||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
||||||
touch -d '10 seconds ago' "$STALE_HB.native"
|
touch -t 200001010000.00 "$STALE_HB.native"
|
||||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
# Hold the sidecar's observation epoch constant: assertion runtime must not age
|
||||||
|
# a fresh-marker mutant into the stale state that this fixture must distinguish.
|
||||||
|
STALE_OBSERVATION_EPOCH=$(date +%s)
|
||||||
|
MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \
|
||||||
|
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||||
wait_for_sidecar_status "$STALE_HB"
|
wait_for_sidecar_status "$STALE_HB"
|
||||||
|
|
||||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
||||||
@@ -381,7 +449,7 @@ echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use sh
|
|||||||
: > "$TMUX_CALLS"
|
: > "$TMUX_CALLS"
|
||||||
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
||||||
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
||||||
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \
|
sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \
|
||||||
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
||||||
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
||||||
fail "interaction wrapper accepted a policy-incompatible projection"
|
fail "interaction wrapper accepted a policy-incompatible projection"
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
|
|
||||||
detect_platform() {
|
detect_platform() {
|
||||||
local remote_url
|
local remote_url
|
||||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
||||||
|
# kills the CALLER before the -z check below can run, so the error message that is
|
||||||
|
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
||||||
|
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
||||||
|
|
||||||
if [[ -z "$remote_url" ]]; then
|
if [[ -z "$remote_url" ]]; then
|
||||||
echo "error: not a git repository or no origin remote" >&2
|
echo "error: not a git repository or no origin remote" >&2
|
||||||
@@ -39,7 +42,10 @@ detect_platform() {
|
|||||||
|
|
||||||
get_repo_info() {
|
get_repo_info() {
|
||||||
local remote_url
|
local remote_url
|
||||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
||||||
|
# kills the CALLER before the -z check below can run, so the error message that is
|
||||||
|
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
||||||
|
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
||||||
|
|
||||||
if [[ -z "$remote_url" ]]; then
|
if [[ -z "$remote_url" ]]; then
|
||||||
echo "error: not a git repository or no origin remote" >&2
|
echo "error: not a git repository or no origin remote" >&2
|
||||||
@@ -240,6 +246,21 @@ PY
|
|||||||
} >&2
|
} >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Explain tea's most misleading failure. `user does not exist [uid: 0, name: ]` reads
|
||||||
|
# as a missing account; it almost always means a REVOKED OR STALE TOKEN. `tea login`
|
||||||
|
# keeps its OWN COPY of the token, so rotating the credential store does not update it.
|
||||||
|
# Diagnostic only -- stderr, no control flow, no exit.
|
||||||
|
explain_tea_user_does_not_exist() {
|
||||||
|
cat >&2 <<'MSG'
|
||||||
|
NOTE: `user does not exist [uid: 0, name: ]` from tea usually means a REVOKED OR STALE TOKEN,
|
||||||
|
not a missing account. A `tea login` stores its OWN COPY of the token; rotating the
|
||||||
|
credential store does NOT update it.
|
||||||
|
CHECK: the login's cached copy (`tea login list` -- read the FULL table, never `| head`),
|
||||||
|
then re-register that login against the current token.
|
||||||
|
DO NOT probe capability with a mutating request; a POST is the action, not a check.
|
||||||
|
MSG
|
||||||
|
}
|
||||||
|
|
||||||
get_gitea_login_for_host() {
|
get_gitea_login_for_host() {
|
||||||
local host="${1:-}"
|
local host="${1:-}"
|
||||||
local login
|
local login
|
||||||
|
|||||||
@@ -91,13 +91,32 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||||
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
# `tea issue comment` is NOT a subcommand -- tea 0.11.x lists only
|
||||||
|
# list/create/edit/reopen/close under `tea issue`. Comments are the
|
||||||
|
# TOP-LEVEL `tea comment`, which takes the same --repo/--login flags.
|
||||||
|
# The old call therefore always failed, was unchecked, and the script
|
||||||
|
# closed the issue anyway, losing the record of WHY.
|
||||||
|
#
|
||||||
|
# Use `tea comment` rather than the API helper so the comment and the
|
||||||
|
# close are made by the SAME principal ($GITEA_LOGIN_NAME). Routing the
|
||||||
|
# comment through the token-authenticated helper here would attribute the
|
||||||
|
# comment to the token holder and the close to the tea login -- two
|
||||||
|
# principals for one operation.
|
||||||
|
tea comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || {
|
||||||
|
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||||
else
|
else
|
||||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
gitea_issue_comment_api
|
# Fail closed here too: an unchecked comment lets the issue close without its
|
||||||
|
# audit trail, which is the same defect as the tea path above.
|
||||||
|
gitea_issue_comment_api || {
|
||||||
|
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
gitea_issue_close_api
|
gitea_issue_close_api
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ case "$PLATFORM" in
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
||||||
|
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||||
fi
|
fi
|
||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||||
|
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||||
fi
|
fi
|
||||||
gitea_issue_view_api
|
gitea_issue_view_api
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ case "$PLATFORM" in
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
||||||
|
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Regression: detect_platform / get_repo_info must FAIL LOUDLY outside a git repo,
|
||||||
|
# not kill the caller silently.
|
||||||
|
#
|
||||||
|
# Both functions already contained the right error path:
|
||||||
|
# if [[ -z "$remote_url" ]]; then echo "error: not a git repository..." >&2; return 1; fi
|
||||||
|
# but under `set -e` -- which every wrapper in this directory uses -- the preceding
|
||||||
|
# assignment `remote_url=$(git remote get-url origin 2>/dev/null)` returns git's 128
|
||||||
|
# outside a repo and terminates the CALLER first. The message was unreachable.
|
||||||
|
#
|
||||||
|
# Observed cost: pr-review.sh invoked from a non-repo cwd exits 128 with NO stdout and
|
||||||
|
# NO stderr, even when -r/--repo and -H/--host are supplied -- the flags documented as
|
||||||
|
# "skips git-remote inference". Two reviewer seats hit this and correctly reported
|
||||||
|
# `blocked` with no diagnostic to report.
|
||||||
|
#
|
||||||
|
# The control that matters is the LOUD one: asserting "rc != 0" passes on the broken
|
||||||
|
# build too, because 128 is also non-zero. The test must assert the MESSAGE.
|
||||||
|
set -uo pipefail
|
||||||
|
fail=0
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
run_outside() { # $1=function name -> "rc:sawmessage"
|
||||||
|
local fn="$1" out rc
|
||||||
|
out=$( cd "$TMP" && bash -c "set -e; source '$HERE/detect-platform.sh'; $fn" 2>&1 ); rc=$?
|
||||||
|
printf '%s:%s' "$rc" "$(grep -qi 'not a git repository' <<<"$out" && echo yes || echo no)"
|
||||||
|
}
|
||||||
|
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||||
|
|
||||||
|
# $TMP must not be inside a git repo. Do not SKIP on failure: be-coder-07 showed the
|
||||||
|
# original SKIP exited 0, so pointing TMPDIR beneath a git worktree made this test PASS
|
||||||
|
# against unchanged main. A skip that exits 0 is indistinguishable from a pass.
|
||||||
|
# GIT_CEILING_DIRECTORIES stops git walking above $TMP, making the condition hold
|
||||||
|
# regardless of where TMPDIR lives, rather than merely detecting when it does not.
|
||||||
|
# GIT_CEILING_DIRECTORIES is matched against the PHYSICAL path -- a symlinked TMPDIR
|
||||||
|
# (/tmp is commonly one) makes the logical path never match, and the ceiling silently
|
||||||
|
# does nothing. Resolve it before exporting.
|
||||||
|
TMP="$(cd "$TMP" && pwd -P)"
|
||||||
|
export GIT_CEILING_DIRECTORIES="$TMP"
|
||||||
|
if ( cd "$TMP" && git rev-parse --git-dir >/dev/null 2>&1 ); then
|
||||||
|
echo " FAIL scratch dir is inside a git repo even with GIT_CEILING_DIRECTORIES set;"
|
||||||
|
echo " the outside-a-repo precondition cannot be established -- refusing to report a result"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== outside a git repo: rc=1 AND the diagnostic is emitted =="
|
||||||
|
check "detect_platform" "$(run_outside detect_platform)" "1:yes"
|
||||||
|
check "get_repo_info" "$(run_outside get_repo_info)" "1:yes"
|
||||||
|
|
||||||
|
echo "== inside a git repo the functions still work =="
|
||||||
|
git init -q "$TMP/repo" 2>/dev/null
|
||||||
|
git -C "$TMP/repo" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git 2>/dev/null
|
||||||
|
out=$( cd "$TMP/repo" && bash -c "set -e; source '$HERE/detect-platform.sh'; detect_platform" 2>&1 ); rc=$?
|
||||||
|
if [ "$rc" -eq 0 ] && grep -qi 'gitea' <<<"$out"; then echo " PASS detect_platform in-repo (rc=0, $out)"
|
||||||
|
else echo " FAIL detect_platform in-repo: rc=$rc out=$out"; fail=1; fi
|
||||||
|
|
||||||
|
[ "$fail" -eq 0 ] && echo "OK detect-platform fails loudly outside a repo" || echo "FAILED"
|
||||||
|
exit "$fail"
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Regression: the tea-failure diagnostic must be STATUS-NEUTRAL.
|
||||||
|
#
|
||||||
|
# Found by be-coder-08 reviewing PR #1086. At all three call sites the diagnostic is emitted
|
||||||
|
# immediately BEFORE the Gitea API fallback. Written as the last command of an && list:
|
||||||
|
# declare -F explain_... >/dev/null && explain_...
|
||||||
|
# under `set -e` a FAILING diagnostic exits and the fallback never runs -- a diagnostic that
|
||||||
|
# suppresses the recovery path it exists to explain. It misbehaves ONLY when the helper is
|
||||||
|
# PRESENT, so the helper-absent path (pre-#1086 behaviour) keeps working and reads as a
|
||||||
|
# passing control.
|
||||||
|
#
|
||||||
|
# TWO DEFECTS IN THE FIRST VERSION OF THIS TEST, both found by be-coder-08:
|
||||||
|
# 1. `out=$( ... ) 2>"$errto"` applies the redirection to the ASSIGNMENT, not to the
|
||||||
|
# command substitution, so the probe's stderr was never actually pointed at /dev/full
|
||||||
|
# and the /dev/full rows proved nothing. Verified: `out=$(echo x >&2) 2>/dev/full`
|
||||||
|
# leaks to the terminal and returns 0; the redirect must be INSIDE the substitution.
|
||||||
|
# 2. `eval "$CONSTRUCT"` changes `set -e` semantics for a bare && list, so the probe did
|
||||||
|
# not exercise the construct as the shipped file executes it. It now writes the line
|
||||||
|
# into a real script and runs it -- same parse, same set -e rules, no eval.
|
||||||
|
# The construct is still LIFTED FROM THE SHIPPED FILE: retyping the fixed form makes the
|
||||||
|
# probe pass on a build whose real call sites still carry the bare && form.
|
||||||
|
set -uo pipefail
|
||||||
|
fail=0
|
||||||
|
GIT_DIR_UNDER_TEST="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
probe() { # $1=present|absent $2=stderr target $3=source file -> "rc:fallback"
|
||||||
|
local helper="$1" errto="$2" src="$3" construct script out rc
|
||||||
|
construct=$(grep -m1 'explain_tea_user_does_not_exist' "$GIT_DIR_UNDER_TEST/$src" | sed 's/^[[:space:]]*//')
|
||||||
|
[ -n "$construct" ] || { printf 'no-construct:no'; return; }
|
||||||
|
script="$TMP/probe.sh"
|
||||||
|
{
|
||||||
|
echo '#!/bin/bash'
|
||||||
|
echo 'set -e'
|
||||||
|
echo 'explain_tea_user_does_not_exist() { echo "diagnostic" >&2; }'
|
||||||
|
[ "$helper" = absent ] && echo 'unset -f explain_tea_user_does_not_exist'
|
||||||
|
echo "$construct" # the shipped line, parsed by a real shell
|
||||||
|
echo 'echo FALLBACK_REACHED'
|
||||||
|
} > "$script"
|
||||||
|
# redirect INSIDE the substitution so the subshell's stderr really is $errto
|
||||||
|
out=$( bash "$script" 2>"$errto" ); rc=$?
|
||||||
|
printf '%s:%s' "$rc" "$(grep -q FALLBACK_REACHED <<<"$out" && echo yes || echo no)"
|
||||||
|
}
|
||||||
|
|
||||||
|
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||||
|
|
||||||
|
echo "== diagnostic must not alter exit status or skip the fallback =="
|
||||||
|
# /dev/full makes every stderr write fail -- the real-world shape is a closed or full fd.
|
||||||
|
for src in pr-create.sh issue-view.sh issue-create.sh; do
|
||||||
|
check "$src stderr OK / helper present" "$(probe present /dev/null "$src")" "0:yes"
|
||||||
|
check "$src stderr OK / helper absent " "$(probe absent /dev/null "$src")" "0:yes"
|
||||||
|
check "$src stderr FAILING / helper present" "$(probe present /dev/full "$src")" "0:yes"
|
||||||
|
check "$src stderr FAILING / helper absent " "$(probe absent /dev/full "$src")" "0:yes"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== all three call sites use the status-neutral form =="
|
||||||
|
for f in pr-create.sh issue-view.sh issue-create.sh; do
|
||||||
|
p="$GIT_DIR_UNDER_TEST/$f"
|
||||||
|
grep -q '{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true' "$p" \
|
||||||
|
&& echo " PASS $f guarded" || { echo " FAIL $f: diagnostic is not status-neutral"; fail=1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$fail" -eq 0 ] && echo "OK diagnostic is status-neutral" || echo "FAILED"
|
||||||
|
exit "$fail"
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression: issue-close.sh must NOT close an issue when the closing comment could not
|
||||||
|
# be posted, and comment+close must be made by ONE principal.
|
||||||
|
#
|
||||||
|
# Guards two defects fixed together (see #1081):
|
||||||
|
# 1. `tea issue comment` is not a subcommand -- tea exposes comments as the TOP-LEVEL
|
||||||
|
# `tea comment`. The old call always failed, was unchecked, and the issue closed
|
||||||
|
# anyway, losing the record of WHY it was closed.
|
||||||
|
# 2. Routing the comment through the token-authenticated API helper while the close
|
||||||
|
# used --login would attribute one operation to two principals.
|
||||||
|
#
|
||||||
|
# SAFETY (rev-974, #1085 review 130): this test previously ran under `set -uo pipefail`
|
||||||
|
# with unchecked mkdir/redirect/cd, then prepended a possibly-nonexistent $MOCK_BIN to
|
||||||
|
# PATH -- while `git remote add origin` names the REAL repository. Forcing setup failure
|
||||||
|
# with an unwritable AGENT_WORK_ROOT made it `git init` in its CALLER's directory and
|
||||||
|
# invoke the real, provider-mutating issue-close.sh. Setup now fails closed, and both
|
||||||
|
# `tea` and `curl` are asserted to resolve INSIDE $MOCK_BIN before any target run.
|
||||||
|
set -euo pipefail
|
||||||
|
# NOTE: with `set -e`, `grep -q X && fail "..."` is a trap -- the ABSENT case (grep rc=1,
|
||||||
|
# which is the PASSING case for a must-not-appear assertion) is the last command of an &&
|
||||||
|
# list and silently terminates the script with no message. Every must-not-appear check
|
||||||
|
# below is therefore an if-block. This is the same set -e + &&-list defect be-coder-08
|
||||||
|
# found in #1086, reintroduced here by adding `set -e` for the sandbox-safety fix.
|
||||||
|
|
||||||
|
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
|
||||||
|
SANDBOX="$WORK_ROOT/issue-close-fail-closed-test-$$"
|
||||||
|
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
|
||||||
|
cleanup() { rm -rf "$SANDBOX"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TARGET="$SCRIPT_DIR/issue-close.sh"
|
||||||
|
[ -f "$TARGET" ] || { echo "FAIL: issue-close.sh not found beside this test"; exit 1; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
|
||||||
|
# Every setup step is checked. Under `set -e` these abort; the explicit || fail keeps the
|
||||||
|
# reason legible instead of a bare non-zero exit.
|
||||||
|
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
|
||||||
|
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
|
||||||
|
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
|
||||||
|
git init -q || fail "setup: git init failed"
|
||||||
|
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
|
||||||
|
export PATH="$MOCK_BIN:$PATH" CALLS
|
||||||
|
export GITEA_URL="https://git.mosaicstack.dev"
|
||||||
|
export GITEA_TOKEN="redacted-test-token"
|
||||||
|
|
||||||
|
cat > "$MOCK_BIN/curl" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
method=GET; url=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-X) method="$2"; shift 2 ;;
|
||||||
|
http*|https*) url="$1"; shift ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf 'curl %s %s\n' "$method" "$url" >> "$CALLS"
|
||||||
|
[ "${MOCK_CURL_FAIL:-}" = "1" ] && [ "$method" = "POST" ] && exit 22
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/curl"
|
||||||
|
|
||||||
|
mk_tea() { # $1 = exit code for a comment attempt; $2 = login list (empty => no login)
|
||||||
|
local rc="$1" login="${2-}"
|
||||||
|
cat > "$MOCK_BIN/tea" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'tea %s\n' "\$*" >> "$CALLS"
|
||||||
|
if [[ "\$*" == *"login list"* ]]; then
|
||||||
|
printf '%s\n' '${login}'; exit 0
|
||||||
|
fi
|
||||||
|
# Fail ANY comment attempt -- both the correct top-level \`tea comment\` and the broken
|
||||||
|
# \`tea issue comment\` -- so an unfixed script exercises the DEFECT rather than tripping
|
||||||
|
# a setup assertion.
|
||||||
|
if [[ "\$1" == "comment" || ( "\$1" == "issue" && "\$2" == "comment" ) ]]; then exit $rc; fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/tea"
|
||||||
|
}
|
||||||
|
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||||
|
|
||||||
|
# The mocks must be the ones that run. Without this, a failed setup silently falls through
|
||||||
|
# to the real tea/curl and the "test" mutates the real provider.
|
||||||
|
assert_mocked() {
|
||||||
|
local w
|
||||||
|
for w in tea curl; do
|
||||||
|
p=$(command -v "$w" || true)
|
||||||
|
[ -n "$p" ] || fail "SAFETY: $w does not resolve at all"
|
||||||
|
case "$p" in
|
||||||
|
"$MOCK_BIN"/*) : ;;
|
||||||
|
*) fail "SAFETY: $w resolves to $p, OUTSIDE the sandbox -- refusing to invoke the target" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
run_target() { # never let a target failure abort the test; we assert on rc
|
||||||
|
# Call sites MUST use `rc=0; run_target ... || rc=$?` -- a bare `run_target ...; rc=$?`
|
||||||
|
# lets the non-zero RETURN trip set -e in the CALLER before rc is ever read.
|
||||||
|
set +e; bash "$TARGET" "$@" >/dev/null 2>&1; local rc=$?; set -e; return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── tea path ────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. NEGATIVE (the regression): comment fails => must NOT close, must exit non-zero
|
||||||
|
mk_tea 1 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||||
|
grep -qE 'tea (issue )?comment' "$CALLS" || fail "no comment attempt -- setup did not reach the tea branch"
|
||||||
|
if grep -q 'tea issue close' "$CALLS"; then fail "ISSUE CLOSED AFTER THE COMMENT FAILED -- the regression"; fi
|
||||||
|
[ "$rc" -ne 0 ] || fail "comment failed but issue-close exited 0 -- FAIL-OPEN"
|
||||||
|
|
||||||
|
# 2. POSITIVE: comment succeeds => close proceeds, exit 0
|
||||||
|
mk_tea 0 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||||
|
[ "$rc" -eq 0 ] || fail "comment succeeded but issue-close exited $rc"
|
||||||
|
grep -q 'tea issue close' "$CALLS" || fail "issue not closed even though the comment succeeded"
|
||||||
|
|
||||||
|
# 3. must use top-level `tea comment`, never `tea issue comment`
|
||||||
|
if grep -q 'tea issue comment' "$CALLS"; then fail "used 'tea issue comment' -- not a valid subcommand"; fi
|
||||||
|
|
||||||
|
# 4. ONE PRINCIPAL: comment and close must carry the SAME --login
|
||||||
|
c=$(grep -m1 '^tea comment' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
||||||
|
k=$(grep -m1 '^tea issue close' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
||||||
|
[ -n "$c" ] || fail "comment carried no --login"
|
||||||
|
[ "$c" = "$k" ] || fail "MIXED PRINCIPALS: comment=$c close=$k"
|
||||||
|
|
||||||
|
# ── no-login / API fallback path ────────────────────────────────────────────────────
|
||||||
|
# rev-974: the delta also adds fail-closed behaviour to this branch, and the suite never
|
||||||
|
# reached it -- replacing the whole fallback contract with an unconditional close still
|
||||||
|
# passed. These assert the POSTCONDITION (which HTTP calls happened, in what order),
|
||||||
|
# not merely that a command ran.
|
||||||
|
# 5. no login + comment FAILS => POST attempted, NO PATCH, non-zero
|
||||||
|
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; MOCK_CURL_FAIL=1 run_target -i 42 -c "closing note" || rc=$?
|
||||||
|
grep -q 'curl POST' "$CALLS" || fail "API path: no comment POST attempted"
|
||||||
|
if grep -q 'curl PATCH' "$CALLS"; then fail "API path: ISSUE CLOSED (PATCH) AFTER THE COMMENT POST FAILED"; fi
|
||||||
|
[ "$rc" -ne 0 ] || fail "API path: comment failed but exited 0 -- FAIL-OPEN"
|
||||||
|
|
||||||
|
# 6. no login + comment SUCCEEDS => POST strictly BEFORE PATCH, exit 0
|
||||||
|
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||||
|
[ "$rc" -eq 0 ] || fail "API path: comment succeeded but exited $rc"
|
||||||
|
order=$(grep -oE 'curl (POST|PATCH)' "$CALLS" | awk '{print $2}' | paste -sd, -)
|
||||||
|
[ "$order" = "POST,PATCH" ] || fail "API path: expected POST,PATCH -- got '${order:-<none>}'"
|
||||||
|
|
||||||
|
# 7. no login + NO comment => PATCH only, never a POST
|
||||||
|
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; run_target -i 42 || rc=$?
|
||||||
|
[ "$rc" -eq 0 ] || fail "API path: no-comment close exited $rc"
|
||||||
|
if grep -q 'curl POST' "$CALLS"; then fail "API path: posted a comment when none was requested"; fi
|
||||||
|
grep -q 'curl PATCH' "$CALLS" || fail "API path: issue not closed when no comment was requested"
|
||||||
|
|
||||||
|
echo "issue-close.sh fail-closed + single-principal regression passed"
|
||||||
@@ -39,7 +39,7 @@ MAX_FRAME: Final = 64 * 1024
|
|||||||
MAX_STATE: Final = 4 * 1024 * 1024
|
MAX_STATE: Final = 4 * 1024 * 1024
|
||||||
MAX_PENDING_TOKENS: Final = 256
|
MAX_PENDING_TOKENS: Final = 256
|
||||||
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
||||||
MAX_LEASE_TTL_SECONDS: Final = 3600
|
MAX_LEASE_TTL_SECONDS: Final = 300
|
||||||
STATE_VERSION: Final = 1
|
STATE_VERSION: Final = 1
|
||||||
READ_DEADLINE_SECONDS: Final = 1.0
|
READ_DEADLINE_SECONDS: Final = 1.0
|
||||||
HANDLE_QUEUE_TIMEOUT_SECONDS: Final = 1.0
|
HANDLE_QUEUE_TIMEOUT_SECONDS: Final = 1.0
|
||||||
@@ -50,8 +50,8 @@ LEASE_PENDING: Final = "PENDING_VERIFICATION"
|
|||||||
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
|
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
|
||||||
LEASE_VERIFIED: Final = "VERIFIED"
|
LEASE_VERIFIED: Final = "VERIFIED"
|
||||||
READ_ONLY_TOOLS: Final = {
|
READ_ONLY_TOOLS: Final = {
|
||||||
"claude": frozenset({"Read", "Grep", "Glob"}),
|
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
|
||||||
"pi": frozenset({"read", "ls"}),
|
"pi": frozenset({"read", "grep", "find", "ls"}),
|
||||||
}
|
}
|
||||||
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from collections.abc import Callable, Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
@@ -55,48 +53,6 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _self_starttime() -> str | None:
|
|
||||||
"""Field 22 of our own /proc stat — the anchor starttime the broker records.
|
|
||||||
|
|
||||||
Read past the comm field's parens, since a process name may contain them.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
raw = Path(f"/proc/{os.getpid()}/stat").read_text()
|
|
||||||
return raw.rsplit(")", 1)[1].split()[19]
|
|
||||||
except (OSError, IndexError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _append_launch_record(environ: Mapping[str, str], record: dict[str, object]) -> None:
|
|
||||||
"""Append one NDJSON event to the #797 Runtime Session Ledger.
|
|
||||||
|
|
||||||
`fleet/run/sessions/` is operator-classified in framework-manifest.txt and is
|
|
||||||
already covered by test-upgrade-manifest-guard.sh, so an upgrade can neither
|
|
||||||
overwrite nor prune it. Files 0600 under a 0700 dir, matching what that guard
|
|
||||||
asserts.
|
|
||||||
|
|
||||||
Never raises: a launch must not be denied over bookkeeping. But it also never
|
|
||||||
fails silently — a missing record is exactly the kind of gap that made the
|
|
||||||
2026-08-06 MUTATOR_UNVERIFIED investigation cost a day.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
mosaic_home = environ.get("MOSAIC_HOME") or str(Path.home() / ".config" / "mosaic")
|
|
||||||
directory = Path(mosaic_home) / "fleet" / "run" / "sessions"
|
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.chmod(directory, 0o700)
|
|
||||||
framed = {
|
|
||||||
"seq": time.time_ns() // 1_000_000,
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
**record,
|
|
||||||
}
|
|
||||||
path = directory / "events.ndjson"
|
|
||||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
||||||
with os.fdopen(descriptor, "w") as handle:
|
|
||||||
handle.write(json.dumps(framed, separators=(",", ":")) + "\n")
|
|
||||||
except (OSError, ValueError, TypeError) as error:
|
|
||||||
print(f"[mosaic] WARNING: launch record not written: {error}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def main(
|
def main(
|
||||||
argv: Sequence[str] | None = None,
|
argv: Sequence[str] | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -138,9 +94,8 @@ def main(
|
|||||||
# silent pass and never folded into the generic registration-failure
|
# silent pass and never folded into the generic registration-failure
|
||||||
# branch.
|
# branch.
|
||||||
try:
|
try:
|
||||||
activation_capability = probe_activation_capability(source_environment)
|
|
||||||
assert_activation_capability_matches(
|
assert_activation_capability_matches(
|
||||||
activation_capability,
|
probe_activation_capability(source_environment),
|
||||||
expected_activation_capability,
|
expected_activation_capability,
|
||||||
)
|
)
|
||||||
except VersionCouplingError as version_error:
|
except VersionCouplingError as version_error:
|
||||||
@@ -173,32 +128,6 @@ def main(
|
|||||||
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Immutable launch record, half two. `mosaic` wrote `session.launch` with the
|
|
||||||
# config/provenance it knows; only this process knows the broker session id
|
|
||||||
# and the activation capability it just asserted. os.execvpe preserves the
|
|
||||||
# PID, so this PID is BOTH the anchor pid and the join key back to that
|
|
||||||
# record. Never fatal — bookkeeping must not deny a launch — but never
|
|
||||||
# silent either.
|
|
||||||
_append_launch_record(
|
|
||||||
source_environment,
|
|
||||||
{
|
|
||||||
"kind": "lease.register",
|
|
||||||
# Joins back to `mosaic`'s session.launch record. NOT pid: execRuntime()
|
|
||||||
# spawns rather than execs, so this process is a CHILD of mosaic with a
|
|
||||||
# different pid. This pid IS the broker anchor pid (os.execvpe below
|
|
||||||
# preserves it), which is a separate and still-useful fact.
|
|
||||||
"launch_id": source_environment.get("MOSAIC_LAUNCH_ID"),
|
|
||||||
"pid": os.getpid(),
|
|
||||||
"runtime": arguments.runtime,
|
|
||||||
"session_id": session_id,
|
|
||||||
"runtime_generation": generation,
|
|
||||||
"generation_file": str(generation_file),
|
|
||||||
"anchor_starttime": _self_starttime(),
|
|
||||||
"activation_capability": activation_capability,
|
|
||||||
"command": Path(command[0]).name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
environment = dict(source_environment)
|
environment = dict(source_environment)
|
||||||
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
||||||
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Lease promotion client — the half the enforcement toolkit never shipped.
|
|
||||||
|
|
||||||
The enforcement half (``daemon.py`` + ``mutator-gate.py``) ships and denies. The
|
|
||||||
promotion half has no production caller anywhere in the package: as of 0.0.48,
|
|
||||||
0.0.49 and 0.0.50-next.2207, ``begin_verification`` / ``observe_receipt`` /
|
|
||||||
``promote_lease`` are invoked only by ``broker-test-client.ts``, the acceptance
|
|
||||||
spec, unit tests, and two probes under ``docs/``. Consequence: **no lease on any
|
|
||||||
host can reach VERIFIED**, so every mutator is denied ``MUTATOR_UNVERIFIED`` by a
|
|
||||||
gate nothing can satisfy.
|
|
||||||
|
|
||||||
THE PROTOCOL (``daemon.py:578-754``)
|
|
||||||
------------------------------------
|
|
||||||
1. ``begin_verification`` — broker revokes, mints a challenge, and returns the
|
|
||||||
exact ``receipt`` text the MODEL must emit
|
|
||||||
2. *the model emits that text verbatim as its ENTIRE latest message*
|
|
||||||
3. the runtime adapter ships that message to the daemon-owned observer socket
|
|
||||||
4. ``observe_receipt`` -> ``PENDING_PROMOTION``
|
|
||||||
5. ``promote_lease`` -> ``VERIFIED``
|
|
||||||
|
|
||||||
THIS MODULE IMPLEMENTS 1, 4 AND 5 — NEVER 2
|
|
||||||
-------------------------------------------
|
|
||||||
Step 2 is the security property, not a formality. ``is_verbatim_receipt`` uses
|
|
||||||
``hmac.compare_digest`` against the exact minted string — explicitly "not a
|
|
||||||
transcript substring" (``receipt_challenge.py``). Promotion therefore requires a
|
|
||||||
live model that received the challenge in its context and echoed it exactly.
|
|
||||||
|
|
||||||
``receipt-observer-client.py`` will post ANY string as the latest assistant
|
|
||||||
message. A promotion client that posted its own receipt would satisfy the broker
|
|
||||||
while proving nothing — a gate-disabler indistinguishable from a working fix
|
|
||||||
unless someone looks for it. **This module never posts a receipt.** Emitting it
|
|
||||||
belongs to the runtime adapter, where a real model turn happens.
|
|
||||||
|
|
||||||
The construction binds the exact normative source bytes. ``h_source`` /
|
|
||||||
``h_payload`` are derived by the framework's own
|
|
||||||
``normative_fragments.build_payload`` rather than reimplemented: the broker
|
|
||||||
derives them the same way and any divergence yields ``PAYLOAD_BINDING_MISMATCH``.
|
|
||||||
There must be exactly one implementation.
|
|
||||||
|
|
||||||
WHAT THE BINDING DOES *NOT* PROVE
|
|
||||||
---------------------------------
|
|
||||||
It is tempting to read a VERIFIED lease as "this agent is running THIS law".
|
|
||||||
**It does not mean that**, and writing it down that way is how the belief spread.
|
|
||||||
The broker holds no reference copy of any normative source and never opens one;
|
|
||||||
it recomputes ``h_source`` / ``h_payload`` from the fragment bytes THIS CLIENT
|
|
||||||
sent and compares them to the binding THIS CLIENT sent (``daemon.py:602-616``).
|
|
||||||
Both sides of that comparison originate here, so it detects corruption in
|
|
||||||
transit and nothing else. What the binding actually asserts is "the client
|
|
||||||
claims these bytes, self-consistently".
|
|
||||||
|
|
||||||
Making it mean the stronger thing requires the broker to re-read the on-disk
|
|
||||||
sources itself, against a manifest the agent cannot rewrite — i.e. broker code
|
|
||||||
attestation under its own uid. Until then, do not cite a VERIFIED lease as
|
|
||||||
evidence of law integrity.
|
|
||||||
|
|
||||||
Usage
|
|
||||||
-----
|
|
||||||
lease_promote.py --begin # prints the receipt the MODEL must emit
|
|
||||||
lease_promote.py --complete <challenge> # after the adapter observed it
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
# Isolated (`python -I`) adapter invocations must still import co-located
|
|
||||||
# framework modules; never depend on the caller's PYTHONPATH.
|
|
||||||
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
||||||
if _MODULE_DIRECTORY not in sys.path:
|
|
||||||
sys.path.insert(0, _MODULE_DIRECTORY)
|
|
||||||
|
|
||||||
from normative_fragments import NormativeFragment, build_payload # noqa: E402
|
|
||||||
|
|
||||||
MAX_FRAME: Final = 64 * 1024
|
|
||||||
BROKER_TIMEOUT_SECONDS: Final = 3.0
|
|
||||||
SCHEMA_VERSION: Final = 1
|
|
||||||
MANIFEST_VERSION: Final = 1
|
|
||||||
GENERATOR_VERSION: Final = "mosaic/lease_promote@1"
|
|
||||||
DEFAULT_TTL_SECONDS: Final = 3600
|
|
||||||
|
|
||||||
# Normative sources whose exact bytes bind the lease, in binding order. Order is
|
|
||||||
# load-bearing: ``h_source`` frames the resolved sequence, so reordering changes
|
|
||||||
# the derivation. Never fabricate a source that is not on disk.
|
|
||||||
FRAGMENT_SOURCES: Final = (
|
|
||||||
"CONSTITUTION.md",
|
|
||||||
"AGENTS.md",
|
|
||||||
"SOUL.md",
|
|
||||||
"USER.md",
|
|
||||||
"STANDARDS.md",
|
|
||||||
"TOOLS.md",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Framework-owned sources, reconciled on every upgrade — `install.sh:76`
|
|
||||||
# FRAMEWORK_OWNED and `config/file-adapter.ts` FRAMEWORK_OWNED_FILES — plus the
|
|
||||||
# per-runtime contract shipped under `framework/runtime/<runtime>/`. A deployment
|
|
||||||
# missing one of these is broken, not minimal, so their absence is refused rather
|
|
||||||
# than silently dropped from the binding.
|
|
||||||
#
|
|
||||||
# SOUL.md and USER.md are deliberately excluded: install.sh does not seed them
|
|
||||||
# ("intentionally NOT seeded here — they are generated by `mosaic init`"), so a
|
|
||||||
# fresh install legitimately lacks both. TOOLS.md is user-seeded on first install
|
|
||||||
# only. Absence of those three is reported, not fatal.
|
|
||||||
REQUIRED_SOURCES: Final = frozenset({"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"})
|
|
||||||
|
|
||||||
|
|
||||||
class IncompleteBinding(RuntimeError):
|
|
||||||
"""A source that must bind this lease could not be read.
|
|
||||||
|
|
||||||
**Never downgrade this to a skip.** The broker recomputes the hashes from the
|
|
||||||
fragments it is sent, so an omitted fragment is internally consistent and
|
|
||||||
``PAYLOAD_BINDING_MISMATCH`` cannot fire — a partial law promotes exactly like
|
|
||||||
a complete one, and nothing downstream can tell the difference. Dropping an
|
|
||||||
unreadable source therefore does not degrade the binding, it forges a smaller
|
|
||||||
one. Fail here, where the omission is still visible.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def mosaic_home() -> Path:
|
|
||||||
return Path(os.environ.get("MOSAIC_HOME") or Path.home() / ".config" / "mosaic")
|
|
||||||
|
|
||||||
|
|
||||||
def broker_socket() -> Path:
|
|
||||||
value = os.environ.get("MOSAIC_LEASE_BROKER_SOCKET")
|
|
||||||
if value:
|
|
||||||
return Path(value)
|
|
||||||
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
|
||||||
if runtime_dir:
|
|
||||||
return Path(runtime_dir) / "mosaic-lease" / "broker.sock"
|
|
||||||
return Path(f"/run/user/{os.getuid()}/mosaic-lease/broker.sock")
|
|
||||||
|
|
||||||
|
|
||||||
def session_identity() -> tuple[str, int, str]:
|
|
||||||
"""Session id, CURRENT generation, runtime.
|
|
||||||
|
|
||||||
The generation file wins over the env var, matching ``lease_generation.py``.
|
|
||||||
Sending a generation HIGHER than the broker's would revoke this session's own
|
|
||||||
authority (``daemon.py:342-344``), so this never guesses.
|
|
||||||
"""
|
|
||||||
session_id = os.environ["MOSAIC_LEASE_SESSION_ID"]
|
|
||||||
runtime = os.environ["MOSAIC_LEASE_RUNTIME"]
|
|
||||||
state_file = os.environ.get("MOSAIC_LEASE_GENERATION_FILE")
|
|
||||||
if state_file:
|
|
||||||
try:
|
|
||||||
return session_id, int(Path(state_file).read_text().strip()), runtime
|
|
||||||
except (OSError, ValueError):
|
|
||||||
pass
|
|
||||||
return session_id, int(os.environ["MOSAIC_RUNTIME_GENERATION"]), runtime
|
|
||||||
|
|
||||||
|
|
||||||
def build_construction(runtime: str) -> tuple[dict[str, object], object]:
|
|
||||||
"""Assemble the wire construction and derive its hashes with the sole builder."""
|
|
||||||
runtime_contract = f"runtime/{runtime}/RUNTIME.md"
|
|
||||||
sources = list(FRAGMENT_SOURCES) + [runtime_contract]
|
|
||||||
required = REQUIRED_SOURCES | {runtime_contract}
|
|
||||||
wire_fragments: list[dict[str, str]] = []
|
|
||||||
objects: list[NormativeFragment] = []
|
|
||||||
absent: list[str] = []
|
|
||||||
|
|
||||||
for source_id in sources:
|
|
||||||
try:
|
|
||||||
content = (mosaic_home() / source_id).read_bytes()
|
|
||||||
except FileNotFoundError:
|
|
||||||
# Genuinely not on disk. Legitimate only for operator-owned sources.
|
|
||||||
if source_id in required:
|
|
||||||
raise IncompleteBinding(
|
|
||||||
f"required normative source is absent: {source_id}"
|
|
||||||
) from None
|
|
||||||
absent.append(source_id)
|
|
||||||
continue
|
|
||||||
except OSError as exc:
|
|
||||||
# The path resolves but will not read — EACCES, EIO, EISDIR, ELOOP.
|
|
||||||
# That is an anomaly for EVERY source, optional ones included: an
|
|
||||||
# unreadable file is not an un-configured one, and treating it as
|
|
||||||
# absent is what lets a permission change quietly shrink the law.
|
|
||||||
raise IncompleteBinding(
|
|
||||||
f"normative source is present but unreadable: {source_id} "
|
|
||||||
f"({type(exc).__name__})"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
digest = hashlib.sha256(content).hexdigest()
|
|
||||||
wire_fragments.append(
|
|
||||||
{
|
|
||||||
"source_id": source_id,
|
|
||||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
|
||||||
"expected_sha256": digest,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
objects.append(NormativeFragment(source_id, content, digest))
|
|
||||||
|
|
||||||
if not wire_fragments:
|
|
||||||
raise IncompleteBinding("no normative sources found — refusing an empty binding")
|
|
||||||
|
|
||||||
# Absence is legitimate here but never invisible. The omission is already
|
|
||||||
# baked into h_source (the framed source sequence differs), but nothing
|
|
||||||
# compares h_source to an expected value, so this line is the only place a
|
|
||||||
# human learns the binding was narrower than the full set.
|
|
||||||
if absent:
|
|
||||||
print(
|
|
||||||
f"lease_promote: binding omits absent operator sources: {', '.join(absent)}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = build_payload(
|
|
||||||
manifest_version=MANIFEST_VERSION,
|
|
||||||
generator_version=GENERATOR_VERSION,
|
|
||||||
fragments=objects,
|
|
||||||
)
|
|
||||||
if result.injectionDecision != "ACCEPTED" or not result.promotion:
|
|
||||||
raise RuntimeError(f"construction refused locally: {result.source_reason}")
|
|
||||||
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"manifest_version": MANIFEST_VERSION,
|
|
||||||
"generator_version": GENERATOR_VERSION,
|
|
||||||
"fragments": wire_fragments,
|
|
||||||
},
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def broker_request(payload: dict[str, object]) -> dict[str, object]:
|
|
||||||
raw = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
|
|
||||||
if len(raw) > MAX_FRAME:
|
|
||||||
raise ValueError(
|
|
||||||
f"request too large ({len(raw)} bytes); broker frame cap is {MAX_FRAME}"
|
|
||||||
)
|
|
||||||
response = bytearray()
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
||||||
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
|
||||||
connection.connect(str(broker_socket()))
|
|
||||||
connection.sendall(raw)
|
|
||||||
connection.shutdown(socket.SHUT_WR)
|
|
||||||
while len(response) <= MAX_FRAME:
|
|
||||||
chunk = connection.recv(4096)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
response.extend(chunk)
|
|
||||||
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
|
||||||
raise ValueError("invalid broker reply")
|
|
||||||
value = json.loads(response)
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValueError("invalid broker reply")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def begin(
|
|
||||||
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
||||||
compaction_epoch: int = 0,
|
|
||||||
request_epoch: int = 0,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""Step 1. Returns the broker reply, including the exact ``receipt`` text."""
|
|
||||||
session_id, generation, runtime = session_identity()
|
|
||||||
construction, derived = build_construction(runtime)
|
|
||||||
return broker_request(
|
|
||||||
{
|
|
||||||
"action": "begin_verification",
|
|
||||||
"session_id": session_id,
|
|
||||||
"runtime_generation": generation,
|
|
||||||
"runtime": runtime,
|
|
||||||
"ttl_seconds": ttl_seconds,
|
|
||||||
"binding": {
|
|
||||||
"compaction_epoch": compaction_epoch,
|
|
||||||
"request_epoch": request_epoch,
|
|
||||||
"h_source": derived.h_source,
|
|
||||||
"h_payload": derived.h_payload,
|
|
||||||
"schema_version": SCHEMA_VERSION,
|
|
||||||
},
|
|
||||||
"construction": construction,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def complete(challenge: str) -> dict[str, object]:
|
|
||||||
"""Steps 4-5. Assumes the model already emitted the receipt and the adapter
|
|
||||||
shipped it to the observer socket."""
|
|
||||||
session_id, generation, _ = session_identity()
|
|
||||||
observed = broker_request(
|
|
||||||
{
|
|
||||||
"action": "observe_receipt",
|
|
||||||
"session_id": session_id,
|
|
||||||
"runtime_generation": generation,
|
|
||||||
"receipt_challenge": challenge,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if observed.get("ok") is not True or observed.get("state") != "PENDING_PROMOTION":
|
|
||||||
return {"stage": "observe_receipt", **observed}
|
|
||||||
promoted = broker_request(
|
|
||||||
{
|
|
||||||
"action": "promote_lease",
|
|
||||||
"session_id": session_id,
|
|
||||||
"runtime_generation": generation,
|
|
||||||
"receipt_challenge": challenge,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {"stage": "promote_lease", **promoted}
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="Mosaic lease promotion client.")
|
|
||||||
group = parser.add_mutually_exclusive_group(required=True)
|
|
||||||
group.add_argument(
|
|
||||||
"--begin",
|
|
||||||
action="store_true",
|
|
||||||
help="mint a challenge; prints the receipt the MODEL must emit verbatim",
|
|
||||||
)
|
|
||||||
group.add_argument(
|
|
||||||
"--complete",
|
|
||||||
metavar="CHALLENGE",
|
|
||||||
help="observe the emitted receipt and promote the lease",
|
|
||||||
)
|
|
||||||
parser.add_argument("--ttl-seconds", type=int, default=DEFAULT_TTL_SECONDS)
|
|
||||||
arguments = parser.parse_args(argv)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if arguments.begin:
|
|
||||||
print(json.dumps(begin(ttl_seconds=arguments.ttl_seconds), indent=2))
|
|
||||||
else:
|
|
||||||
print(json.dumps(complete(arguments.complete), indent=2))
|
|
||||||
except KeyError as exc:
|
|
||||||
print(f"missing lease environment: {exc}; not a lease-gated session", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
|
||||||
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Claude UserPromptSubmit hook for operator-triggered lease promotion."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable, Mapping
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Final, TextIO
|
|
||||||
|
|
||||||
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
||||||
if _MODULE_DIRECTORY not in sys.path:
|
|
||||||
sys.path.insert(0, _MODULE_DIRECTORY)
|
|
||||||
|
|
||||||
from receipt_challenge import receipt_for # noqa: E402
|
|
||||||
|
|
||||||
MAX_FRAME: Final = 64 * 1024
|
|
||||||
PENDING_MAX_AGE_SECONDS: Final = 60 * 60
|
|
||||||
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
|
|
||||||
PROMOTION_PROMPT: Final = "/mosaic-promote"
|
|
||||||
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
|
|
||||||
PENDING_DIRECTORY: Final = "mosaic-lease"
|
|
||||||
LOCK_FILE: Final = "promotion.lock"
|
|
||||||
EXPECTED_BEGIN_KEYS: Final = frozenset(
|
|
||||||
{"ok", "state", "receipt_challenge", "receipt", "binding"}
|
|
||||||
)
|
|
||||||
EXPECTED_BINDING_KEYS: Final = frozenset(
|
|
||||||
{
|
|
||||||
"compaction_epoch",
|
|
||||||
"request_epoch",
|
|
||||||
"h_source",
|
|
||||||
"h_payload",
|
|
||||||
"runtime_generation",
|
|
||||||
"schema_version",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionAlreadyInProgress(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
||||||
value: dict[str, object] = {}
|
|
||||||
for key, item in pairs:
|
|
||||||
if key in value:
|
|
||||||
raise ValueError("duplicate promoter JSON key")
|
|
||||||
value[key] = item
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def read_hook_input(stream: object) -> dict[str, object]:
|
|
||||||
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
|
||||||
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
|
||||||
raise ValueError("invalid UserPromptSubmit input")
|
|
||||||
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValueError("invalid UserPromptSubmit input")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def emit_context(stream: TextIO, message: str) -> None:
|
|
||||||
json.dump(
|
|
||||||
{
|
|
||||||
"hookSpecificOutput": {
|
|
||||||
"hookEventName": "UserPromptSubmit",
|
|
||||||
"additionalContext": message,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
stream,
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
|
||||||
stream.write("\n")
|
|
||||||
|
|
||||||
|
|
||||||
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
|
|
||||||
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
|
|
||||||
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
|
|
||||||
if not runtime_dir.is_absolute():
|
|
||||||
raise ValueError("XDG_RUNTIME_DIR must be absolute")
|
|
||||||
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
|
|
||||||
raise ValueError("invalid lease session id")
|
|
||||||
return runtime_dir, f"pending-{session_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def open_pending_directory(runtime_dir: Path) -> int:
|
|
||||||
directory_flags = (
|
|
||||||
os.O_RDONLY
|
|
||||||
| getattr(os, "O_CLOEXEC", 0)
|
|
||||||
| getattr(os, "O_DIRECTORY", 0)
|
|
||||||
| getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
)
|
|
||||||
runtime_descriptor = os.open(runtime_dir, directory_flags)
|
|
||||||
try:
|
|
||||||
runtime_metadata = os.fstat(runtime_descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISDIR(runtime_metadata.st_mode)
|
|
||||||
or runtime_metadata.st_uid != os.getuid()
|
|
||||||
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
|
|
||||||
):
|
|
||||||
raise ValueError("unsafe XDG runtime directory")
|
|
||||||
try:
|
|
||||||
os.mkdir(PENDING_DIRECTORY, mode=0o700, dir_fd=runtime_descriptor)
|
|
||||||
except FileExistsError:
|
|
||||||
pass
|
|
||||||
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(runtime_descriptor)
|
|
||||||
|
|
||||||
metadata = os.fstat(descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISDIR(metadata.st_mode)
|
|
||||||
or metadata.st_uid != os.getuid()
|
|
||||||
or stat.S_IMODE(metadata.st_mode) != 0o700
|
|
||||||
):
|
|
||||||
os.close(descriptor)
|
|
||||||
raise ValueError("unsafe promotion pending directory")
|
|
||||||
return descriptor
|
|
||||||
|
|
||||||
|
|
||||||
def acquire_lock(directory_descriptor: int) -> int:
|
|
||||||
flags = (
|
|
||||||
os.O_RDWR
|
|
||||||
| os.O_CREAT
|
|
||||||
| getattr(os, "O_CLOEXEC", 0)
|
|
||||||
| getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
)
|
|
||||||
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
|
|
||||||
metadata = os.fstat(descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISREG(metadata.st_mode)
|
|
||||||
or metadata.st_uid != os.getuid()
|
|
||||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
|
||||||
):
|
|
||||||
os.close(descriptor)
|
|
||||||
raise ValueError("unsafe promotion lock file")
|
|
||||||
try:
|
|
||||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
except BlockingIOError as error:
|
|
||||||
os.close(descriptor)
|
|
||||||
raise PromotionAlreadyInProgress() from error
|
|
||||||
return descriptor
|
|
||||||
|
|
||||||
|
|
||||||
def sweep_stale_pending(directory_descriptor: int, current_time: float) -> None:
|
|
||||||
cutoff = current_time - PENDING_MAX_AGE_SECONDS
|
|
||||||
removed = False
|
|
||||||
with os.scandir(directory_descriptor) as entries:
|
|
||||||
for candidate in entries:
|
|
||||||
if not (
|
|
||||||
candidate.name.startswith("pending-")
|
|
||||||
or candidate.name.startswith(".pending-")
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
metadata = candidate.stat(follow_symlinks=False)
|
|
||||||
if metadata.st_mtime < cutoff and not stat.S_ISDIR(metadata.st_mode):
|
|
||||||
os.unlink(candidate.name, dir_fd=directory_descriptor)
|
|
||||||
removed = True
|
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
if removed:
|
|
||||||
os.fsync(directory_descriptor)
|
|
||||||
|
|
||||||
|
|
||||||
def write_pending(directory_descriptor: int, name: str, challenge: str) -> None:
|
|
||||||
temporary = f".{name}.tmp-{secrets.token_hex(8)}"
|
|
||||||
flags = (
|
|
||||||
os.O_WRONLY
|
|
||||||
| os.O_CREAT
|
|
||||||
| os.O_EXCL
|
|
||||||
| getattr(os, "O_CLOEXEC", 0)
|
|
||||||
| getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
)
|
|
||||||
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
|
|
||||||
try:
|
|
||||||
os.fchmod(descriptor, 0o600)
|
|
||||||
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
|
|
||||||
stream.write(challenge)
|
|
||||||
stream.flush()
|
|
||||||
os.fsync(stream.fileno())
|
|
||||||
os.replace(
|
|
||||||
temporary,
|
|
||||||
name,
|
|
||||||
src_dir_fd=directory_descriptor,
|
|
||||||
dst_dir_fd=directory_descriptor,
|
|
||||||
)
|
|
||||||
os.fsync(directory_descriptor)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
os.unlink(temporary, dir_fd=directory_descriptor)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_begin_reply(
|
|
||||||
completed: subprocess.CompletedProcess[str],
|
|
||||||
) -> tuple[str, dict[str, object] | None]:
|
|
||||||
if completed.returncode != 0:
|
|
||||||
return f"PROMOTER_EXIT_{completed.returncode}", None
|
|
||||||
try:
|
|
||||||
value = json.loads(
|
|
||||||
completed.stdout,
|
|
||||||
object_pairs_hook=reject_duplicate_json_keys,
|
|
||||||
)
|
|
||||||
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
if value.get("ok") is False and set(value) == {"ok", "code"}:
|
|
||||||
code = value.get("code")
|
|
||||||
return code if isinstance(code, str) and code else "PROMOTION_BEGIN_REFUSED", value
|
|
||||||
if set(value) != EXPECTED_BEGIN_KEYS or value.get("ok") is not True:
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
if value.get("state") != "PENDING_VERIFICATION":
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
challenge = value.get("receipt_challenge")
|
|
||||||
receipt = value.get("receipt")
|
|
||||||
binding = value.get("binding")
|
|
||||||
if (
|
|
||||||
not isinstance(challenge, str)
|
|
||||||
or len(challenge) != 64
|
|
||||||
or any(character not in "0123456789abcdef" for character in challenge)
|
|
||||||
or not isinstance(receipt, str)
|
|
||||||
or not isinstance(binding, dict)
|
|
||||||
or set(binding) != EXPECTED_BINDING_KEYS
|
|
||||||
):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
integer_fields = (
|
|
||||||
"compaction_epoch",
|
|
||||||
"request_epoch",
|
|
||||||
"runtime_generation",
|
|
||||||
"schema_version",
|
|
||||||
)
|
|
||||||
if any(type(binding.get(field)) is not int or binding[field] < 0 for field in integer_fields):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
if not all(
|
|
||||||
isinstance(binding.get(field), str)
|
|
||||||
and len(binding[field]) == 64
|
|
||||||
and all(character in "0123456789abcdef" for character in binding[field])
|
|
||||||
for field in ("h_source", "h_payload")
|
|
||||||
):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
if not secrets.compare_digest(
|
|
||||||
receipt.encode("utf-8"),
|
|
||||||
receipt_for(challenge, binding).encode("utf-8"),
|
|
||||||
):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
return "", value
|
|
||||||
|
|
||||||
|
|
||||||
def main(
|
|
||||||
*,
|
|
||||||
environ: Mapping[str, str] | None = None,
|
|
||||||
stdin: object | None = None,
|
|
||||||
stdout: TextIO | None = None,
|
|
||||||
stderr: TextIO | None = None,
|
|
||||||
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
||||||
now: Callable[[], float] = time.time,
|
|
||||||
) -> int:
|
|
||||||
source_environment = os.environ if environ is None else environ
|
|
||||||
input_stream = sys.stdin if stdin is None else stdin
|
|
||||||
output_stream = sys.stdout if stdout is None else stdout
|
|
||||||
error_stream = sys.stderr if stderr is None else stderr
|
|
||||||
|
|
||||||
try:
|
|
||||||
hook_input = read_hook_input(input_stream)
|
|
||||||
except (OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
|
|
||||||
print(f"Mosaic promotion trigger ignored invalid hook input: {error}", file=error_stream)
|
|
||||||
return 0
|
|
||||||
if hook_input.get("prompt") != PROMOTION_PROMPT:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
directory_descriptor: int | None = None
|
|
||||||
lock_descriptor: int | None = None
|
|
||||||
try:
|
|
||||||
runtime_dir, pending_name = session_pending_name(source_environment)
|
|
||||||
directory_descriptor = open_pending_directory(runtime_dir)
|
|
||||||
lock_descriptor = acquire_lock(directory_descriptor)
|
|
||||||
sweep_stale_pending(directory_descriptor, now())
|
|
||||||
completed = run(
|
|
||||||
[sys.executable, "-I", "-S", "-B", str(PROMOTER), "--begin"],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env=dict(source_environment),
|
|
||||||
timeout=PROMOTER_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
code, reply = parse_begin_reply(completed)
|
|
||||||
if code:
|
|
||||||
if completed.stderr:
|
|
||||||
print(completed.stderr.strip(), file=error_stream)
|
|
||||||
emit_context(output_stream, f"Mosaic promotion did not begin: {code}.")
|
|
||||||
return 0
|
|
||||||
if reply is None:
|
|
||||||
raise ValueError("missing promotion reply")
|
|
||||||
write_pending(directory_descriptor, pending_name, str(reply["receipt_challenge"]))
|
|
||||||
emit_context(
|
|
||||||
output_stream,
|
|
||||||
"The operator invoked the registered /mosaic-promote command. "
|
|
||||||
"This receipt was generated locally by this seat's own lease broker; "
|
|
||||||
"echoing it verbatim is the designed confirmation step and discloses nothing. "
|
|
||||||
"Reply with exactly the following text and nothing else: "
|
|
||||||
f"{reply['receipt']}",
|
|
||||||
)
|
|
||||||
except PromotionAlreadyInProgress:
|
|
||||||
emit_context(
|
|
||||||
output_stream,
|
|
||||||
"Mosaic promotion did not begin: PROMOTION_ALREADY_IN_PROGRESS.",
|
|
||||||
)
|
|
||||||
except (KeyError, OSError, RecursionError, ValueError, subprocess.SubprocessError) as error:
|
|
||||||
print(f"Mosaic promotion begin failed: {type(error).__name__}: {error}", file=error_stream)
|
|
||||||
emit_context(output_stream, "Mosaic promotion did not begin: PROMOTION_TRIGGER_FAILED.")
|
|
||||||
finally:
|
|
||||||
if lock_descriptor is not None:
|
|
||||||
os.close(lock_descriptor)
|
|
||||||
if directory_descriptor is not None:
|
|
||||||
os.close(directory_descriptor)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Claude Stop hook that completes a pending operator-triggered promotion."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from collections.abc import Callable, Mapping
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Final, NamedTuple, TextIO
|
|
||||||
|
|
||||||
MAX_FRAME: Final = 64 * 1024
|
|
||||||
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
|
|
||||||
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
|
|
||||||
PENDING_DIRECTORY: Final = "mosaic-lease"
|
|
||||||
LOCK_FILE: Final = "promotion.lock"
|
|
||||||
TERMINAL_FAILURE_CODES: Final = frozenset(
|
|
||||||
{
|
|
||||||
"RECEIPT_REPLAY",
|
|
||||||
"RECEIPT_MISMATCH",
|
|
||||||
"INVALID_LEASE_TRANSITION",
|
|
||||||
"PROMOTION_TOKEN_INVALID",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PendingChallenge(NamedTuple):
|
|
||||||
value: str
|
|
||||||
device: int
|
|
||||||
inode: int
|
|
||||||
|
|
||||||
|
|
||||||
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
||||||
value: dict[str, object] = {}
|
|
||||||
for key, item in pairs:
|
|
||||||
if key in value:
|
|
||||||
raise ValueError("duplicate promoter JSON key")
|
|
||||||
value[key] = item
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
|
|
||||||
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
|
|
||||||
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
|
|
||||||
if not runtime_dir.is_absolute():
|
|
||||||
raise ValueError("XDG_RUNTIME_DIR must be absolute")
|
|
||||||
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
|
|
||||||
raise ValueError("invalid lease session id")
|
|
||||||
return runtime_dir, f"pending-{session_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def open_pending_directory(runtime_dir: Path) -> int | None:
|
|
||||||
directory_flags = (
|
|
||||||
os.O_RDONLY
|
|
||||||
| getattr(os, "O_CLOEXEC", 0)
|
|
||||||
| getattr(os, "O_DIRECTORY", 0)
|
|
||||||
| getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
runtime_descriptor = os.open(runtime_dir, directory_flags)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
runtime_metadata = os.fstat(runtime_descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISDIR(runtime_metadata.st_mode)
|
|
||||||
or runtime_metadata.st_uid != os.getuid()
|
|
||||||
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
|
|
||||||
):
|
|
||||||
raise ValueError("unsafe XDG runtime directory")
|
|
||||||
try:
|
|
||||||
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
os.close(runtime_descriptor)
|
|
||||||
|
|
||||||
metadata = os.fstat(descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISDIR(metadata.st_mode)
|
|
||||||
or metadata.st_uid != os.getuid()
|
|
||||||
or stat.S_IMODE(metadata.st_mode) != 0o700
|
|
||||||
):
|
|
||||||
os.close(descriptor)
|
|
||||||
raise ValueError("unsafe promotion pending directory")
|
|
||||||
return descriptor
|
|
||||||
|
|
||||||
|
|
||||||
def acquire_lock(directory_descriptor: int) -> int:
|
|
||||||
flags = (
|
|
||||||
os.O_RDWR
|
|
||||||
| os.O_CREAT
|
|
||||||
| getattr(os, "O_CLOEXEC", 0)
|
|
||||||
| getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
)
|
|
||||||
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
|
|
||||||
metadata = os.fstat(descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISREG(metadata.st_mode)
|
|
||||||
or metadata.st_uid != os.getuid()
|
|
||||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
|
||||||
):
|
|
||||||
os.close(descriptor)
|
|
||||||
raise ValueError("unsafe promotion lock file")
|
|
||||||
try:
|
|
||||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
except BlockingIOError:
|
|
||||||
os.close(descriptor)
|
|
||||||
raise
|
|
||||||
return descriptor
|
|
||||||
|
|
||||||
|
|
||||||
def read_pending(directory_descriptor: int, name: str) -> PendingChallenge | None:
|
|
||||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
try:
|
|
||||||
descriptor = os.open(name, flags, dir_fd=directory_descriptor)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
metadata = os.fstat(descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISREG(metadata.st_mode)
|
|
||||||
or metadata.st_uid != os.getuid()
|
|
||||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
|
||||||
or metadata.st_size <= 0
|
|
||||||
or metadata.st_size > MAX_FRAME
|
|
||||||
):
|
|
||||||
raise ValueError("unsafe promotion pending file")
|
|
||||||
raw = os.read(descriptor, MAX_FRAME + 1)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
if len(raw) > MAX_FRAME:
|
|
||||||
raise ValueError("oversized promotion challenge")
|
|
||||||
challenge = raw.decode("utf-8")
|
|
||||||
if (
|
|
||||||
len(challenge) != 64
|
|
||||||
or any(character not in "0123456789abcdef" for character in challenge)
|
|
||||||
):
|
|
||||||
raise ValueError("invalid promotion challenge")
|
|
||||||
return PendingChallenge(challenge, metadata.st_dev, metadata.st_ino)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_pending_if_unchanged(
|
|
||||||
directory_descriptor: int,
|
|
||||||
name: str,
|
|
||||||
pending: PendingChallenge,
|
|
||||||
error_stream: TextIO,
|
|
||||||
) -> None:
|
|
||||||
quarantine = f".{name}.delete-{secrets.token_hex(8)}"
|
|
||||||
try:
|
|
||||||
os.rename(
|
|
||||||
name,
|
|
||||||
quarantine,
|
|
||||||
src_dir_fd=directory_descriptor,
|
|
||||||
dst_dir_fd=directory_descriptor,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return
|
|
||||||
except OSError as error:
|
|
||||||
print(f"Mosaic promotion could not quarantine pending file: {error}", file=error_stream)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
moved = os.stat(
|
|
||||||
quarantine,
|
|
||||||
dir_fd=directory_descriptor,
|
|
||||||
follow_symlinks=False,
|
|
||||||
)
|
|
||||||
if (moved.st_dev, moved.st_ino) == (pending.device, pending.inode):
|
|
||||||
os.unlink(quarantine, dir_fd=directory_descriptor)
|
|
||||||
os.fsync(directory_descriptor)
|
|
||||||
return
|
|
||||||
|
|
||||||
print("Mosaic promotion pending file changed; preserving replacement.", file=error_stream)
|
|
||||||
try:
|
|
||||||
os.link(
|
|
||||||
quarantine,
|
|
||||||
name,
|
|
||||||
src_dir_fd=directory_descriptor,
|
|
||||||
dst_dir_fd=directory_descriptor,
|
|
||||||
follow_symlinks=False,
|
|
||||||
)
|
|
||||||
except FileExistsError:
|
|
||||||
print(
|
|
||||||
f"Mosaic promotion preserved replacement as {quarantine}.",
|
|
||||||
file=error_stream,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
os.unlink(quarantine, dir_fd=directory_descriptor)
|
|
||||||
os.fsync(directory_descriptor)
|
|
||||||
except OSError as error:
|
|
||||||
print(f"Mosaic promotion could not resolve pending file: {error}", file=error_stream)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_reply(completed: subprocess.CompletedProcess[str]) -> dict[str, object] | None:
|
|
||||||
if completed.returncode != 0:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
value = json.loads(
|
|
||||||
completed.stdout,
|
|
||||||
object_pairs_hook=reject_duplicate_json_keys,
|
|
||||||
)
|
|
||||||
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return None
|
|
||||||
if set(value) == {"stage", "ok", "state"}:
|
|
||||||
if (
|
|
||||||
value.get("stage") == "promote_lease"
|
|
||||||
and value.get("ok") is True
|
|
||||||
and value.get("state") == "VERIFIED"
|
|
||||||
):
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
if set(value) == {"stage", "ok", "code"}:
|
|
||||||
if (
|
|
||||||
value.get("stage") in {"observe_receipt", "promote_lease"}
|
|
||||||
and value.get("ok") is False
|
|
||||||
and isinstance(value.get("code"), str)
|
|
||||||
and value.get("code")
|
|
||||||
):
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main(
|
|
||||||
*,
|
|
||||||
environ: Mapping[str, str] | None = None,
|
|
||||||
stderr: TextIO | None = None,
|
|
||||||
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
||||||
) -> int:
|
|
||||||
source_environment = os.environ if environ is None else environ
|
|
||||||
error_stream = sys.stderr if stderr is None else stderr
|
|
||||||
directory_descriptor: int | None = None
|
|
||||||
lock_descriptor: int | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
runtime_dir, pending_name = session_pending_name(source_environment)
|
|
||||||
directory_descriptor = open_pending_directory(runtime_dir)
|
|
||||||
if directory_descriptor is None:
|
|
||||||
return 0
|
|
||||||
try:
|
|
||||||
lock_descriptor = acquire_lock(directory_descriptor)
|
|
||||||
except (BlockingIOError, FileNotFoundError):
|
|
||||||
print("Mosaic promotion completion deferred: promotion is in progress.", file=error_stream)
|
|
||||||
return 0
|
|
||||||
pending = read_pending(directory_descriptor, pending_name)
|
|
||||||
if pending is None:
|
|
||||||
return 0
|
|
||||||
completed = run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-I",
|
|
||||||
"-S",
|
|
||||||
"-B",
|
|
||||||
str(PROMOTER),
|
|
||||||
"--complete",
|
|
||||||
pending.value,
|
|
||||||
],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env=dict(source_environment),
|
|
||||||
timeout=PROMOTER_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
reply = parse_reply(completed)
|
|
||||||
if reply is not None and reply.get("ok") is True:
|
|
||||||
delete_pending_if_unchanged(
|
|
||||||
directory_descriptor,
|
|
||||||
pending_name,
|
|
||||||
pending,
|
|
||||||
error_stream,
|
|
||||||
)
|
|
||||||
print("Mosaic lease promotion completed.", file=error_stream)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if reply is not None:
|
|
||||||
code = str(reply["code"])
|
|
||||||
print(f"Mosaic promotion incomplete: {code}.", file=error_stream)
|
|
||||||
if code in TERMINAL_FAILURE_CODES:
|
|
||||||
delete_pending_if_unchanged(
|
|
||||||
directory_descriptor,
|
|
||||||
pending_name,
|
|
||||||
pending,
|
|
||||||
error_stream,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
diagnostic = completed.stderr.strip() or f"promoter exit {completed.returncode}"
|
|
||||||
print(f"Mosaic promotion retryable failure: {diagnostic}.", file=error_stream)
|
|
||||||
except (KeyError, OSError, RecursionError, UnicodeError, ValueError, subprocess.SubprocessError) as error:
|
|
||||||
print(f"Mosaic promotion completion deferred: {type(error).__name__}: {error}", file=error_stream)
|
|
||||||
finally:
|
|
||||||
if lock_descriptor is not None:
|
|
||||||
os.close(lock_descriptor)
|
|
||||||
if directory_descriptor is not None:
|
|
||||||
os.close(directory_descriptor)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -22,23 +22,13 @@ from typing import Final
|
|||||||
MAX_FRAME: Final = 64 * 1024
|
MAX_FRAME: Final = 64 * 1024
|
||||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||||
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
|
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
|
||||||
BENIGN_OBSERVATION_UNAVAILABLE_CODE: Final = "OBSERVATION_UNAVAILABLE"
|
|
||||||
|
|
||||||
|
|
||||||
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
||||||
value: dict[str, object] = {}
|
|
||||||
for key, item in pairs:
|
|
||||||
if key in value:
|
|
||||||
raise ValueError("duplicate observer JSON key")
|
|
||||||
value[key] = item
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def read_json(stream: object) -> dict[str, object]:
|
def read_json(stream: object) -> dict[str, object]:
|
||||||
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
||||||
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
||||||
raise ValueError("invalid observer input")
|
raise ValueError("invalid observer input")
|
||||||
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
value = json.loads(raw)
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ValueError("invalid observer input")
|
raise ValueError("invalid observer input")
|
||||||
return value
|
return value
|
||||||
@@ -110,9 +100,9 @@ def observer_request(socket_path: Path, request: dict[str, object]) -> dict[str,
|
|||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
response.extend(chunk)
|
response.extend(chunk)
|
||||||
if len(response) > MAX_FRAME or response.count(b"\n") != 1 or not response.endswith(b"\n"):
|
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||||
raise ValueError("invalid observer reply")
|
raise ValueError("invalid observer reply")
|
||||||
value = json.loads(response[:-1], object_pairs_hook=reject_duplicate_json_keys)
|
value = json.loads(response)
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ValueError("invalid observer reply")
|
raise ValueError("invalid observer reply")
|
||||||
return value
|
return value
|
||||||
@@ -129,12 +119,7 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
|
|||||||
if arguments.runtime == "claude":
|
if arguments.runtime == "claude":
|
||||||
if not arguments.latest_entry:
|
if not arguments.latest_entry:
|
||||||
raise ValueError("Claude observer requires --latest-entry")
|
raise ValueError("Claude observer requires --latest-entry")
|
||||||
if "last_assistant_message" in source:
|
message = claude_latest_entry(source)
|
||||||
message = source["last_assistant_message"]
|
|
||||||
if not isinstance(message, str):
|
|
||||||
raise ValueError("invalid Claude observer input")
|
|
||||||
else:
|
|
||||||
message = claude_latest_entry(source)
|
|
||||||
else:
|
else:
|
||||||
if arguments.latest_entry:
|
if arguments.latest_entry:
|
||||||
raise ValueError("Pi observer is message_end only")
|
raise ValueError("Pi observer is message_end only")
|
||||||
@@ -148,18 +133,10 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
|
|||||||
"runtime": arguments.runtime,
|
"runtime": arguments.runtime,
|
||||||
"latest_assistant_message": message,
|
"latest_assistant_message": message,
|
||||||
})
|
})
|
||||||
except (KeyError, OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
|
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
|
||||||
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
|
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
if set(reply) == {"ok"} and reply.get("ok") is True:
|
return 0 if reply == {"ok": True} else 2
|
||||||
return 0
|
|
||||||
if (
|
|
||||||
set(reply) == {"ok", "code"}
|
|
||||||
and reply.get("ok") is False
|
|
||||||
and reply.get("code") == BENIGN_OBSERVATION_UNAVAILABLE_CODE
|
|
||||||
):
|
|
||||||
return 0
|
|
||||||
return 2
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def is_verbatim_receipt(message: str, challenge: str, binding: dict[str, object]
|
|||||||
"""Require the exact one current-cycle receipt, not a transcript substring."""
|
"""Require the exact one current-cycle receipt, not a transcript substring."""
|
||||||
|
|
||||||
expected = receipt_for(challenge, binding)
|
expected = receipt_for(challenge, binding)
|
||||||
return hmac.compare_digest(message.encode("utf-8"), expected.encode("utf-8"))
|
return hmac.compare_digest(message, expected)
|
||||||
|
|
||||||
|
|
||||||
def latest_assistant_digest(message: str) -> str:
|
def latest_assistant_digest(message: str) -> str:
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re
|
|||||||
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||||
|
|
||||||
# --- single-suite directories: unmeasured in CI ---
|
# --- single-suite directories: unmeasured in CI ---
|
||||||
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
|
|
||||||
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
|
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
|
||||||
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
|
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
|
||||||
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
|
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_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/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_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/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 && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -131,13 +131,14 @@ async function exists(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
||||||
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => {
|
it('maps a roster-v2 agent to exactly the nine generated projection keys', (): void => {
|
||||||
const roster = parseRosterV2(rosterYaml, 'yaml');
|
const roster = parseRosterV2(rosterYaml, 'yaml');
|
||||||
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
||||||
expect(agent).toBeDefined();
|
expect(agent).toBeDefined();
|
||||||
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
||||||
expect(values).toEqual({
|
expect(values).toEqual({
|
||||||
MOSAIC_AGENT_NAME: 'coder0',
|
MOSAIC_AGENT_NAME: 'coder0',
|
||||||
|
MOSAIC_GIT_IDENTITY: 'coder0',
|
||||||
MOSAIC_AGENT_CLASS: 'code',
|
MOSAIC_AGENT_CLASS: 'code',
|
||||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ describe('fleet roster parsing', () => {
|
|||||||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||||||
[
|
[
|
||||||
'MOSAIC_AGENT_NAME=coder0',
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
|
'MOSAIC_GIT_IDENTITY=coder0',
|
||||||
// Reflects the roster's canonicalized compatibility class (A3a).
|
// Reflects the roster's canonicalized compatibility class (A3a).
|
||||||
'MOSAIC_AGENT_CLASS=code',
|
'MOSAIC_AGENT_CLASS=code',
|
||||||
'MOSAIC_AGENT_RUNTIME=codex',
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
@@ -3799,6 +3800,7 @@ describe('fleet add command', () => {
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||||
|
expect(envContent).toContain('MOSAIC_GIT_IDENTITY=coder0');
|
||||||
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ function generateAgentEnvValues(
|
|||||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_GIT_IDENTITY: agent.name,
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||||
|
|||||||
@@ -14,11 +14,9 @@ import {
|
|||||||
readdirSync,
|
readdirSync,
|
||||||
realpathSync,
|
realpathSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
appendFileSync,
|
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { homedir, hostname } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import {
|
import {
|
||||||
@@ -44,163 +42,6 @@ const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
|||||||
pi: 'Pi',
|
pi: 'Pi',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Harness home isolation ──────────────────────────────────────────────────
|
|
||||||
// Mosaic-launched runtimes read config from a dedicated home under the mosaic
|
|
||||||
// tree — never the operator's base install. A bare `claude` / `pi` therefore
|
|
||||||
// keeps its own config AND its own auth, and stays a working break-glass no
|
|
||||||
// matter what mosaic does to its own tree.
|
|
||||||
//
|
|
||||||
// These paths are manifest-UNKNOWN, which resolves to operator ownership
|
|
||||||
// (framework-manifest.txt rule 3, #791), so a keep-mode `mosaic update` can
|
|
||||||
// neither overwrite nor prune them. Overwrite-mode install still would.
|
|
||||||
//
|
|
||||||
// opencode has no dedicated config-dir variable and follows XDG, so isolating it
|
|
||||||
// sets XDG_CONFIG_HOME for that process tree. That is blunter than the other
|
|
||||||
// three: it also relocates XDG lookups for anything opencode spawns.
|
|
||||||
const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
|
|
||||||
claude: 'CLAUDE_CONFIG_DIR',
|
|
||||||
pi: 'PI_CODING_AGENT_DIR',
|
|
||||||
codex: 'CODEX_HOME',
|
|
||||||
opencode: 'XDG_CONFIG_HOME',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
|
|
||||||
function harnessHome(runtime: RuntimeName): string {
|
|
||||||
return join(MOSAIC_HOME, `.${runtime}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Env overlay pointing a runtime at its mosaic-owned home. The directory is
|
|
||||||
* created on demand so a first launch does not fail on a missing path.
|
|
||||||
*/
|
|
||||||
function harnessEnv(runtime: RuntimeName): Record<string, string> {
|
|
||||||
const key = HARNESS_HOME_ENV[runtime];
|
|
||||||
if (!key) return {};
|
|
||||||
const home = harnessHome(runtime);
|
|
||||||
mkdirSync(home, { recursive: true });
|
|
||||||
return { [key]: home };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Launch record (immutable provenance) ────────────────────────────────────
|
|
||||||
// MANDATORY and MECHANICAL: every launch appends one record of what the agent
|
|
||||||
// actually launched with, written before exec. No model involvement, no opt-out.
|
|
||||||
//
|
|
||||||
// WHY LAUNCH-TIME AND NOT INSPECT-LATER: pi rewrites its own argv to a bare
|
|
||||||
// `pi`, so /proc/<pid>/cmdline DESTROYS the launch evidence. That has already
|
|
||||||
// produced a confident wrong diagnosis ("this agent bypassed the launcher"),
|
|
||||||
// disproved only by the parent process's argv and only because the parent had
|
|
||||||
// not yet exited. A record written before exec is the only place this survives.
|
|
||||||
//
|
|
||||||
// Lands in fleet/run/sessions/ — the #797 Runtime Session Ledger path, already
|
|
||||||
// operator-classified in framework-manifest.txt and already covered by
|
|
||||||
// test-upgrade-manifest-guard.sh, so an upgrade can neither overwrite nor prune
|
|
||||||
// it.
|
|
||||||
//
|
|
||||||
// CORRELATION is by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime()
|
|
||||||
// uses spawnSync, so the runtime is a CHILD with a different pid.
|
|
||||||
// launch-runtime.py appends the matching `lease.register` event.
|
|
||||||
//
|
|
||||||
// NEVER records a credential value: env is captured as PRESENT NAMES ONLY, and
|
|
||||||
// oversized argv values (the composed system prompt) become a digest + length.
|
|
||||||
const LAUNCH_LEDGER_DIR = join(MOSAIC_HOME, 'fleet', 'run', 'sessions');
|
|
||||||
|
|
||||||
const CLI_VERSION: string | null = (() => {
|
|
||||||
try {
|
|
||||||
// Resolved RELATIVELY: the package `exports` map does not expose
|
|
||||||
// package.json, so '@mosaicstack/mosaic/package.json' throws
|
|
||||||
// ERR_PACKAGE_PATH_NOT_EXPORTED. Same relative depth from src/ and dist/.
|
|
||||||
return (createRequire(import.meta.url)('../../package.json') as { version: string }).version;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
interface NormativeFragmentDigest {
|
|
||||||
source_id: string;
|
|
||||||
sha256: string | null;
|
|
||||||
bytes: number | null;
|
|
||||||
missing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sha256Of(value: string | Buffer): string {
|
|
||||||
return createHash('sha256').update(value).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hash the normative sources injected into the agent. This is "what the agent
|
|
||||||
* IS" — and it is the same fragment set the lease broker hashes for promotion,
|
|
||||||
* so an unexpected digest here is a mechanically detectable red flag rather than
|
|
||||||
* a matter of judgement.
|
|
||||||
*/
|
|
||||||
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
|
|
||||||
const candidates: Array<[string, string]> = [
|
|
||||||
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
|
|
||||||
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
|
|
||||||
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
|
|
||||||
['USER.md', join(MOSAIC_HOME, 'USER.md')],
|
|
||||||
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
|
|
||||||
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
|
|
||||||
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
|
|
||||||
];
|
|
||||||
return candidates.map(([sourceId, path]) => {
|
|
||||||
try {
|
|
||||||
const bytes = readFileSync(path);
|
|
||||||
return { source_id: sourceId, sha256: sha256Of(bytes), bytes: bytes.length };
|
|
||||||
} catch {
|
|
||||||
return { source_id: sourceId, sha256: null, bytes: null, missing: true };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** argv with oversized values replaced by a digest, so the record stays small
|
|
||||||
* and never inlines injected content verbatim. */
|
|
||||||
function redactArgv(argv: string[]): string[] {
|
|
||||||
return argv.map((a) =>
|
|
||||||
typeof a === 'string' && a.length > 256
|
|
||||||
? `<redacted sha256:${sha256Of(a).slice(0, 16)} bytes:${a.length}>`
|
|
||||||
: a,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
|
|
||||||
try {
|
|
||||||
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
|
||||||
// Correlation id for the lease.register half. Set into process.env so it
|
|
||||||
// propagates through every `...process.env` / `...baseEnv` spread below.
|
|
||||||
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
|
||||||
process.env['MOSAIC_LAUNCH_ID'] = launchId;
|
|
||||||
const record = {
|
|
||||||
seq: Date.now(),
|
|
||||||
kind: 'session.launch',
|
|
||||||
launch_id: launchId,
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
host: hostname(),
|
|
||||||
pid: process.pid,
|
|
||||||
runtime,
|
|
||||||
mode: yolo ? 'yolo' : 'normal',
|
|
||||||
cwd: process.cwd(),
|
|
||||||
cli_version: CLI_VERSION,
|
|
||||||
config_home: harnessHome(runtime),
|
|
||||||
config_home_isolated: true,
|
|
||||||
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
|
|
||||||
argv: redactArgv(cliArgs),
|
|
||||||
normative_fragments: normativeFragmentDigests(runtime),
|
|
||||||
// names only — values are never recorded
|
|
||||||
mosaic_env_present: Object.keys(process.env)
|
|
||||||
.filter((k) => k.startsWith('MOSAIC_'))
|
|
||||||
.sort(),
|
|
||||||
};
|
|
||||||
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// Never block a launch on bookkeeping — but never fail silently either.
|
|
||||||
console.error(
|
|
||||||
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function checkMosaicHome(): void {
|
function checkMosaicHome(): void {
|
||||||
@@ -264,11 +105,11 @@ interface SettingsAudit {
|
|||||||
|
|
||||||
function auditClaudeSettings(): SettingsAudit {
|
function auditClaudeSettings(): SettingsAudit {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const settingsPath = join(harnessHome('claude'), 'settings.json');
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||||
const settings = readJson(settingsPath);
|
const settings = readJson(settingsPath);
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
warnings.push(`${settingsPath} not found — hooks and plugins will be missing`);
|
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
|
||||||
return { warnings };
|
return { warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,9 +561,7 @@ function skillRealPath(dir: string): string {
|
|||||||
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
|
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
|
||||||
* skills dir and the project-local one relative to the launch cwd. */
|
* skills dir and the project-local one relative to the launch cwd. */
|
||||||
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
|
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
|
||||||
// PI_CODING_AGENT_DIR replaces ~/.pi/agent (not ~/.pi), so skills live at
|
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
|
||||||
// <home>/skills — there is no extra 'agent' segment under the isolated home.
|
|
||||||
return [join(harnessHome('pi'), 'skills'), join(cwd, '.pi', 'skills')];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
|
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
|
||||||
@@ -925,13 +764,12 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('claude', cliArgs, yolo);
|
|
||||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'codex': {
|
case 'codex': {
|
||||||
ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md'));
|
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
|
||||||
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
||||||
if (hasMissionNoArgs) {
|
if (hasMissionNoArgs) {
|
||||||
cliArgs.push(missionPrompt);
|
cliArgs.push(missionPrompt);
|
||||||
@@ -939,17 +777,14 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('codex', cliArgs, yolo);
|
execRuntime('codex', cliArgs);
|
||||||
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'opencode': {
|
case 'opencode': {
|
||||||
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
|
||||||
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||||
recordLaunch('opencode', args, yolo);
|
execRuntime('opencode', args);
|
||||||
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,7 +799,6 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('pi', cliArgs, yolo);
|
|
||||||
execLeaseGatedRuntime('pi', cliArgs);
|
execLeaseGatedRuntime('pi', cliArgs);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1001,7 +835,6 @@ function execLeaseGatedRuntime(
|
|||||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||||
{
|
{
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
...harnessEnv(runtime),
|
|
||||||
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||||
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ function generatedValues(
|
|||||||
): Readonly<Record<string, string>> {
|
): Readonly<Record<string, string>> {
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_GIT_IDENTITY: agent.name,
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.model,
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ const COMMAND_RECORDS: Readonly<Record<string, RegExp>> = {
|
|||||||
|
|
||||||
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
||||||
'DATA.DOTENV.FLEET_LAUNCH':
|
'DATA.DOTENV.FLEET_LAUNCH':
|
||||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||||
'DATA.TEXT_TABLE.FLEET_TASKS':
|
'DATA.TEXT_TABLE.FLEET_TASKS':
|
||||||
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
|
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
|
||||||
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
|
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
|
||||||
@@ -406,7 +406,7 @@ const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
|||||||
'DATA.JSON.MUTATION_RESULT':
|
'DATA.JSON.MUTATION_RESULT':
|
||||||
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
|
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
|
||||||
'DATA.DOTENV.GENERATED_ENV':
|
'DATA.DOTENV.GENERATED_ENV':
|
||||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||||
'DATA.YAML.ROSTER_FIELDS':
|
'DATA.YAML.ROSTER_FIELDS':
|
||||||
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
|
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
|
||||||
};
|
};
|
||||||
@@ -922,8 +922,8 @@ describe('fleet operator documentation', (): void => {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
|
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
|
||||||
).toHaveLength(858);
|
).toHaveLength(863);
|
||||||
expect(surfaces).toHaveLength(882);
|
expect(surfaces).toHaveLength(887);
|
||||||
|
|
||||||
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
||||||
const auxiliary: CodeSurface = {
|
const auxiliary: CodeSurface = {
|
||||||
|
|||||||
@@ -597,6 +597,7 @@ export function projectRosterV2AgentGeneratedEnv(
|
|||||||
): Readonly<Record<string, string>> {
|
): Readonly<Record<string, string>> {
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_GIT_IDENTITY: agent.name,
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.model,
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
|
|
||||||
const generatedValues = {
|
const generatedValues = {
|
||||||
MOSAIC_AGENT_NAME: 'coder0',
|
MOSAIC_AGENT_NAME: 'coder0',
|
||||||
|
MOSAIC_GIT_IDENTITY: 'coder0',
|
||||||
MOSAIC_AGENT_CLASS: 'code',
|
MOSAIC_AGENT_CLASS: 'code',
|
||||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
||||||
@@ -45,6 +46,7 @@ describe('generated fleet agent environment boundary', (): void => {
|
|||||||
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
||||||
[
|
[
|
||||||
'MOSAIC_AGENT_NAME=coder0',
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
|
'MOSAIC_GIT_IDENTITY=coder0',
|
||||||
'MOSAIC_AGENT_CLASS=code',
|
'MOSAIC_AGENT_CLASS=code',
|
||||||
'MOSAIC_AGENT_RUNTIME=pi',
|
'MOSAIC_AGENT_RUNTIME=pi',
|
||||||
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
||||||
@@ -78,6 +80,22 @@ describe('generated fleet agent environment boundary', (): void => {
|
|||||||
expect(String(error)).toMatch(/key=.*sha256=/);
|
expect(String(error)).toMatch(/key=.*sha256=/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['unsafe-git-identity', 'other/identity'],
|
||||||
|
['git-identity-mismatch', 'reviewer0'],
|
||||||
|
])('rejects %s before any launch consumer can use it', (code: string, identity: string): void => {
|
||||||
|
expect((): void => {
|
||||||
|
renderGeneratedAgentEnvironment({
|
||||||
|
...generatedValues,
|
||||||
|
MOSAIC_GIT_IDENTITY: identity,
|
||||||
|
});
|
||||||
|
}).toThrow(
|
||||||
|
expect.objectContaining({
|
||||||
|
diagnostic: expect.objectContaining({ code, key: 'MOSAIC_GIT_IDENTITY' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
||||||
expect((): void => {
|
expect((): void => {
|
||||||
renderGeneratedAgentEnvironment({
|
renderGeneratedAgentEnvironment({
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export class AgentEnvBoundaryError extends Error {
|
|||||||
|
|
||||||
export const GENERATED_AGENT_ENV_KEYS = [
|
export const GENERATED_AGENT_ENV_KEYS = [
|
||||||
'MOSAIC_AGENT_NAME',
|
'MOSAIC_AGENT_NAME',
|
||||||
|
'MOSAIC_GIT_IDENTITY',
|
||||||
'MOSAIC_AGENT_CLASS',
|
'MOSAIC_AGENT_CLASS',
|
||||||
'MOSAIC_AGENT_RUNTIME',
|
'MOSAIC_AGENT_RUNTIME',
|
||||||
'MOSAIC_AGENT_MODEL',
|
'MOSAIC_AGENT_MODEL',
|
||||||
@@ -402,6 +403,7 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
|||||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||||
}
|
}
|
||||||
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
||||||
|
const gitIdentity = requiredGeneratedValue(values, 'MOSAIC_GIT_IDENTITY');
|
||||||
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
||||||
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
||||||
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
||||||
@@ -412,6 +414,12 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
|||||||
|
|
||||||
if (!AGENT_NAME.test(name))
|
if (!AGENT_NAME.test(name))
|
||||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
||||||
|
if (!AGENT_NAME.test(gitIdentity)) {
|
||||||
|
throw new AgentEnvBoundaryError('unsafe-git-identity', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
||||||
|
}
|
||||||
|
if (gitIdentity !== name) {
|
||||||
|
throw new AgentEnvBoundaryError('git-identity-mismatch', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
||||||
|
}
|
||||||
if (!POLICY_NAME.test(className)) {
|
if (!POLICY_NAME.test(className)) {
|
||||||
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1405,6 +1405,7 @@ function generatedValues(
|
|||||||
): Readonly<Record<string, string>> {
|
): Readonly<Record<string, string>> {
|
||||||
return {
|
return {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_GIT_IDENTITY: agent.name,
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
MOSAIC_AGENT_MODEL: agent.model,
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Invariant R: a read-only carve-out can neither disappear nor be shadowed.
|
|
||||||
|
|
||||||
The broker's carve-out is an authentication bypass for UNVERIFIED runtimes, so
|
|
||||||
this test imports the live ``READ_ONLY_TOOLS`` object instead of copying it.
|
|
||||||
Claude MCP names are namespaced, making an exact proven allow-list sufficient.
|
|
||||||
Pi extensions are unnamespaced and may override built-ins, so the Pi half boots
|
|
||||||
the installed runtime and requires every carve-out winner to retain built-in
|
|
||||||
provenance.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
|
|
||||||
PACKAGE_ROOT = Path(__file__).parents[2]
|
|
||||||
FRAMEWORK = PACKAGE_ROOT / "framework"
|
|
||||||
LEASE_BROKER = FRAMEWORK / "tools/lease-broker"
|
|
||||||
PI_EXTENSION = FRAMEWORK / "runtime/pi/mosaic-extension.ts"
|
|
||||||
sys.path.insert(0, str(LEASE_BROKER))
|
|
||||||
daemon = importlib.import_module("daemon")
|
|
||||||
READ_ONLY_TOOLS = daemon.READ_ONLY_TOOLS
|
|
||||||
|
|
||||||
# Claude Code's measured, bare built-ins that are both registered and incapable
|
|
||||||
# of filesystem mutation or subprocess execution. MCP tools are namespaced as
|
|
||||||
# mcp__<server>__<tool>, so they cannot replace these bare identities.
|
|
||||||
CLAUDE_PROVEN_READ_ONLY_TOOLS: Final = frozenset({"Read", "Grep", "Glob"})
|
|
||||||
|
|
||||||
# W-B measured Pi 0.84.1 through getAllTools(), observed every tool_call name,
|
|
||||||
# and cross-checked dist/core/tools/index.js:18. Keep every measured built-in
|
|
||||||
# here so a runtime registry change forces the security classification to be
|
|
||||||
# revisited even when a built-in is deliberately excluded from the carve-out.
|
|
||||||
PI_VERSION: Final = "0.84.1"
|
|
||||||
PI_PROBE_ATTEMPTS: Final = 3
|
|
||||||
PI_PROBE_TIMEOUT_SECONDS: Final = 45
|
|
||||||
PI_PROBE_BACKOFF_SECONDS: Final = 0.25
|
|
||||||
PI_PROVEN_READ_ONLY_TOOLS: Final = frozenset({"read", "ls"})
|
|
||||||
PI_SUBPROCESS_TOOLS: Final = frozenset({"grep", "find"})
|
|
||||||
PI_MUTATING_TOOLS: Final = frozenset({"bash", "edit", "write"})
|
|
||||||
PI_MEASURED_BUILTINS: Final = (
|
|
||||||
PI_PROVEN_READ_ONLY_TOOLS | PI_SUBPROCESS_TOOLS | PI_MUTATING_TOOLS
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pi 0.84.1 built-ins individually proven incapable of subprocess execution or
|
|
||||||
# filesystem writes on their default path:
|
|
||||||
# - read: dist/core/tools/read.js:26-29 dispatches only read/access operations.
|
|
||||||
# - ls: dist/core/tools/ls.js:19-22 dispatches only exists/stat/readdir operations.
|
|
||||||
# grep and find are deliberately absent: grep.js:99/148 and find.js:161/203
|
|
||||||
# reach ensureTool(..., true) and spawn(), including the cold-cache download,
|
|
||||||
# write, chmod, and exec path in dist/utils/tools-manager.js:285-313.
|
|
||||||
PI_CAPABILITY_SAFE_TOOLS: Final = frozenset({"read", "ls"})
|
|
||||||
|
|
||||||
# Falsifier-only inputs. They are intentionally undocumented outside this test:
|
|
||||||
# normal CI leaves them unset; the W-A evidence run uses them to prove that the
|
|
||||||
# suite turns red for a nonexistent Claude carve-out or a Pi built-in override.
|
|
||||||
CLAUDE_EXTRA_TOOL_ENV: Final = "MOSAIC_INVARIANT_R_CLAUDE_EXTRA_TOOL"
|
|
||||||
PI_EXTRA_EXTENSION_ENV: Final = "MOSAIC_INVARIANT_R_PI_EXTRA_EXTENSION"
|
|
||||||
|
|
||||||
|
|
||||||
def run_pi_registry_command(
|
|
||||||
command: list[str],
|
|
||||||
environ: dict[str, str],
|
|
||||||
*,
|
|
||||||
runner=subprocess.run,
|
|
||||||
sleeper=time.sleep,
|
|
||||||
) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""Run the registry probe with bounded retries for concurrent-Pi stalls."""
|
|
||||||
|
|
||||||
for attempt in range(1, PI_PROBE_ATTEMPTS + 1):
|
|
||||||
try:
|
|
||||||
return runner(
|
|
||||||
command,
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env=environ,
|
|
||||||
timeout=PI_PROBE_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired as error:
|
|
||||||
if attempt == PI_PROBE_ATTEMPTS:
|
|
||||||
raise AssertionError(
|
|
||||||
"Pi registry probe could not complete after "
|
|
||||||
f"{PI_PROBE_ATTEMPTS} attempts (concurrent pi?); this is a "
|
|
||||||
"probe/infra failure, NOT an Invariant R violation"
|
|
||||||
) from error
|
|
||||||
sleeper(PI_PROBE_BACKOFF_SECONDS * attempt)
|
|
||||||
|
|
||||||
raise AssertionError("unreachable Pi registry retry state")
|
|
||||||
|
|
||||||
|
|
||||||
def probe_pi_registry() -> list[dict[str, object]]:
|
|
||||||
"""Boot Pi's real registry and return the final winning tool definitions."""
|
|
||||||
|
|
||||||
pi = shutil.which("pi")
|
|
||||||
if pi is None:
|
|
||||||
raise AssertionError("installed Pi runtime is required for Invariant R")
|
|
||||||
|
|
||||||
version = subprocess.run(
|
|
||||||
[pi, "--version"],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if version.returncode != 0:
|
|
||||||
raise AssertionError(f"Pi version probe failed: {version.stderr.strip()}")
|
|
||||||
if version.stdout.strip() != PI_VERSION:
|
|
||||||
raise AssertionError(
|
|
||||||
f"Pi runtime changed from measured {PI_VERSION} to {version.stdout.strip()!r}; "
|
|
||||||
"remeasure its registry before updating Invariant R"
|
|
||||||
)
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
output = root / "registry.json"
|
|
||||||
observer = root / "registry-observer.ts"
|
|
||||||
observer.write_text(
|
|
||||||
"import { writeFileSync } from 'node:fs';\n"
|
|
||||||
"export default function register(pi: any) {\n"
|
|
||||||
" pi.on('session_start', () => {\n"
|
|
||||||
f" writeFileSync({json.dumps(str(output))}, JSON.stringify(pi.getAllTools()));\n"
|
|
||||||
" process.exit(0);\n"
|
|
||||||
" });\n"
|
|
||||||
"}\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
command = [
|
|
||||||
pi,
|
|
||||||
"--mode",
|
|
||||||
"text",
|
|
||||||
"--no-session",
|
|
||||||
"--no-approve",
|
|
||||||
"--no-context-files",
|
|
||||||
"--no-skills",
|
|
||||||
"--no-prompt-templates",
|
|
||||||
"--no-extensions",
|
|
||||||
"-e",
|
|
||||||
str(observer),
|
|
||||||
"-e",
|
|
||||||
str(PI_EXTENSION),
|
|
||||||
]
|
|
||||||
extra_extension = os.environ.get(PI_EXTRA_EXTENSION_ENV)
|
|
||||||
if extra_extension:
|
|
||||||
command.extend(("-e", extra_extension))
|
|
||||||
command.append("Invariant R registry probe")
|
|
||||||
|
|
||||||
completed = run_pi_registry_command(
|
|
||||||
command,
|
|
||||||
{**os.environ, "PI_OFFLINE": "1"},
|
|
||||||
)
|
|
||||||
if completed.returncode != 0 or not output.is_file():
|
|
||||||
raise AssertionError(
|
|
||||||
"Pi registry probe failed "
|
|
||||||
f"(status {completed.returncode}): {completed.stderr.strip()}"
|
|
||||||
)
|
|
||||||
value = json.loads(output.read_text(encoding="utf-8"))
|
|
||||||
if not isinstance(value, list) or not value:
|
|
||||||
raise AssertionError("Pi registry probe returned no tools; control failed")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class InvariantRTest(unittest.TestCase):
|
|
||||||
def test_live_carve_out_has_only_supported_runtimes(self) -> None:
|
|
||||||
self.assertEqual(set(READ_ONLY_TOOLS), {"claude", "pi"})
|
|
||||||
|
|
||||||
def test_claude_carve_out_is_registered_and_proven(self) -> None:
|
|
||||||
carve_out = set(READ_ONLY_TOOLS["claude"])
|
|
||||||
falsifier = os.environ.get(CLAUDE_EXTRA_TOOL_ENV)
|
|
||||||
if falsifier:
|
|
||||||
carve_out.add(falsifier)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
carve_out,
|
|
||||||
set(CLAUDE_PROVEN_READ_ONLY_TOOLS),
|
|
||||||
"every Claude carve-out must exist and be in the exact proven read-only allow-list",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_pi_carve_out_has_no_exec_or_write_capability(self) -> None:
|
|
||||||
carve_out = set(READ_ONLY_TOOLS["pi"])
|
|
||||||
|
|
||||||
capability_unsafe = carve_out - set(PI_CAPABILITY_SAFE_TOOLS)
|
|
||||||
self.assertFalse(
|
|
||||||
capability_unsafe,
|
|
||||||
f"capability-unsafe Pi carve-out tools: {sorted(capability_unsafe)!r}; "
|
|
||||||
"Pi 0.84.1 grep.js:99/148 and find.js:161/203 reach "
|
|
||||||
"ensureTool(..., true) and spawn(), whose cold-cache path downloads, "
|
|
||||||
"writes, chmods, and execs",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_pi_carve_out_resolves_to_real_unshadowed_builtins(self) -> None:
|
|
||||||
carve_out = set(READ_ONLY_TOOLS["pi"])
|
|
||||||
self.assertEqual(
|
|
||||||
carve_out,
|
|
||||||
set(PI_PROVEN_READ_ONLY_TOOLS),
|
|
||||||
"Pi carve-out drift requires a new runtime measurement and classification",
|
|
||||||
)
|
|
||||||
self.assertTrue(carve_out.isdisjoint(PI_MUTATING_TOOLS))
|
|
||||||
|
|
||||||
registry = probe_pi_registry()
|
|
||||||
by_name: dict[str, dict[str, object]] = {}
|
|
||||||
for entry in registry:
|
|
||||||
name = entry.get("name")
|
|
||||||
if not isinstance(name, str):
|
|
||||||
self.fail(f"Pi registry entry has no string name: {entry!r}")
|
|
||||||
by_name[name] = entry
|
|
||||||
|
|
||||||
builtin_names = {
|
|
||||||
name
|
|
||||||
for name, entry in by_name.items()
|
|
||||||
if isinstance(entry.get("sourceInfo"), dict)
|
|
||||||
and entry["sourceInfo"].get("source") == "builtin"
|
|
||||||
}
|
|
||||||
self.assertEqual(
|
|
||||||
builtin_names,
|
|
||||||
set(PI_MEASURED_BUILTINS),
|
|
||||||
"Pi's real built-in registry drifted from the positive-control W-B measurement",
|
|
||||||
)
|
|
||||||
|
|
||||||
for name in sorted(carve_out):
|
|
||||||
with self.subTest(tool=name):
|
|
||||||
self.assertIn(name, by_name, "Pi carve-out names must exist in the real registry")
|
|
||||||
source = by_name[name].get("sourceInfo")
|
|
||||||
self.assertIsInstance(source, dict)
|
|
||||||
if isinstance(source, dict):
|
|
||||||
self.assertEqual(
|
|
||||||
source.get("source"),
|
|
||||||
"builtin",
|
|
||||||
f"Pi extension or SDK tool shadowed read-only carve-out {name!r}",
|
|
||||||
)
|
|
||||||
self.assertEqual(source.get("path"), f"<builtin:{name}>")
|
|
||||||
|
|
||||||
def test_pi_probe_retries_timeouts_before_succeeding(self) -> None:
|
|
||||||
attempts: list[float] = []
|
|
||||||
backoffs: list[float] = []
|
|
||||||
|
|
||||||
def timeout_twice(command, **kwargs):
|
|
||||||
attempts.append(kwargs["timeout"])
|
|
||||||
if len(attempts) < 3:
|
|
||||||
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
|
|
||||||
return subprocess.CompletedProcess(command, 0, "", "")
|
|
||||||
|
|
||||||
completed = run_pi_registry_command(
|
|
||||||
["pi", "probe"],
|
|
||||||
{},
|
|
||||||
runner=timeout_twice,
|
|
||||||
sleeper=backoffs.append,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(completed.returncode, 0)
|
|
||||||
self.assertEqual(attempts, [45, 45, 45])
|
|
||||||
self.assertEqual(backoffs, [0.25, 0.5])
|
|
||||||
|
|
||||||
def test_pi_probe_labels_exhausted_timeouts_as_infrastructure_failure(self) -> None:
|
|
||||||
attempts = 0
|
|
||||||
|
|
||||||
def always_timeout(command, **kwargs):
|
|
||||||
nonlocal attempts
|
|
||||||
attempts += 1
|
|
||||||
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
|
|
||||||
|
|
||||||
with self.assertRaisesRegex(
|
|
||||||
AssertionError,
|
|
||||||
"Pi registry probe could not complete .* NOT an Invariant R violation",
|
|
||||||
) as caught:
|
|
||||||
run_pi_registry_command(
|
|
||||||
["pi", "probe"],
|
|
||||||
{},
|
|
||||||
runner=always_timeout,
|
|
||||||
sleeper=lambda _delay: None,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(attempts, 3)
|
|
||||||
self.assertIsInstance(caught.exception.__cause__, subprocess.TimeoutExpired)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""The promotion client must never build a binding narrower than it claims.
|
|
||||||
|
|
||||||
RED-first against a real defect: ``build_construction`` skipped any normative
|
|
||||||
source it could not read (``except OSError: continue``) and promoted whatever
|
|
||||||
remained. That is not a degraded binding, it is a forged smaller one — the
|
|
||||||
broker recomputes ``h_source`` / ``h_payload`` from the fragments it is *sent*
|
|
||||||
(``daemon.py:602-616``), so an omitted fragment is internally consistent and
|
|
||||||
``PAYLOAD_BINDING_MISMATCH`` cannot fire. Measured before the fix: with only
|
|
||||||
``USER.md`` readable (964 bytes on the live host), the client produced a
|
|
||||||
one-fragment construction with ``promotion=True``.
|
|
||||||
|
|
||||||
The classification under test mirrors the framework's own file ownership, and
|
|
||||||
must keep mirroring it:
|
|
||||||
|
|
||||||
* framework-owned, reconciled every upgrade (``install.sh`` FRAMEWORK_OWNED /
|
|
||||||
``config/file-adapter.ts`` FRAMEWORK_OWNED_FILES) plus the per-runtime
|
|
||||||
contract — absence is a broken deployment, so it is REFUSED;
|
|
||||||
* ``SOUL.md`` / ``USER.md`` — install.sh deliberately does not seed them
|
|
||||||
("generated by `mosaic init`"), so absence is legitimate and ALLOWED.
|
|
||||||
|
|
||||||
Unreadable is treated separately from absent for *every* source, optional ones
|
|
||||||
included: a file that will not open is not a file that was never configured, and
|
|
||||||
collapsing the two is what let a permission change quietly shrink the law.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
|
||||||
sys.path.insert(0, str(TOOLS))
|
|
||||||
|
|
||||||
import lease_promote # noqa: E402
|
|
||||||
|
|
||||||
RUNTIME = "pi"
|
|
||||||
RUNTIME_CONTRACT = f"runtime/{RUNTIME}/RUNTIME.md"
|
|
||||||
ALL_SOURCES = (*lease_promote.FRAGMENT_SOURCES, RUNTIME_CONTRACT)
|
|
||||||
REQUIRED = frozenset(lease_promote.REQUIRED_SOURCES) | {RUNTIME_CONTRACT}
|
|
||||||
# Derived, never listed: a hand-kept second copy is exactly the drift this file
|
|
||||||
# exists to catch.
|
|
||||||
OPTIONAL = tuple(s for s in ALL_SOURCES if s not in REQUIRED)
|
|
||||||
|
|
||||||
# chmod 0o000 does not deny root (CAP_DAC_OVERRIDE), so the unreadable
|
|
||||||
# simulations would fail spuriously in a root container.
|
|
||||||
runs_unprivileged = unittest.skipIf(
|
|
||||||
os.geteuid() == 0, "chmod 0o000 cannot make a file unreadable to root"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionBindingTest(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self._previous_home = os.environ.get("MOSAIC_HOME")
|
|
||||||
self._temporary = tempfile.TemporaryDirectory()
|
|
||||||
self.root = Path(self._temporary.name)
|
|
||||||
for source_id in ALL_SOURCES:
|
|
||||||
path = self.root / source_id
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_bytes(f"# {source_id}\nnormative bytes\n".encode())
|
|
||||||
os.environ["MOSAIC_HOME"] = str(self.root)
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
for path in self.root.rglob("*"):
|
|
||||||
if path.is_file():
|
|
||||||
path.chmod(0o644)
|
|
||||||
self._temporary.cleanup()
|
|
||||||
if self._previous_home is None:
|
|
||||||
os.environ.pop("MOSAIC_HOME", None)
|
|
||||||
else:
|
|
||||||
os.environ["MOSAIC_HOME"] = self._previous_home
|
|
||||||
|
|
||||||
def reset_home(self) -> None:
|
|
||||||
"""Discard the current home and seed a fresh complete one.
|
|
||||||
|
|
||||||
Each subTest mutates the tree destructively, so it needs a clean start —
|
|
||||||
and the old one must be released, not orphaned.
|
|
||||||
"""
|
|
||||||
self.tearDown()
|
|
||||||
self.setUp()
|
|
||||||
|
|
||||||
def build(self):
|
|
||||||
return lease_promote.build_construction(RUNTIME)
|
|
||||||
|
|
||||||
def source_ids(self) -> list[str]:
|
|
||||||
construction, _ = self.build()
|
|
||||||
return [f["source_id"] for f in construction["fragments"]]
|
|
||||||
|
|
||||||
# --- the binding is complete when the deployment is complete -------------
|
|
||||||
|
|
||||||
def test_complete_deployment_binds_every_source(self) -> None:
|
|
||||||
construction, result = self.build()
|
|
||||||
self.assertEqual([f["source_id"] for f in construction["fragments"]], list(ALL_SOURCES))
|
|
||||||
self.assertTrue(result.promotion)
|
|
||||||
|
|
||||||
# --- absence: refused for framework-owned, allowed for operator-owned ----
|
|
||||||
|
|
||||||
def test_absent_required_source_is_refused(self) -> None:
|
|
||||||
for source_id in sorted(REQUIRED):
|
|
||||||
with self.subTest(source=source_id):
|
|
||||||
self.reset_home()
|
|
||||||
(self.root / source_id).unlink()
|
|
||||||
with self.assertRaises(lease_promote.IncompleteBinding) as caught:
|
|
||||||
self.build()
|
|
||||||
self.assertIn(source_id, str(caught.exception))
|
|
||||||
|
|
||||||
def test_absent_operator_source_still_binds_the_rest(self) -> None:
|
|
||||||
for source_id in OPTIONAL:
|
|
||||||
with self.subTest(source=source_id):
|
|
||||||
self.reset_home()
|
|
||||||
(self.root / source_id).unlink()
|
|
||||||
notice = io.StringIO()
|
|
||||||
with contextlib.redirect_stderr(notice):
|
|
||||||
bound = self.source_ids()
|
|
||||||
self.assertNotIn(source_id, bound)
|
|
||||||
for required in lease_promote.REQUIRED_SOURCES:
|
|
||||||
self.assertIn(required, bound)
|
|
||||||
# A silent omission is the original defect in miniature: the
|
|
||||||
# narrower binding must announce itself.
|
|
||||||
self.assertIn(source_id, notice.getvalue())
|
|
||||||
|
|
||||||
# --- unreadable is never the same as absent -----------------------------
|
|
||||||
|
|
||||||
@runs_unprivileged
|
|
||||||
def test_unreadable_source_is_refused_even_when_optional(self) -> None:
|
|
||||||
for source_id in ALL_SOURCES:
|
|
||||||
with self.subTest(source=source_id):
|
|
||||||
self.reset_home()
|
|
||||||
(self.root / source_id).chmod(0o000)
|
|
||||||
with self.assertRaises(lease_promote.IncompleteBinding) as caught:
|
|
||||||
self.build()
|
|
||||||
self.assertIn(source_id, str(caught.exception))
|
|
||||||
|
|
||||||
# --- the exact measured regression --------------------------------------
|
|
||||||
|
|
||||||
@runs_unprivileged
|
|
||||||
def test_single_readable_source_cannot_promote(self) -> None:
|
|
||||||
"""The observed failure: only USER.md readable produced a valid binding."""
|
|
||||||
for source_id in ALL_SOURCES:
|
|
||||||
if source_id != "USER.md":
|
|
||||||
(self.root / source_id).chmod(0o000)
|
|
||||||
with self.assertRaises(lease_promote.IncompleteBinding):
|
|
||||||
self.build()
|
|
||||||
|
|
||||||
@runs_unprivileged
|
|
||||||
def test_no_source_readable_cannot_promote(self) -> None:
|
|
||||||
for source_id in ALL_SOURCES:
|
|
||||||
(self.root / source_id).chmod(0o000)
|
|
||||||
with self.assertRaises(lease_promote.IncompleteBinding):
|
|
||||||
self.build()
|
|
||||||
|
|
||||||
# --- the classification must not drift from the framework's -------------
|
|
||||||
|
|
||||||
def test_required_set_excludes_only_the_unseeded_sources(self) -> None:
|
|
||||||
"""`install.sh` decides which files exist; this list must follow it.
|
|
||||||
|
|
||||||
If a source moves between framework-owned and operator-generated
|
|
||||||
upstream, this fails and forces the classification to be re-read rather
|
|
||||||
than silently inherited.
|
|
||||||
"""
|
|
||||||
self.assertEqual(
|
|
||||||
set(lease_promote.REQUIRED_SOURCES),
|
|
||||||
{"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"},
|
|
||||||
"REQUIRED_SOURCES changed — re-read install.sh FRAMEWORK_OWNED and "
|
|
||||||
"config/file-adapter.ts FRAMEWORK_OWNED_FILES before accepting it",
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
set(lease_promote.REQUIRED_SOURCES) <= set(lease_promote.FRAGMENT_SOURCES),
|
|
||||||
"a required source is not in the binding order",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,519 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""RED-first contracts for the operator-triggered Claude promotion hooks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
|
|
||||||
PACKAGE_ROOT = Path(__file__).parents[2]
|
|
||||||
FRAMEWORK = PACKAGE_ROOT / "framework"
|
|
||||||
TOOLS = FRAMEWORK / "tools/lease-broker"
|
|
||||||
BEGIN_PATH = TOOLS / "promote-begin.py"
|
|
||||||
COMPLETE_PATH = TOOLS / "promote-complete.py"
|
|
||||||
OBSERVER_CLIENT_PATH = TOOLS / "receipt-observer-client.py"
|
|
||||||
RECEIPT_CHALLENGE_PATH = TOOLS / "receipt_challenge.py"
|
|
||||||
CLAUDE_SETTINGS = FRAMEWORK / "runtime/claude/settings.json"
|
|
||||||
CLAUDE_COMMAND = FRAMEWORK / "runtime/claude/commands/mosaic-promote.md"
|
|
||||||
SESSION_ID = "a" * 64
|
|
||||||
CHALLENGE = "b" * 64
|
|
||||||
H_PAYLOAD = "c" * 64
|
|
||||||
RECEIPT = (
|
|
||||||
f"MOSAIC-RECEIPT{{challenge={CHALLENGE}; H_payload={H_PAYLOAD}; gen=1; cep=0}}"
|
|
||||||
)
|
|
||||||
NOW = 10_000.0
|
|
||||||
|
|
||||||
|
|
||||||
def load_module(name: str, path: Path):
|
|
||||||
if not path.is_file():
|
|
||||||
raise AssertionError(f"shipped module is missing: {path}")
|
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
|
||||||
if spec is None or spec.loader is None:
|
|
||||||
raise RuntimeError(f"unable to load {name}")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionHookFixture(unittest.TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls) -> None:
|
|
||||||
cls.begin = load_module("promotion_begin_test", BEGIN_PATH)
|
|
||||||
cls.complete = load_module("promotion_complete_test", COMPLETE_PATH)
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.temporary = tempfile.TemporaryDirectory()
|
|
||||||
self.runtime_dir = Path(self.temporary.name)
|
|
||||||
self.environment = {
|
|
||||||
"XDG_RUNTIME_DIR": str(self.runtime_dir),
|
|
||||||
"MOSAIC_LEASE_SESSION_ID": SESSION_ID,
|
|
||||||
}
|
|
||||||
self.pending_dir = self.runtime_dir / "mosaic-lease"
|
|
||||||
self.pending_file = self.pending_dir / f"pending-{SESSION_ID}"
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
self.temporary.cleanup()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def completed(payload: dict[str, object], returncode: int = 0, stderr: str = ""):
|
|
||||||
return subprocess.CompletedProcess(
|
|
||||||
["lease_promote.py"],
|
|
||||||
returncode,
|
|
||||||
json.dumps(payload),
|
|
||||||
stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def successful_begin_reply(cls, **extra: object) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"state": "PENDING_VERIFICATION",
|
|
||||||
"receipt_challenge": CHALLENGE,
|
|
||||||
"receipt": RECEIPT,
|
|
||||||
"binding": {
|
|
||||||
"compaction_epoch": 0,
|
|
||||||
"request_epoch": 0,
|
|
||||||
"h_source": "d" * 64,
|
|
||||||
"h_payload": H_PAYLOAD,
|
|
||||||
"runtime_generation": 1,
|
|
||||||
"schema_version": 1,
|
|
||||||
},
|
|
||||||
**extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
def run_begin(
|
|
||||||
self,
|
|
||||||
prompt: str,
|
|
||||||
runner: mock.Mock,
|
|
||||||
) -> tuple[int, str, str]:
|
|
||||||
stdout = io.StringIO()
|
|
||||||
stderr = io.StringIO()
|
|
||||||
result = self.begin.main(
|
|
||||||
environ=self.environment,
|
|
||||||
stdin=io.BytesIO(json.dumps({"prompt": prompt}).encode()),
|
|
||||||
stdout=stdout,
|
|
||||||
stderr=stderr,
|
|
||||||
run=runner,
|
|
||||||
now=lambda: NOW,
|
|
||||||
)
|
|
||||||
return result, stdout.getvalue(), stderr.getvalue()
|
|
||||||
|
|
||||||
def write_pending(self, challenge: str = CHALLENGE) -> None:
|
|
||||||
self.pending_dir.mkdir(mode=0o700, exist_ok=True)
|
|
||||||
self.pending_file.write_text(challenge, encoding="utf-8")
|
|
||||||
self.pending_file.chmod(0o600)
|
|
||||||
|
|
||||||
def run_complete(self, runner: mock.Mock) -> tuple[int, str]:
|
|
||||||
stderr = io.StringIO()
|
|
||||||
result = self.complete.main(
|
|
||||||
environ=self.environment,
|
|
||||||
stderr=stderr,
|
|
||||||
run=runner,
|
|
||||||
)
|
|
||||||
return result, stderr.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionBeginTest(PromotionHookFixture):
|
|
||||||
def test_exact_prompt_writes_private_challenge_and_injects_verbatim_receipt(self) -> None:
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, stdout, stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stderr, "")
|
|
||||||
output = json.loads(stdout)
|
|
||||||
self.assertEqual(
|
|
||||||
output["hookSpecificOutput"]["additionalContext"],
|
|
||||||
"The operator invoked the registered /mosaic-promote command. "
|
|
||||||
"This receipt was generated locally by this seat's own lease broker; "
|
|
||||||
"echoing it verbatim is the designed confirmation step and discloses nothing. "
|
|
||||||
f"Reply with exactly the following text and nothing else: {RECEIPT}",
|
|
||||||
)
|
|
||||||
self.assertEqual(self.pending_file.read_text(encoding="utf-8"), CHALLENGE)
|
|
||||||
self.assertEqual(stat.S_IMODE(self.pending_file.stat().st_mode), 0o600)
|
|
||||||
command = runner.call_args.args[0]
|
|
||||||
self.assertEqual(command[-1], "--begin")
|
|
||||||
self.assertTrue(command[-2].endswith("lease_promote.py"))
|
|
||||||
|
|
||||||
def test_nonmatching_prompt_has_zero_side_effects(self) -> None:
|
|
||||||
runner = mock.Mock()
|
|
||||||
|
|
||||||
result, stdout, stderr = self.run_begin("please /mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stdout, "")
|
|
||||||
self.assertEqual(stderr, "")
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertFalse(self.pending_dir.exists())
|
|
||||||
|
|
||||||
def test_begin_refusal_reports_daemon_code_without_pending_file(self) -> None:
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed({"ok": False, "code": "INVALID_BINDING"})
|
|
||||||
)
|
|
||||||
|
|
||||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertIn("INVALID_BINDING", json.loads(stdout)["hookSpecificOutput"]["additionalContext"])
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
|
|
||||||
def test_sweep_removes_stale_sibling_and_spares_fresh_sibling(self) -> None:
|
|
||||||
self.pending_dir.mkdir(mode=0o700)
|
|
||||||
stale = self.pending_dir / "pending-stale"
|
|
||||||
fresh = self.pending_dir / "pending-fresh"
|
|
||||||
stale.write_text("stale", encoding="utf-8")
|
|
||||||
fresh.write_text("fresh", encoding="utf-8")
|
|
||||||
os.utime(stale, (NOW - 3_601, NOW - 3_601))
|
|
||||||
os.utime(fresh, (NOW - 3_599, NOW - 3_599))
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(stale.exists())
|
|
||||||
self.assertTrue(fresh.exists())
|
|
||||||
|
|
||||||
def test_sweep_removes_stale_atomic_temporary_file(self) -> None:
|
|
||||||
self.pending_dir.mkdir(mode=0o700)
|
|
||||||
stale_temporary = self.pending_dir / f".pending-{SESSION_ID}.tmp-abandoned"
|
|
||||||
stale_temporary.write_text("partial", encoding="utf-8")
|
|
||||||
os.utime(stale_temporary, (NOW - 3_601, NOW - 3_601))
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(stale_temporary.exists())
|
|
||||||
|
|
||||||
def test_insecure_pending_directory_mode_refuses_before_begin(self) -> None:
|
|
||||||
self.pending_dir.mkdir(mode=0o755)
|
|
||||||
self.pending_dir.chmod(0o755)
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
self.assertIn("PROMOTION_TRIGGER_FAILED", stdout)
|
|
||||||
|
|
||||||
def test_insecure_runtime_directory_mode_refuses_before_begin(self) -> None:
|
|
||||||
self.runtime_dir.chmod(0o755)
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertIn("PROMOTION_TRIGGER_FAILED", stdout)
|
|
||||||
|
|
||||||
def test_parent_symlink_cannot_redirect_pending_write(self) -> None:
|
|
||||||
outside = self.runtime_dir / "outside"
|
|
||||||
outside.mkdir(mode=0o700)
|
|
||||||
self.pending_dir.symlink_to(outside, target_is_directory=True)
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertFalse((outside / f"pending-{SESSION_ID}").exists())
|
|
||||||
|
|
||||||
def test_concurrent_begin_is_refused_without_minting_a_second_challenge(self) -> None:
|
|
||||||
inner_runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
inner_result: list[tuple[int, str, str]] = []
|
|
||||||
|
|
||||||
def overlap(*_args: object, **_kwargs: object):
|
|
||||||
inner_result.append(self.run_begin("/mosaic-promote", inner_runner))
|
|
||||||
return self.completed(self.successful_begin_reply())
|
|
||||||
|
|
||||||
outer_runner = mock.Mock(side_effect=overlap)
|
|
||||||
|
|
||||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", outer_runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
inner_runner.assert_not_called()
|
|
||||||
self.assertEqual(inner_result[0][0], 0)
|
|
||||||
self.assertIn("PROMOTION_ALREADY_IN_PROGRESS", inner_result[0][1])
|
|
||||||
|
|
||||||
def test_non_ascii_receipt_reply_is_rejected_without_crashing_hook(self) -> None:
|
|
||||||
reply = self.successful_begin_reply()
|
|
||||||
reply["receipt"] = "MOSAIC—RECEIPT"
|
|
||||||
runner = mock.Mock(return_value=self.completed(reply))
|
|
||||||
|
|
||||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
self.assertIn("INVALID_PROMOTER_REPLY", stdout)
|
|
||||||
|
|
||||||
def test_success_shaped_reply_with_extra_fields_is_rejected(self) -> None:
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(self.successful_begin_reply(unexpected=True))
|
|
||||||
)
|
|
||||||
|
|
||||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
self.assertIn("INVALID_PROMOTER_REPLY", stdout)
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionCompleteTest(PromotionHookFixture):
|
|
||||||
def test_no_pending_file_is_zero_cost_success(self) -> None:
|
|
||||||
runner = mock.Mock()
|
|
||||||
|
|
||||||
result, stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stderr, "")
|
|
||||||
runner.assert_not_called()
|
|
||||||
|
|
||||||
def test_success_deletes_pending_file(self) -> None:
|
|
||||||
self.write_pending()
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
self.assertEqual(runner.call_args.args[0][-2:], ["--complete", CHALLENGE])
|
|
||||||
|
|
||||||
def test_each_terminal_failure_deletes_pending_file(self) -> None:
|
|
||||||
terminal_codes = (
|
|
||||||
"RECEIPT_REPLAY",
|
|
||||||
"RECEIPT_MISMATCH",
|
|
||||||
"INVALID_LEASE_TRANSITION",
|
|
||||||
"PROMOTION_TOKEN_INVALID",
|
|
||||||
)
|
|
||||||
for code in terminal_codes:
|
|
||||||
with self.subTest(code=code):
|
|
||||||
self.write_pending()
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "observe_receipt", "ok": False, "code": code}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
self.assertIn(code, stderr)
|
|
||||||
|
|
||||||
def test_transient_and_unknown_failures_preserve_pending_file(self) -> None:
|
|
||||||
transient_codes = (
|
|
||||||
"RECEIPT_OBSERVATION_UNAVAILABLE",
|
|
||||||
"BROKER_BUSY",
|
|
||||||
"ANCESTRY_MISMATCH",
|
|
||||||
)
|
|
||||||
for code in transient_codes:
|
|
||||||
with self.subTest(code=code):
|
|
||||||
self.write_pending()
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "observe_receipt", "ok": False, "code": code}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertTrue(self.pending_file.exists())
|
|
||||||
self.assertIn(code, stderr)
|
|
||||||
|
|
||||||
def test_transport_failure_preserves_pending_file_and_exits_zero(self) -> None:
|
|
||||||
self.write_pending()
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed({}, returncode=2, stderr="ConnectionRefusedError")
|
|
||||||
)
|
|
||||||
|
|
||||||
result, stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertTrue(self.pending_file.exists())
|
|
||||||
self.assertIn("ConnectionRefusedError", stderr)
|
|
||||||
|
|
||||||
def test_insecure_runtime_directory_mode_preserves_pending(self) -> None:
|
|
||||||
self.write_pending(CHALLENGE)
|
|
||||||
self.runtime_dir.chmod(0o755)
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertTrue(self.pending_file.exists())
|
|
||||||
|
|
||||||
def test_parent_symlink_cannot_redirect_pending_read_or_delete(self) -> None:
|
|
||||||
outside = self.runtime_dir / "outside"
|
|
||||||
outside.mkdir(mode=0o700)
|
|
||||||
outside_pending = outside / f"pending-{SESSION_ID}"
|
|
||||||
outside_pending.write_text(CHALLENGE, encoding="utf-8")
|
|
||||||
outside_pending.chmod(0o600)
|
|
||||||
self.pending_dir.symlink_to(outside, target_is_directory=True)
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertTrue(outside_pending.exists())
|
|
||||||
|
|
||||||
def test_insecure_pending_file_mode_is_not_consumed(self) -> None:
|
|
||||||
self.write_pending(CHALLENGE)
|
|
||||||
self.pending_file.chmod(0o644)
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertTrue(self.pending_file.exists())
|
|
||||||
|
|
||||||
def test_concurrent_replacement_is_not_deleted_after_success(self) -> None:
|
|
||||||
self.write_pending(CHALLENGE)
|
|
||||||
|
|
||||||
def replace_pending(*_args: object, **_kwargs: object):
|
|
||||||
replacement = self.pending_dir / "replacement"
|
|
||||||
replacement.write_text("replacement", encoding="utf-8")
|
|
||||||
replacement.chmod(0o600)
|
|
||||||
os.replace(replacement, self.pending_file)
|
|
||||||
return self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
|
|
||||||
runner = mock.Mock(side_effect=replace_pending)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(self.pending_file.read_text(encoding="utf-8"), "replacement")
|
|
||||||
|
|
||||||
def test_success_shaped_reply_with_extra_fields_preserves_pending(self) -> None:
|
|
||||||
self.write_pending(CHALLENGE)
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{
|
|
||||||
"stage": "promote_lease",
|
|
||||||
"ok": True,
|
|
||||||
"state": "VERIFIED",
|
|
||||||
"unexpected": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertTrue(self.pending_file.exists())
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionTemplateWiringTest(unittest.TestCase):
|
|
||||||
def test_gated_claude_template_wires_begin_and_ordered_stop_chain(self) -> None:
|
|
||||||
settings = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8"))
|
|
||||||
hooks = settings["hooks"]
|
|
||||||
submit_commands = [
|
|
||||||
hook["command"]
|
|
||||||
for group in hooks["UserPromptSubmit"]
|
|
||||||
for hook in group["hooks"]
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
submit_commands,
|
|
||||||
["python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py"],
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
[group.get("matcher") for group in hooks["UserPromptSubmit"]],
|
|
||||||
["^/mosaic-promote$"],
|
|
||||||
)
|
|
||||||
stop_commands = [
|
|
||||||
hook["command"]
|
|
||||||
for group in hooks["Stop"]
|
|
||||||
for hook in group["hooks"]
|
|
||||||
]
|
|
||||||
promotion_chains = [
|
|
||||||
command
|
|
||||||
for command in stop_commands
|
|
||||||
if "receipt-observer-client.py" in command and "promote-complete.py" in command
|
|
||||||
]
|
|
||||||
self.assertEqual(len(promotion_chains), 1)
|
|
||||||
chain = promotion_chains[0]
|
|
||||||
self.assertLess(
|
|
||||||
chain.index("receipt-observer-client.py"),
|
|
||||||
chain.index("promote-complete.py"),
|
|
||||||
)
|
|
||||||
self.assertIn("observer_status=$?", chain)
|
|
||||||
self.assertTrue(chain.endswith("exit $observer_status"))
|
|
||||||
|
|
||||||
def test_registered_command_is_one_line_and_defers_to_injected_instruction(self) -> None:
|
|
||||||
body = CLAUDE_COMMAND.read_text(encoding="utf-8")
|
|
||||||
self.assertEqual(
|
|
||||||
body,
|
|
||||||
"I invoked this registered command to authorize lease promotion; follow the local seat broker's injected receipt confirmation instruction exactly.\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionVerbatimToleranceTest(unittest.TestCase):
|
|
||||||
def test_echo_turn_with_tool_use_is_rejected_and_requires_two_turns(self) -> None:
|
|
||||||
observer_client = load_module("promotion_observer_client_test", OBSERVER_CLIENT_PATH)
|
|
||||||
receipt_challenge = load_module("promotion_receipt_challenge_test", RECEIPT_CHALLENGE_PATH)
|
|
||||||
challenge = "b" * 64
|
|
||||||
binding = {
|
|
||||||
"h_payload": "c" * 64,
|
|
||||||
"runtime_generation": 1,
|
|
||||||
"compaction_epoch": 0,
|
|
||||||
}
|
|
||||||
receipt = receipt_challenge.receipt_for(challenge, binding)
|
|
||||||
real_claude_entry = {
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{"type": "text", "text": receipt},
|
|
||||||
{
|
|
||||||
"type": "tool_use",
|
|
||||||
"id": "tool-1",
|
|
||||||
"name": "mcp__discord__reply",
|
|
||||||
"input": {"message": "promoted"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extracted = observer_client.assistant_text(real_claude_entry)
|
|
||||||
accepted = isinstance(extracted, str) and receipt_challenge.is_verbatim_receipt(
|
|
||||||
extracted,
|
|
||||||
challenge,
|
|
||||||
binding,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIsNone(extracted)
|
|
||||||
self.assertFalse(accepted)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -207,24 +207,6 @@ class ReceiptObserverTest(BrokerFixture):
|
|||||||
with self.assertRaisesRegex(DAEMON.BrokerFailure, "INVALID_LEASE_TRANSITION"):
|
with self.assertRaisesRegex(DAEMON.BrokerFailure, "INVALID_LEASE_TRANSITION"):
|
||||||
self.promote(current["receipt_challenge"])
|
self.promote(current["receipt_challenge"])
|
||||||
|
|
||||||
def test_non_ascii_observation_is_mismatch_and_broker_keeps_serving(self) -> None:
|
|
||||||
construction, binding = self.construction()
|
|
||||||
cycle = self.begin(binding, construction)
|
|
||||||
self.record("I refuse—this is not the receipt")
|
|
||||||
|
|
||||||
with self.assertRaisesRegex(DAEMON.BrokerFailure, "RECEIPT_MISMATCH"):
|
|
||||||
self.observe(cycle["receipt_challenge"])
|
|
||||||
|
|
||||||
denied = self.broker.handle(self.peer, {
|
|
||||||
"action": "authorize_tool",
|
|
||||||
"session_id": self.session_id,
|
|
||||||
"runtime_generation": 7,
|
|
||||||
"runtime": "pi",
|
|
||||||
"tool_name": "bash",
|
|
||||||
})
|
|
||||||
self.assertEqual(denied["decision"], "deny")
|
|
||||||
self.assertEqual(denied["state"], DAEMON.LEASE_UNVERIFIED)
|
|
||||||
|
|
||||||
def test_t29_altered_model_hash_cannot_promote_against_shipped_binding(self) -> None:
|
def test_t29_altered_model_hash_cannot_promote_against_shipped_binding(self) -> None:
|
||||||
construction, binding = self.construction()
|
construction, binding = self.construction()
|
||||||
cycle = self.begin(binding, construction)
|
cycle = self.begin(binding, construction)
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Exit-semantics tests for the receipt observer Stop-hook client."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from contextlib import redirect_stderr
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
|
|
||||||
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
|
||||||
CLIENT_PATH = TOOLS / "receipt-observer-client.py"
|
|
||||||
|
|
||||||
|
|
||||||
def load_client():
|
|
||||||
spec = importlib.util.spec_from_file_location("receipt_observer_client_test", CLIENT_PATH)
|
|
||||||
if spec is None or spec.loader is None:
|
|
||||||
raise RuntimeError("unable to load receipt-observer-client.py")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
CLIENT = load_client()
|
|
||||||
VALID_INPUT = json.dumps({"latest_assistant_message": "ordinary turn"}).encode()
|
|
||||||
DEEPLY_NESTED_JSON = b"[" * 2_000 + b"0" + b"]" * 2_000
|
|
||||||
ENVIRONMENT = {
|
|
||||||
"MOSAIC_RECEIPT_OBSERVER_SOCKET": "/unused/observer.sock",
|
|
||||||
"MOSAIC_LEASE_SESSION_ID": "a" * 64,
|
|
||||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FakeObserverSocket:
|
|
||||||
def __init__(self, response: bytes) -> None:
|
|
||||||
self.response = response
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *_args: object) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def settimeout(self, _timeout: float) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def connect(self, _path: str) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def sendall(self, _payload: bytes) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def shutdown(self, _how: int) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def recv(self, _size: int) -> bytes:
|
|
||||||
response, self.response = self.response, b""
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
class ReceiptObserverClientExitSemanticsTest(unittest.TestCase):
|
|
||||||
def run_client(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
input_bytes: bytes = VALID_INPUT,
|
|
||||||
reply: dict[str, object] | None = None,
|
|
||||||
transport_error: OSError | None = None,
|
|
||||||
runtime: str = "pi",
|
|
||||||
) -> tuple[int, str, mock.Mock]:
|
|
||||||
request = mock.Mock(return_value=reply)
|
|
||||||
if transport_error is not None:
|
|
||||||
request.side_effect = transport_error
|
|
||||||
stderr = io.StringIO()
|
|
||||||
with (
|
|
||||||
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(input_bytes)),
|
|
||||||
mock.patch.object(CLIENT, "observer_request", request),
|
|
||||||
redirect_stderr(stderr),
|
|
||||||
):
|
|
||||||
arguments = ["--runtime", runtime]
|
|
||||||
if runtime == "claude":
|
|
||||||
arguments.append("--latest-entry")
|
|
||||||
result = CLIENT.main(arguments, environ=ENVIRONMENT)
|
|
||||||
return result, stderr.getvalue(), request
|
|
||||||
|
|
||||||
def test_claude_prefers_inline_last_assistant_message(self) -> None:
|
|
||||||
inline = "the just-finished assistant message"
|
|
||||||
input_bytes = json.dumps({
|
|
||||||
"last_assistant_message": inline,
|
|
||||||
"transcript_path": "/must/not/be/opened.jsonl",
|
|
||||||
}).encode()
|
|
||||||
|
|
||||||
result, stderr, request = self.run_client(
|
|
||||||
input_bytes=input_bytes,
|
|
||||||
reply={"ok": True},
|
|
||||||
runtime="claude",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stderr, "")
|
|
||||||
self.assertEqual(
|
|
||||||
request.call_args.args[1]["latest_assistant_message"],
|
|
||||||
inline,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_claude_falls_back_to_transcript_when_inline_field_is_absent(self) -> None:
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
transcript = Path(directory) / "transcript.jsonl"
|
|
||||||
transcript.write_text(
|
|
||||||
json.dumps({
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "text", "text": "fallback message"}],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
+ "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
input_bytes = json.dumps({"transcript_path": str(transcript)}).encode()
|
|
||||||
|
|
||||||
result, stderr, request = self.run_client(
|
|
||||||
input_bytes=input_bytes,
|
|
||||||
reply={"ok": True},
|
|
||||||
runtime="claude",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stderr, "")
|
|
||||||
self.assertEqual(
|
|
||||||
request.call_args.args[1]["latest_assistant_message"],
|
|
||||||
"fallback message",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_claude_present_invalid_inline_field_fails_without_fallback(self) -> None:
|
|
||||||
fallback = mock.Mock(return_value="must not be used")
|
|
||||||
input_bytes = json.dumps({
|
|
||||||
"last_assistant_message": None,
|
|
||||||
"transcript_path": "/unused/transcript.jsonl",
|
|
||||||
}).encode()
|
|
||||||
|
|
||||||
with mock.patch.object(CLIENT, "claude_latest_entry", fallback):
|
|
||||||
result, stderr, request = self.run_client(
|
|
||||||
input_bytes=input_bytes,
|
|
||||||
reply={"ok": True},
|
|
||||||
runtime="claude",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, 2)
|
|
||||||
self.assertIn("Mosaic receipt observer refused", stderr)
|
|
||||||
request.assert_not_called()
|
|
||||||
fallback.assert_not_called()
|
|
||||||
|
|
||||||
def test_claude_inline_message_size_guard_stays_enforced(self) -> None:
|
|
||||||
request = mock.Mock(return_value={"ok": True})
|
|
||||||
stderr = io.StringIO()
|
|
||||||
with (
|
|
||||||
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(b"{}")),
|
|
||||||
mock.patch.object(
|
|
||||||
CLIENT,
|
|
||||||
"read_json",
|
|
||||||
return_value={"last_assistant_message": "x" * (CLIENT.MAX_FRAME + 1)},
|
|
||||||
),
|
|
||||||
mock.patch.object(CLIENT, "observer_request", request),
|
|
||||||
redirect_stderr(stderr),
|
|
||||||
):
|
|
||||||
result = CLIENT.main(
|
|
||||||
["--runtime", "claude", "--latest-entry"],
|
|
||||||
environ=ENVIRONMENT,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, 2)
|
|
||||||
self.assertIn("Mosaic receipt observer refused", stderr.getvalue())
|
|
||||||
request.assert_not_called()
|
|
||||||
|
|
||||||
def test_pi_still_posts_only_its_runtime_message(self) -> None:
|
|
||||||
result, stderr, request = self.run_client(reply={"ok": True})
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stderr, "")
|
|
||||||
self.assertEqual(
|
|
||||||
request.call_args.args[1]["latest_assistant_message"],
|
|
||||||
"ordinary turn",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_nothing_pending_observation_refusal_is_benign(self) -> None:
|
|
||||||
result, stderr, request = self.run_client(
|
|
||||||
reply={"ok": False, "code": "OBSERVATION_UNAVAILABLE"}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stderr, "")
|
|
||||||
request.assert_called_once()
|
|
||||||
|
|
||||||
def test_success_reply_remains_successful(self) -> None:
|
|
||||||
result, stderr, _request = self.run_client(reply={"ok": True})
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stderr, "")
|
|
||||||
|
|
||||||
def test_pending_cycle_auth_failure_stays_fail_closed(self) -> None:
|
|
||||||
result, _stderr, _request = self.run_client(
|
|
||||||
reply={"ok": False, "code": "ANCESTRY_MISMATCH"}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, 2)
|
|
||||||
|
|
||||||
def test_transport_failure_stays_fail_closed(self) -> None:
|
|
||||||
result, stderr, _request = self.run_client(
|
|
||||||
transport_error=ConnectionRefusedError("observer unavailable")
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, 2)
|
|
||||||
self.assertIn("Mosaic receipt observer refused", stderr)
|
|
||||||
|
|
||||||
def test_parse_failure_stays_fail_closed(self) -> None:
|
|
||||||
result, stderr, request = self.run_client(input_bytes=b"{")
|
|
||||||
|
|
||||||
self.assertEqual(result, 2)
|
|
||||||
self.assertIn("Mosaic receipt observer refused", stderr)
|
|
||||||
request.assert_not_called()
|
|
||||||
|
|
||||||
def test_malformed_wire_replies_stay_fail_closed(self) -> None:
|
|
||||||
for response in (
|
|
||||||
b"not-json\n",
|
|
||||||
b'{"ok":true}',
|
|
||||||
b"{}\n{}\n",
|
|
||||||
b'{"ok":false,"code":"OBSERVATION_UNAVAILABLE"}\n\n',
|
|
||||||
b'{"ok":true,"ok":false,"code":"OBSERVATION_UNAVAILABLE"}\n',
|
|
||||||
DEEPLY_NESTED_JSON + b"\n",
|
|
||||||
b"x" * (CLIENT.MAX_FRAME + 1),
|
|
||||||
):
|
|
||||||
with self.subTest(response=response):
|
|
||||||
stderr = io.StringIO()
|
|
||||||
with (
|
|
||||||
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(VALID_INPUT)),
|
|
||||||
mock.patch.object(
|
|
||||||
CLIENT.socket,
|
|
||||||
"socket",
|
|
||||||
return_value=FakeObserverSocket(response),
|
|
||||||
),
|
|
||||||
redirect_stderr(stderr),
|
|
||||||
):
|
|
||||||
result = CLIENT.main(["--runtime", "pi"], environ=ENVIRONMENT)
|
|
||||||
|
|
||||||
self.assertEqual(result, 2)
|
|
||||||
self.assertIn("Mosaic receipt observer refused", stderr.getvalue())
|
|
||||||
|
|
||||||
def test_oversized_input_stays_fail_closed(self) -> None:
|
|
||||||
result, stderr, request = self.run_client(input_bytes=b"x" * (CLIENT.MAX_FRAME + 1))
|
|
||||||
|
|
||||||
self.assertEqual(result, 2)
|
|
||||||
self.assertIn("Mosaic receipt observer refused", stderr)
|
|
||||||
request.assert_not_called()
|
|
||||||
|
|
||||||
def test_deeply_nested_input_stays_fail_closed(self) -> None:
|
|
||||||
result, stderr, request = self.run_client(input_bytes=DEEPLY_NESTED_JSON)
|
|
||||||
|
|
||||||
self.assertEqual(result, 2)
|
|
||||||
self.assertIn("Mosaic receipt observer refused", stderr)
|
|
||||||
request.assert_not_called()
|
|
||||||
|
|
||||||
def test_json_recursion_failure_stays_fail_closed(self) -> None:
|
|
||||||
request = mock.Mock()
|
|
||||||
stderr = io.StringIO()
|
|
||||||
with (
|
|
||||||
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(VALID_INPUT)),
|
|
||||||
mock.patch.object(CLIENT, "observer_request", request),
|
|
||||||
mock.patch.object(
|
|
||||||
CLIENT.json,
|
|
||||||
"loads",
|
|
||||||
side_effect=RecursionError("maximum JSON nesting exceeded"),
|
|
||||||
),
|
|
||||||
redirect_stderr(stderr),
|
|
||||||
):
|
|
||||||
result = CLIENT.main(["--runtime", "pi"], environ=ENVIRONMENT)
|
|
||||||
|
|
||||||
self.assertEqual(result, 2)
|
|
||||||
self.assertIn("Mosaic receipt observer refused", stderr.getvalue())
|
|
||||||
request.assert_not_called()
|
|
||||||
|
|
||||||
def test_observation_unavailable_with_unexpected_fields_stays_fail_closed(self) -> None:
|
|
||||||
result, _stderr, _request = self.run_client(
|
|
||||||
reply={"ok": False, "code": "OBSERVATION_UNAVAILABLE", "unexpected": True}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, 2)
|
|
||||||
|
|
||||||
def test_non_boolean_ok_values_stay_fail_closed(self) -> None:
|
|
||||||
for reply in (
|
|
||||||
{"ok": 1},
|
|
||||||
{"ok": 0, "code": "OBSERVATION_UNAVAILABLE"},
|
|
||||||
):
|
|
||||||
with self.subTest(reply=reply):
|
|
||||||
result, _stderr, _request = self.run_client(reply=reply)
|
|
||||||
self.assertEqual(result, 2)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Reference in New Issue
Block a user