Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
029af418f0 | ||
|
|
51746f44eb | ||
|
|
1bfd0ddd71 | ||
|
|
a3cacac7fb | ||
|
|
b4578dcd0a | ||
|
|
b1254f52f3 | ||
|
|
2a2a87251a | ||
|
|
e6a881a795 | ||
|
|
7962e4302f | ||
|
|
8a901cc19a | ||
|
|
8b7ac5b51e | ||
|
|
96609bdade | ||
|
|
e3a0ee87b3 | ||
|
|
ec260e678f | ||
|
|
b590a5c3d8 | ||
|
|
540ec5b6ef | ||
|
|
563d1ac053 | ||
|
|
722163671f | ||
|
|
f158be8003 | ||
|
|
b0f7d26dd9 | ||
|
|
2fa6bcd576 | ||
|
|
1afe2b36dc |
@@ -46,10 +46,21 @@ 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
|
||||||
|
# Tool-index gate: a shipped wrapper that appears in no resident index doc
|
||||||
|
# is undiscoverable from inside a session, and an agent that cannot learn a
|
||||||
|
# wrapper exists reaches for raw curl instead — which is how a Gitea review
|
||||||
|
# got filed PENDING three times. Ships-and-documented is one commit, or red.
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh
|
||||||
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
|
# 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
|
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||||
# joins CI directly rather than the exclusions file.
|
# joins CI directly rather than the exclusions file.
|
||||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||||
|
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||||
|
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||||
|
# endpoints and ordinary commands through. Both directions are asserted —
|
||||||
|
# a guard that over-blocks gets routed around, which fails just as hard.
|
||||||
|
- bash packages/mosaic/framework/tools/git/test-wrapper-guard.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
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
|
|||||||
|
|
||||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
||||||
|
|
||||||
|
Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi,
|
||||||
|
`/goal set <statement>` starts a bounded persistent loop that checks every turn and successful
|
||||||
|
compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with
|
||||||
|
`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state
|
||||||
|
entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and
|
||||||
|
evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension
|
||||||
|
into `~/.pi/agent/extensions/`.
|
||||||
|
|
||||||
### TUI & Gateway
|
### TUI & Gateway
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+122
@@ -102,6 +102,128 @@ Context compaction, session replacement, and same-PID runtime reloads can leave
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Pi Persistent Goal Loop (#1150)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is
|
||||||
|
not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an
|
||||||
|
optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native
|
||||||
|
lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is
|
||||||
|
reached.
|
||||||
|
|
||||||
|
The objective is a Mosaic-owned Pi extension deployed from the framework into
|
||||||
|
`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
#### In scope
|
||||||
|
|
||||||
|
1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under
|
||||||
|
`packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make
|
||||||
|
`mosaic pi` load it alongside the core Mosaic extension when present.
|
||||||
|
2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help
|
||||||
|
operations without silently replacing an active goal.
|
||||||
|
3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries,
|
||||||
|
restored on session start and tree navigation, and never rely on a compaction summary as its
|
||||||
|
source of truth.
|
||||||
|
4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every
|
||||||
|
model request so it remains effective across tool turns, retries, and post-compaction requests.
|
||||||
|
5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event.
|
||||||
|
A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`,
|
||||||
|
or `blocked` status without requiring a redundant model turn.
|
||||||
|
6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive
|
||||||
|
evidence-bearing verification report. Any continuation report or successful compaction during
|
||||||
|
verification SHALL reset the verification sequence.
|
||||||
|
7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily
|
||||||
|
`agent_settled`; manual compaction and restored active sessions may schedule a deferred idle
|
||||||
|
continuation without re-entering compaction handlers.
|
||||||
|
8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress
|
||||||
|
limits. Exhausted or blocked goals pause rather than continuing indefinitely.
|
||||||
|
9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the
|
||||||
|
goal extension is framework-owned under `runtime/**`, while no goal extension or configuration
|
||||||
|
asset is created or modified under the operator's main Pi configuration. Pi remains the owner of
|
||||||
|
its native session files used by `appendEntry()`.
|
||||||
|
|
||||||
|
#### Out of scope
|
||||||
|
|
||||||
|
1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete.
|
||||||
|
2. Automatically executing user-supplied shell predicates or accepting executable validation code in
|
||||||
|
`/goal` arguments.
|
||||||
|
3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime
|
||||||
|
supervisor owns process durability.
|
||||||
|
4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice.
|
||||||
|
|
||||||
|
### User and stakeholder requirements
|
||||||
|
|
||||||
|
- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report.
|
||||||
|
- The agent remains oriented after each turn and compaction until verified, paused, blocked,
|
||||||
|
exhausted, or cancelled.
|
||||||
|
- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an
|
||||||
|
extension asset to `~/.pi/agent/extensions/`.
|
||||||
|
- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest
|
||||||
|
sync path.
|
||||||
|
|
||||||
|
### Non-functional requirements
|
||||||
|
|
||||||
|
1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no
|
||||||
|
completion without non-empty reported evidence.
|
||||||
|
2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe
|
||||||
|
context injection, and stale-timer cancellation on session shutdown.
|
||||||
|
3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the
|
||||||
|
active agent's final terminating tool call.
|
||||||
|
4. **Observability:** Pi status/notifications expose phase and bounded counters without recording
|
||||||
|
credentials or hidden model reasoning.
|
||||||
|
5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi
|
||||||
|
provider/network access.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. `AC-PGL-01`: A framework-sync fixture installs the extension at
|
||||||
|
`$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are
|
||||||
|
emitted in deterministic order while absent optional files remain backward-compatible.
|
||||||
|
2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement
|
||||||
|
refusal, and bounded input handling.
|
||||||
|
3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every
|
||||||
|
request, two evidence-bearing achievement reports are required, and `agent_settled` continues an
|
||||||
|
unmet goal without duplicate scheduling.
|
||||||
|
4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and
|
||||||
|
rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session
|
||||||
|
branch state is reconstructed correctly.
|
||||||
|
5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous
|
||||||
|
continuation, while pause/cancel/blocked states do not restart.
|
||||||
|
6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load
|
||||||
|
smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before
|
||||||
|
issue #1150 closes.
|
||||||
|
|
||||||
|
### Constraints, risks, and assumptions
|
||||||
|
|
||||||
|
- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`,
|
||||||
|
`context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating
|
||||||
|
tool results.
|
||||||
|
- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory
|
||||||
|
second verification pass, explicit semantic limitations, and operator-visible reports.
|
||||||
|
- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds
|
||||||
|
and paused terminal states.
|
||||||
|
- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from
|
||||||
|
`agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown.
|
||||||
|
- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy;
|
||||||
|
rationale: it provides a real recheck without doubling every turn's model cost. Future policy may
|
||||||
|
add independent or deterministic validators.
|
||||||
|
- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by
|
||||||
|
bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget.
|
||||||
|
- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication
|
||||||
|
is requested.
|
||||||
|
|
||||||
|
### Testing and delivery intent
|
||||||
|
|
||||||
|
Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle
|
||||||
|
objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests,
|
||||||
|
launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one
|
||||||
|
reviewed squash PR to `main` with terminal-green CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
||||||
|
|
||||||
### Problem and objective
|
### Problem and objective
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||||
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
|
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
|
||||||
|
|
||||||
|
## Pi persistent goals
|
||||||
|
|
||||||
|
- [Persistent goal user guide](guides/user-guide.md#pi-persistent-goals) — `/goal` commands, verification behavior, limits, compaction/resume semantics, and limitations.
|
||||||
|
- [Goal extension developer guide](guides/dev-guide.md#pi-persistent-goal-extension) — framework ownership, launcher ordering, lifecycle design, tests, and local Mosaic-path smoke workflow.
|
||||||
|
- [Goal loop operations](guides/admin-guide.md#pi-goal-loop-operations) — deployment ownership, bounded settings, pause/resume procedures, and supervisor boundary.
|
||||||
|
- [Pi runtime reference](../packages/mosaic/framework/runtime/pi/RUNTIME.md#extensions) — deployed paths, command summary, and bounded environment settings.
|
||||||
|
|
||||||
## Fleet configuration management
|
## Fleet configuration management
|
||||||
|
|
||||||
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
3. [Provider Configuration](#provider-configuration)
|
3. [Provider Configuration](#provider-configuration)
|
||||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||||
5. [Environment Variables Reference](#environment-variables-reference)
|
5. [Environment Variables Reference](#environment-variables-reference)
|
||||||
6. [Local Fleet Canary](./fleet-local-canary.md)
|
6. [Pi Goal Loop Operations](#pi-goal-loop-operations)
|
||||||
|
7. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -264,6 +265,16 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
|||||||
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
||||||
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
||||||
|
|
||||||
|
### Mosaic Pi goal loop
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ----------------------------- | ------- | -------------------------------------------------------------------- |
|
||||||
|
| `MOSAIC_GOAL_MAX_TURNS` | `40` | Per-goal autonomous turn limit; accepted range `1..500` |
|
||||||
|
| `MOSAIC_GOAL_MAX_NO_PROGRESS` | `6` | Consecutive identical progress-report limit; accepted range `1..100` |
|
||||||
|
|
||||||
|
These variables are consumed by the framework-owned Pi goal extension at goal creation. Invalid or
|
||||||
|
out-of-range values fall back to the defaults; they do not disable the bounds.
|
||||||
|
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -374,3 +385,29 @@ Session cleanup is scoped to one session identifier and only removes that sessio
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
||||||
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pi Goal Loop Operations
|
||||||
|
|
||||||
|
The reviewed runtime asset is deployed at
|
||||||
|
`~/.config/mosaic/runtime/pi/goal-extension.ts` by framework install/update. Do not install another
|
||||||
|
copy under `~/.pi/agent/extensions/`; duplicate registration can create suffixed commands and two
|
||||||
|
competing lifecycle controllers.
|
||||||
|
|
||||||
|
Operational checks:
|
||||||
|
|
||||||
|
1. Run `mosaic pi` and verify `/goal help` is available.
|
||||||
|
2. Use `/goal status` to inspect phase, turn/no-progress limits, compaction checks, and evidence.
|
||||||
|
Reports persist in Pi session data; controller-owned state redacts common credential shapes, but
|
||||||
|
Pi's model/tool-call history is separate. Operators must not place secrets or raw sensitive output
|
||||||
|
in goals, pause reasons, or evidence.
|
||||||
|
3. Use `/goal pause <reason>` before planned maintenance or manual investigation. Pause and cancel
|
||||||
|
abort the current goal-driven run when Pi is busy.
|
||||||
|
4. Use `/goal resume` only after addressing a blocker; counters restart with the configured bounds.
|
||||||
|
5. Use `/goal cancel` before replacing an unfinished goal.
|
||||||
|
|
||||||
|
A blocked or exhausted goal remains stopped and visible; Mosaic does not automatically raise its
|
||||||
|
limits or restart the process. Framework sync owns file deployment, while Pi's native session file
|
||||||
|
owns branch replay. Process/host restart remains the responsibility of the existing runtime or fleet
|
||||||
|
supervisor.
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||||
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
||||||
8. [API Endpoint Reference](#api-endpoint-reference)
|
8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension)
|
||||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
9. [API Endpoint Reference](#api-endpoint-reference)
|
||||||
|
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -385,6 +386,85 @@ M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the
|
|||||||
canonical root directly. Codex still relies on the existing full skill-sync
|
canonical root directly. Codex still relies on the existing full skill-sync
|
||||||
linker and needs separate parity analysis before this lifecycle API is extended.
|
linker and needs separate parity analysis before this lifecycle API is extended.
|
||||||
|
|
||||||
|
## Pi Persistent Goal Extension
|
||||||
|
|
||||||
|
The source of the Mosaic-owned Pi goal controller is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/mosaic/framework/runtime/pi/goal-extension.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The framework manifest classifies `runtime/**` as framework-owned. Both the bash installer and the
|
||||||
|
TypeScript file adapter therefore deploy the same reviewed source to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$MOSAIC_HOME/runtime/pi/goal-extension.ts
|
||||||
|
# default: ~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not copy or link this extension into `~/.pi/agent/extensions/`. The launcher function
|
||||||
|
`discoverPiExtensionArgs()` emits the core `mosaic-extension.ts` first and the optional
|
||||||
|
`goal-extension.ts` second, preserving compatibility with an older installed framework that does
|
||||||
|
not have the goal file yet.
|
||||||
|
|
||||||
|
### Lifecycle design
|
||||||
|
|
||||||
|
| Pi API | Goal-controller responsibility |
|
||||||
|
| ------------------------------ | --------------------------------------------------------------------------------- |
|
||||||
|
| `registerCommand('goal')` | Set, inspect, pause, resume, or cancel one branch-specific goal |
|
||||||
|
| `registerTool(...)` | Record a terminating structured progress report with evidence |
|
||||||
|
| `context` | Inject the active goal contract before every provider request |
|
||||||
|
| `turn_end` | Record every turn, reject mixed final reports, and enforce the turn bound |
|
||||||
|
| `agent_settled` | Start one deduplicated continuation only after Pi has no retry/compact/queue work |
|
||||||
|
| `session_compact` | Record the compact check, reset provisional verification, and defer idle work |
|
||||||
|
| `session_start`/`session_tree` | Rebuild state from custom entries on the active branch |
|
||||||
|
| `session_shutdown` | Invalidate deferred callbacks and clear UI state |
|
||||||
|
|
||||||
|
State is appended as `mosaic-goal-state` custom entries, which do not enter model context. The
|
||||||
|
`context` hook creates a fresh hidden `mosaic-goal-context` message for each request instead of
|
||||||
|
trusting compaction summaries. The `mosaic_goal_report` result uses `terminate: true`; when it is the
|
||||||
|
sole final tool call, Pi avoids an unnecessary model response before the controller decides whether
|
||||||
|
to verify, continue, or stop.
|
||||||
|
|
||||||
|
Before state is appended or displayed, the controller applies bounded credential-pattern redaction
|
||||||
|
to the goal statement, report summary/evidence/next step, and stop reason. Fingerprints are computed
|
||||||
|
over redacted report content. Pi session entries are append-only, so a credential-bearing legacy
|
||||||
|
entry cannot honestly be erased by the extension: restoration fails closed, emits a warning, and
|
||||||
|
requires removal of the affected session before setting a new goal. This is defense-in-depth rather
|
||||||
|
than a secret-storage contract, and it does not rewrite Pi's separate model-message/tool-call
|
||||||
|
history. Goal prompts tell the agent not to submit credentials or raw sensitive output, and tests use
|
||||||
|
canaries to prove known forms do not reach new custom entries, status text, context, or tool details
|
||||||
|
while ordinary typed fields such as `token: string` remain intact.
|
||||||
|
|
||||||
|
Completion remains evidence-gated but semantic: two consecutive `achieved` reports are required,
|
||||||
|
and the second run is explicitly a verification pass. This avoids an extra judge-model request after
|
||||||
|
every turn. Deterministic validator commands are intentionally not accepted as `/goal` input in this
|
||||||
|
slice, so never describe this mechanism as proof of arbitrary natural-language completion.
|
||||||
|
|
||||||
|
### Tests and local smoke workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaicstack/mosaic exec vitest run \
|
||||||
|
src/runtime/pi-goal-extension.spec.ts \
|
||||||
|
src/commands/launch.spec.ts \
|
||||||
|
src/config/file-adapter.test.ts
|
||||||
|
|
||||||
|
bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For an additive local smoke test without reseeding unrelated live framework files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -D -m 0644 \
|
||||||
|
packages/mosaic/framework/runtime/pi/goal-extension.ts \
|
||||||
|
~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||||
|
|
||||||
|
pi --extension ~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `/goal help`, `/goal set ...`, and `/goal status` in that test session. A released framework
|
||||||
|
sync installs the file, and a released Mosaic CLI loads it automatically through `mosaic pi`.
|
||||||
|
|
||||||
## API Endpoint Reference
|
## API Endpoint Reference
|
||||||
|
|
||||||
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
4. [Tasks](#tasks)
|
4. [Tasks](#tasks)
|
||||||
5. [Settings](#settings)
|
5. [Settings](#settings)
|
||||||
6. [CLI Usage](#cli-usage)
|
6. [CLI Usage](#cli-usage)
|
||||||
7. [Sub-package Commands](#sub-package-commands)
|
7. [Pi Persistent Goals](#pi-persistent-goals)
|
||||||
8. [Telemetry](#telemetry)
|
8. [Sub-package Commands](#sub-package-commands)
|
||||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
9. [Telemetry](#telemetry)
|
||||||
|
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -307,6 +308,57 @@ mosaic prdy
|
|||||||
mosaic quality-rails
|
mosaic quality-rails
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Pi Persistent Goals
|
||||||
|
|
||||||
|
`mosaic pi` loads a Mosaic-owned goal extension from
|
||||||
|
`~/.config/mosaic/runtime/pi/goal-extension.ts`. It is deliberately not installed in
|
||||||
|
`~/.pi/agent/extensions/`; framework installation and updates manage it with the rest of the Mosaic
|
||||||
|
runtime assets.
|
||||||
|
|
||||||
|
Start Pi, then set a goal:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/goal set Deliver the feature, tests, documentation, and verification evidence
|
||||||
|
# Shorthand:
|
||||||
|
/goal Deliver the feature, tests, documentation, and verification evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
Control and inspect the loop with:
|
||||||
|
|
||||||
|
| Command | Behavior |
|
||||||
|
| ---------------------- | ------------------------------------------------------------------ |
|
||||||
|
| `/goal status` | Show phase, limits, compaction checks, latest report, and evidence |
|
||||||
|
| `/goal pause [reason]` | Stop autonomous continuation while preserving the goal |
|
||||||
|
| `/goal resume` | Resume with fresh turn and no-progress counters |
|
||||||
|
| `/goal cancel` | Cancel the goal and remove its active status |
|
||||||
|
| `/goal help` | Show command help |
|
||||||
|
|
||||||
|
While a goal is active, Mosaic injects its contract before every Pi model request and checks every
|
||||||
|
completed model/tool turn. The agent ends each work cycle with the structured
|
||||||
|
`mosaic_goal_report` tool. `achieved` is provisional until a second consecutive report rechecks the
|
||||||
|
whole goal with evidence. A continuation report or a successful compaction resets provisional
|
||||||
|
verification.
|
||||||
|
|
||||||
|
Goal statements and reports are stored in Pi session data. Mosaic redacts common credential shapes
|
||||||
|
before appending its goal-state entries and before goal tool output or `/goal status`, but
|
||||||
|
pattern-based redaction is not a secret store. Pi's own model-message and tool-call records are
|
||||||
|
outside that redactor. Never put tokens, passwords, private keys, connection strings, or raw
|
||||||
|
sensitive output in a goal or report; cite the command, artifact, and pass/fail result instead.
|
||||||
|
|
||||||
|
The loop stops instead of running forever when it is paused, blocked, cancelled, verified, reaches
|
||||||
|
its turn limit, or repeats the same no-progress report too many times. Defaults are 40 turns and 6
|
||||||
|
repeated no-progress reports. Operators may lower or raise them within enforced bounds before
|
||||||
|
launching Pi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MOSAIC_GOAL_MAX_TURNS=60 MOSAIC_GOAL_MAX_NO_PROGRESS=8 mosaic pi
|
||||||
|
```
|
||||||
|
|
||||||
|
Goal state is branch-specific Pi session data. It survives compaction and session resume, but Pi's
|
||||||
|
process still must be relaunched or supervised after a process/host failure. This initial verifier
|
||||||
|
checks structured evidence twice; it cannot mathematically prove every arbitrary natural-language
|
||||||
|
goal. Use explicit acceptance criteria and inspect `/goal status` for consequential work.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Claude Code Skill Registration
|
### Claude Code Skill Registration
|
||||||
|
|||||||
@@ -69,4 +69,15 @@ All 22 baseline sites below are `FIXED`; the checked-in tranche fixture is passe
|
|||||||
| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions |
|
| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions |
|
||||||
| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string |
|
| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string |
|
||||||
|
|
||||||
Remaining wake-validation sites are intentionally deferred to the final review-sized tranche and are not yet assigned a safety verdict here.
|
## Tranche 3 — wake validation harnesses
|
||||||
|
|
||||||
|
All 26 baseline occurrences (25 normalized identities; one preimage selector occurs twice) are `FIXED` and mechanically bound through the wake fixture and shared scanner.
|
||||||
|
|
||||||
|
| Baseline site(s) | Verdict | Construction |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `wake/test-wake-digest-quarantine.sh:567` | FIXED | complete match populations are captured, then first line selected by parameter expansion |
|
||||||
|
| `wake/test-wake-preimage.sh:182-183,346-347` | FIXED | jq `first(...)` reads each JSONL file directly |
|
||||||
|
| `wake/validate-973/microtest-wake-assert.sh:153,170-171,176,204-209,233-234,251-252,286-287` | FIXED | scalar assertions use here-strings; diagnostics use non-early sed ranges; source line captured before matching |
|
||||||
|
| `wake/validate-973/validate-973.sh:110,119,180,182,187` | FIXED | scalar assertions use here-strings; diagnostic truncation uses consuming sed ranges |
|
||||||
|
|
||||||
|
The scoped inventory is complete: 26 runtime/general + 22 non-wake tests + 26 wake tests fixed; 11 explicitly withdrawn or non-load-bearing sites retain their documented verdicts.
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ Site-by-site verdicts: `docs/reports/quality/1099-pipefail-sweep.md`.
|
|||||||
|
|
||||||
Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2.
|
Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2.
|
||||||
|
|
||||||
|
## Tranche 3 TDD
|
||||||
|
|
||||||
|
Expanded the shared scanner over four wake validation harnesses. RED named 26 occurrences. The wake fixture asserts 26 occurrences / 25 normalized identities through the same scanner; all scalar assertions now use redirection, direct jq selection, complete capture, or consuming diagnostic ranges. Current-tree finding count is zero across the full scoped population.
|
||||||
|
|
||||||
## Verification so far
|
## Verification so far
|
||||||
|
|
||||||
- `bash -n` on every changed shell script: pass.
|
- `bash -n` on every changed shell script: pass.
|
||||||
@@ -33,5 +37,5 @@ Expanded the unconditional scanner over 11 non-wake test harnesses. RED named ex
|
|||||||
- `test-send-message-verdict.sh`: 3/3 pass.
|
- `test-send-message-verdict.sh`: 3/3 pass.
|
||||||
- `test-send-message-socket.sh`: pass.
|
- `test-send-message-socket.sh`: pass.
|
||||||
- Independent review 143 found two semantic regressions: a help-probe `|| true` changed the failure truth table, and an unguarded Git capture changed non-Git data-dir behavior from rc 0 + JSON to silent rc 128. Both received RED-first regressions before correction; help status is now separate and required, and Git status remains condition-guarded.
|
- Independent review 143 found two semantic regressions: a help-probe `|| true` changed the failure truth table, and an unguarded Git capture changed non-Git data-dir behavior from rc 0 + JSON to silent rc 128. Both received RED-first regressions before correction; help status is now separate and required, and Git status remains condition-guarded.
|
||||||
- Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
|
- Wake static inventory remains aligned at 261/261 after line-neutral rewrites; no static-set mismatch. Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
|
||||||
- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced.
|
- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced.
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# #1150 — Pi persistent goal extension
|
||||||
|
|
||||||
|
- **Task ID:** ISSUE-1150 (no `docs/TASKS.md` row; that file is orchestrator-only)
|
||||||
|
- **Issue:** #1150 — `pi: add persistent /goal controller extension to Mosaic framework`
|
||||||
|
- **Branch:** `feat/1150-pi-goal-extension`
|
||||||
|
- **Mode:** Delivery
|
||||||
|
- **Status:** in progress
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Build and locally validate a Mosaic-owned Pi `/goal` extension. Source must ship from
|
||||||
|
`packages/mosaic/framework/runtime/pi/`, framework sync must deploy it under
|
||||||
|
`~/.config/mosaic/runtime/pi/`, and no extension/configuration asset may be written into `~/.pi`.
|
||||||
|
Pi's native session manager remains the owner of session entries.
|
||||||
|
|
||||||
|
## Scope and acceptance source
|
||||||
|
|
||||||
|
- Canonical requirements: `docs/PRD.md`, section **Pi Persistent Goal Loop (#1150)**.
|
||||||
|
- User intent: continuous goal orientation and status checking after each Pi turn and compaction,
|
||||||
|
tested locally before framework delivery.
|
||||||
|
- Documentation target: canonical in-repo user/developer/runtime docs; no external publication.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- `ASSUMPTION:` Initial semantic verification uses two consecutive structured, evidence-bearing
|
||||||
|
reports from the working agent rather than a second model request after every turn. This keeps the
|
||||||
|
loop testable and avoids doubling model cost while making the limitation explicit.
|
||||||
|
- `ASSUMPTION:` Default autonomous bounds are 40 turns and 6 repeated no-progress reports, with only
|
||||||
|
bounded numeric environment overrides.
|
||||||
|
- `ASSUMPTION:` A local smoke copy to `~/.config/mosaic/runtime/pi/goal-extension.ts` is authorized by
|
||||||
|
the user's explicit request. Full framework reseed into the live home is not required for the smoke
|
||||||
|
test and would touch unrelated framework-owned files.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- Working estimate: 30K implementation/review tokens.
|
||||||
|
- Hard user cap: none stated.
|
||||||
|
- Cost control: deterministic fake-Pi tests; no nested evaluator calls; only bounded arithmetic/load
|
||||||
|
smoke workflows against the installed runtime.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Update PRD and create tracking/scratchpad artifacts.
|
||||||
|
2. Read launcher, installer ownership, Pi extension, and documentation surfaces.
|
||||||
|
3. TDD: add fake-Pi behavior tests for commands, state restoration, turn checks, compaction, limits,
|
||||||
|
verification, and continuation deduplication.
|
||||||
|
4. Implement `runtime/pi/goal-extension.ts` and deterministic launcher discovery.
|
||||||
|
5. Add framework-sync/deployment acceptance coverage.
|
||||||
|
6. Update user, developer, runtime, framework README, and sitemap documentation.
|
||||||
|
7. Run focused tests, local Mosaic-path smoke test, then baseline repository gates.
|
||||||
|
8. Run independent review, remediate, commit, push/PR/CI/merge/issue closure per delivery gates.
|
||||||
|
|
||||||
|
## TDD decision
|
||||||
|
|
||||||
|
Applied. The continuation state machine and lifecycle scheduling are control-path logic where a race
|
||||||
|
or false terminal state can cause unbounded work or premature completion.
|
||||||
|
|
||||||
|
## Progress checkpoints
|
||||||
|
|
||||||
|
- [x] Issue #1150 created through Mosaic wrapper.
|
||||||
|
- [x] Isolated worktree created from `origin/main`.
|
||||||
|
- [x] PRD requirements and acceptance criteria added.
|
||||||
|
- [x] Task scratchpad created.
|
||||||
|
- [x] RED controller and security-regression tests written and observed failing before implementation.
|
||||||
|
- [x] Goal controller, launcher discovery, framework deployment coverage, and bounded state machine
|
||||||
|
implemented.
|
||||||
|
- [x] User, admin, developer, runtime, adapter, README, and sitemap documentation updated.
|
||||||
|
- [x] Final source copied additively to `~/.config/mosaic/runtime/pi/goal-extension.ts`; source and
|
||||||
|
deployed SHA-256 are identical.
|
||||||
|
- [x] Live Pi RPC smoke from the exact Mosaic path reached `achieved` with two verification passes and
|
||||||
|
no extension errors.
|
||||||
|
- [x] Baseline and situational checks completed, except the explicitly documented unavailable
|
||||||
|
PostgreSQL-only root integration case.
|
||||||
|
- [x] Independent code and OWASP/security reviews completed; all findings remediated and re-reviewed.
|
||||||
|
- [ ] Commit, push, PR, terminal-green CI, squash merge, and issue closure complete.
|
||||||
|
|
||||||
|
## Tests and evidence
|
||||||
|
|
||||||
|
### Situational
|
||||||
|
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/runtime/pi-goal-extension.spec.ts`
|
||||||
|
- final: 25 passed.
|
||||||
|
- Covers commands, per-turn checks, context injection, two-pass verification, mixed-report
|
||||||
|
rejection, bounded limits, compaction, branch restore, stale timers, credential redaction,
|
||||||
|
typed-field false-positive protection, and append-only legacy-state fail-closed behavior.
|
||||||
|
- Final focused launcher/controller/file-adapter run: 3 files / 67 tests passed.
|
||||||
|
- Final V8 coverage for `framework/runtime/pi/goal-extension.ts`:
|
||||||
|
- 99.17% statements/lines, 93.78% branches, 100% functions.
|
||||||
|
- Installer migration fixture: 24 passed and byte-compared the deployed framework asset.
|
||||||
|
- Standalone extension TypeScript check against installed Pi 0.84.1 types passed:
|
||||||
|
`pnpm --filter @mosaicstack/mosaic exec tsc --noEmit --pretty false --module NodeNext
|
||||||
|
--moduleResolution NodeNext --target ES2022 --skipLibCheck framework/runtime/pi/goal-extension.ts`.
|
||||||
|
- Live deployment/load evidence:
|
||||||
|
- source/deployed SHA-256:
|
||||||
|
`1f0a3806e0948ad5f49684273a7e535e9880c148f7fd16d13ee487fcd601f637`.
|
||||||
|
- `get_commands` identified `/goal` as an extension command sourced from
|
||||||
|
`~/.config/mosaic/runtime/pi/goal-extension.ts`; `/goal help` succeeded; zero extension errors.
|
||||||
|
- live arithmetic goal ended `achieved`, verification `2/2`, with 3 goal reports / 3 agent starts
|
||||||
|
and zero extension errors.
|
||||||
|
- no goal extension exists under `~/.pi` extension paths.
|
||||||
|
|
||||||
|
### Baseline
|
||||||
|
|
||||||
|
- `pnpm build`: passed before the final framework-only redaction remediation; the extension is not a
|
||||||
|
package build input and its final source passed the standalone Pi type check.
|
||||||
|
- `pnpm typecheck`: 45/45 tasks passed.
|
||||||
|
- `pnpm lint`: 25/25 tasks passed.
|
||||||
|
- `pnpm format:check`: passed.
|
||||||
|
- Final Mosaic package components:
|
||||||
|
- Vitest: 82 files / 1,539 tests passed.
|
||||||
|
- full `test:framework-shell` harness passed.
|
||||||
|
- the discovered pre-existing tmux loader-marker race was reproduced with constructor PID
|
||||||
|
evidence, fixed with a pane readiness/FIFO barrier, passed 3 consecutive focused runs, and passed
|
||||||
|
in the full shell harness.
|
||||||
|
- one combined rerun encountered the separate existing real-lease probe TOCTOU in
|
||||||
|
`install-ordering-guard.spec.ts`; an earlier final Vitest run was fully green and the changed
|
||||||
|
focused suites remained green.
|
||||||
|
- Gateway safe baseline excluding the prohibited PostgreSQL-only fixture: 55 files / 600 tests passed
|
||||||
|
(6 files / 12 tests skipped by their existing environment gates).
|
||||||
|
- Root `pnpm test` reached 43 successful workspace tasks and all changed-package Vitest tests, but
|
||||||
|
the unchanged `apps/gateway/src/__tests__/cross-user-isolation.test.ts` afterAll hook retried a
|
||||||
|
PostgreSQL connection and failed authentication (`28P01`). This checkout explicitly forbids local
|
||||||
|
PostgreSQL startup/access; the failure is unrelated to #1150 and cannot be remediated by starting
|
||||||
|
the database. The gateway suite excluding that PostgreSQL-only file and required CI are used as
|
||||||
|
the safe verification paths.
|
||||||
|
|
||||||
|
### Independent review
|
||||||
|
|
||||||
|
- Codex code review: approved, 0 findings across 15 files.
|
||||||
|
- Initial Codex security review: one medium CWE-532/A09 finding for raw report persistence.
|
||||||
|
- Remediation added central credential-pattern redaction, prompt/docs guidance, canary tests, typed
|
||||||
|
field false-positive guards, and sticky fail-closed restore for credential-bearing append-only
|
||||||
|
history.
|
||||||
|
- Codex security re-review: risk `none`, 0 findings, confidence 0.87.
|
||||||
|
- Focused remediation review findings were fixed; final focused re-review verdict: `APPROVE`.
|
||||||
|
- Focused independent review of the tmux readiness barrier: `APPROVE`, no actionable findings.
|
||||||
|
|
||||||
|
## Risks and blockers
|
||||||
|
|
||||||
|
- Live `~/.config/mosaic` is shared by active Pi/fleet processes. Local deployment remained a single
|
||||||
|
additive framework file and did not reload or restart unrelated sessions.
|
||||||
|
- Completion verification is semantic, not mathematical: the active agent supplies structured
|
||||||
|
evidence twice. Operators must still inspect consequential outcomes.
|
||||||
|
- Credential redaction is pattern-based defense-in-depth, not a secret store. It covers
|
||||||
|
controller-owned state/status/tool details, not Pi's separate model-message/tool-call history.
|
||||||
|
Goals and reports must never contain real secrets or raw sensitive output. Because Pi session
|
||||||
|
entries are append-only, a detected credential-bearing legacy branch fails closed and the affected
|
||||||
|
session must be removed.
|
||||||
|
- Current installed Pi is newer than the repository's historical gateway Pi dependency. The
|
||||||
|
extension was checked and smoke-tested against installed Pi 0.84.1 using stable documented APIs.
|
||||||
|
- Local root testing cannot safely execute the unchanged PostgreSQL-only integration fixture under
|
||||||
|
the checkout's explicit database safety constraints. Terminal-green PR CI remains mandatory before
|
||||||
|
merge.
|
||||||
|
- The unchanged real-lease default-probe test can observe different broker availability across its two
|
||||||
|
sequential probes; one combined package rerun hit that existing TOCTOU. The same final Vitest suite
|
||||||
|
passed in a separate run, and CI remains the merge authority.
|
||||||
@@ -13,7 +13,8 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
|||||||
|
|
||||||
1. Injects the full runtime contract via `--append-system-prompt`
|
1. Injects the full runtime contract via `--append-system-prompt`
|
||||||
2. Loads Mosaic skills via `--skill` flags
|
2. Loads Mosaic skills via `--skill` flags
|
||||||
3. Loads the Mosaic extension via `--extension` for lifecycle hooks
|
3. Loads framework-owned `mosaic-extension.ts` and `goal-extension.ts` from
|
||||||
|
`~/.config/mosaic/runtime/pi/` via ordered `--extension` flags
|
||||||
4. Detects active missions and injects initial prompts
|
4. Detects active missions and injects initial prompts
|
||||||
|
|
||||||
## Capabilities vs Other Runtimes
|
## Capabilities vs Other Runtimes
|
||||||
@@ -22,6 +23,7 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
|||||||
- Native thinking levels replace sequential-thinking MCP
|
- Native thinking levels replace sequential-thinking MCP
|
||||||
- Native skill discovery compatible with Mosaic SKILL.md format
|
- Native skill discovery compatible with Mosaic SKILL.md format
|
||||||
- Native extension system for lifecycle hooks (TypeScript, not bash shims)
|
- Native extension system for lifecycle hooks (TypeScript, not bash shims)
|
||||||
|
- Bounded persistent `/goal` loop with per-turn, post-compaction, and two-pass evidence checks
|
||||||
- Native session persistence and resume
|
- Native session persistence and resume
|
||||||
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
|
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,14 @@ The launcher:
|
|||||||
1. Verifies `~/.config/mosaic` exists
|
1. Verifies `~/.config/mosaic` exists
|
||||||
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
||||||
3. Injects `AGENTS.md` into the runtime
|
3. Injects `AGENTS.md` into the runtime
|
||||||
4. Forwards all arguments to the runtime CLI
|
4. For Pi, loads the framework-owned core and persistent-goal extensions from
|
||||||
|
`~/.config/mosaic/runtime/pi/`
|
||||||
|
5. Forwards all arguments to the runtime CLI
|
||||||
|
|
||||||
|
Inside `mosaic pi`, `/goal set <statement>` starts a bounded persistent goal loop. Use `/goal status`,
|
||||||
|
`/goal pause`, `/goal resume`, or `/goal cancel` to control it. The extension remains part of Mosaic
|
||||||
|
under `~/.config/mosaic/runtime/pi/goal-extension.ts`; it is not installed in Pi's main extension
|
||||||
|
directory.
|
||||||
|
|
||||||
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
|
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
|
||||||
|
|
||||||
@@ -114,7 +121,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
|||||||
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
||||||
│ ├── codex/ ← instructions.md, RUNTIME.md
|
│ ├── codex/ ← instructions.md, RUNTIME.md
|
||||||
│ ├── opencode/ ← AGENTS.md, RUNTIME.md
|
│ ├── opencode/ ← AGENTS.md, RUNTIME.md
|
||||||
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts
|
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts, goal-extension.ts
|
||||||
│ └── mcp/ ← MCP server configs
|
│ └── mcp/ ← MCP server configs
|
||||||
├── skills/ ← Universal skills (synced from mosaic/agent-skills)
|
├── skills/ ← Universal skills (synced from mosaic/agent-skills)
|
||||||
├── skills-local/ ← Local cross-runtime skills
|
├── skills-local/ ← Local cross-runtime skills
|
||||||
@@ -126,7 +133,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
|||||||
|
|
||||||
| Launch method | Injection mechanism |
|
| Launch method | Injection mechanism |
|
||||||
| ------------------- | ----------------------------------------------------------------------------------------- |
|
| ------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + extension |
|
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + Mosaic extensions |
|
||||||
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
|
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
|
||||||
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
|
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
|
||||||
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
||||||
|
|||||||
@@ -52,6 +52,52 @@ If a repo does not expose these scripts, run equivalent local workflow commands
|
|||||||
- Do not auto-resolve data conflicts in shared state files.
|
- Do not auto-resolve data conflicts in shared state files.
|
||||||
- Keep commits scoped to a single logical change set.
|
- Keep commits scoped to a single logical change set.
|
||||||
|
|
||||||
|
## Model Tiering
|
||||||
|
|
||||||
|
Model choice is a standard, not a preference. Delegating a mechanical grep to a
|
||||||
|
frontier reasoning model wastes budget; sending a security review to a cheap tier
|
||||||
|
produces a review that passes and proves nothing. Both are defects.
|
||||||
|
|
||||||
|
Tiers are named by **capability class**, so the standard survives a model
|
||||||
|
generation. An operator binds each class to a concrete model id.
|
||||||
|
|
||||||
|
| Class | Use for |
|
||||||
|
| ------------- | ----------------------------------------------------------------------------------------- |
|
||||||
|
| `search` | grep/glob, file location, status and health checks, one-line mechanical edits |
|
||||||
|
| `build` | feature implementation, test writing, bugfixes, routine refactors |
|
||||||
|
| `judge` | code review, planning, API/compat-sensitive changes |
|
||||||
|
| `adversarial` | security review, ambiguous architecture, anything where a wrong "looks fine" is expensive |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
1. **Start at the cheapest class that can do the task; escalate on evidence, not
|
||||||
|
on nerves.** Omitting a tier is not neutral — it inherits the caller's model,
|
||||||
|
which is usually the most expensive one.
|
||||||
|
2. **Compat-sensitive work escalates one class.** A change that must interoperate
|
||||||
|
with an existing contract is judged, not just built.
|
||||||
|
3. **A tier assignment is benchmarked, not asserted.** Move a task class to a
|
||||||
|
cheaper tier only against a blind A/B on real work from this codebase, ranked
|
||||||
|
by someone other than the author. "It seemed fine" is not evidence.
|
||||||
|
4. **Reviewer independence beats reviewer size.** An `adversarial` verdict from
|
||||||
|
the model that wrote the code is not a second opinion (see Constitution gate 16).
|
||||||
|
|
||||||
|
### Where the binding lives
|
||||||
|
|
||||||
|
The class→model map is operator configuration, never framework source: model
|
||||||
|
availability, cost, and quotas differ per operator and per host.
|
||||||
|
|
||||||
|
Resolution order, first hit wins:
|
||||||
|
|
||||||
|
1. the config service (DB-backed, surfaced and editable in the Mosaic webUI)
|
||||||
|
2. a local operator file (`STANDARDS.local.md`, or `policy/` where the runtime
|
||||||
|
injects it)
|
||||||
|
3. the framework default — the class names above, with no binding
|
||||||
|
|
||||||
|
Only layer 1 is auditable across a fleet, so it is the target end state; layers 2
|
||||||
|
and 3 exist so a host with no config service still runs. A local override that
|
||||||
|
silently disagrees with the config service is drift — the same failure class the
|
||||||
|
tool-index gate exists to catch, and it belongs in `mosaic doctor`.
|
||||||
|
|
||||||
## Prompting Contract
|
## Prompting Contract
|
||||||
|
|
||||||
All runtime adapters should inject:
|
All runtime adapters should inject:
|
||||||
|
|||||||
@@ -11,22 +11,105 @@ All tool suites are located at `~/.config/mosaic/tools/`.
|
|||||||
|
|
||||||
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||||
|
|
||||||
|
This index is complete and is kept complete mechanically: `tools/quality/scripts/check-tools-index.sh`
|
||||||
|
fails CI when a wrapper ships without an entry here, or when an entry here names a wrapper that no
|
||||||
|
longer exists. A wrapper missing from this list is, from inside an agent session, indistinguishable
|
||||||
|
from a wrapper that was never written — which is how the APPROVE/APPROVED incident below happened.
|
||||||
|
|
||||||
|
Every command takes `--help`. All of them accept `--login <account>` to pin the acting identity;
|
||||||
|
supply it explicitly on any host where the provider CLI's default account is an admin.
|
||||||
|
|
||||||
|
| Issues | |
|
||||||
|
| ------------------ | --------------------------------- |
|
||||||
|
| `issue-create.sh` | Create an issue (Gitea or GitHub) |
|
||||||
|
| `issue-view.sh` | Show one issue |
|
||||||
|
| `issue-list.sh` | List issues |
|
||||||
|
| `issue-edit.sh` | Edit title/body/labels/milestone |
|
||||||
|
| `issue-comment.sh` | Add a comment |
|
||||||
|
| `issue-assign.sh` | Assign or unassign |
|
||||||
|
| `issue-close.sh` | Close an issue |
|
||||||
|
| `issue-reopen.sh` | Reopen a closed issue |
|
||||||
|
|
||||||
|
| Pull requests | |
|
||||||
|
| ---------------- | --------------------------------------------------------- |
|
||||||
|
| `pr-create.sh` | Open a pull request |
|
||||||
|
| `pr-view.sh` | Show one PR |
|
||||||
|
| `pr-list.sh` | List PRs |
|
||||||
|
| `pr-diff.sh` | Fetch a PR's diff |
|
||||||
|
| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) |
|
||||||
|
| `pr-review.sh` | **Place a review verdict — see the dialect note below** |
|
||||||
|
| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state |
|
||||||
|
| `pr-merge.sh` | Merge a PR |
|
||||||
|
| `pr-close.sh` | Close a PR without merging |
|
||||||
|
|
||||||
|
| Milestones | |
|
||||||
|
| --------------------- | ------------------ |
|
||||||
|
| `milestone-create.sh` | Create a milestone |
|
||||||
|
| `milestone-list.sh` | List milestones |
|
||||||
|
| `milestone-close.sh` | Close a milestone |
|
||||||
|
|
||||||
|
| Gates and guards | |
|
||||||
|
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
|
||||||
|
| `push-guard.sh` | Refuse verifications that pass for the wrong reason (e.g. green against an unpushed tree) |
|
||||||
|
| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard |
|
||||||
|
| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree |
|
||||||
|
|
||||||
|
| Context | |
|
||||||
|
| -------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it |
|
||||||
|
| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider |
|
||||||
|
|
||||||
|
| Workspace | |
|
||||||
|
| -------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| `mosaic-worktree.sh` | Create/list/remove git worktrees — **the only supported way**; see below |
|
||||||
|
| `wrapper-guard.sh` | PreToolUse hook that enforces the two rules above; not called by hand |
|
||||||
|
|
||||||
|
**Workspace placement is derived, not chosen.** `mosaic-worktree.sh new <branch>` takes a branch
|
||||||
|
name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree,
|
||||||
|
repo name, parent dir, then `<parent>/<repo>-worktrees/<branch-slug>`. There is no placement flag
|
||||||
|
because a decision an agent has to make is a decision that drifts: the rule "big work goes on a work
|
||||||
|
filesystem" already existed in prose and 255 GB accumulated in `$HOME` across 842 directories
|
||||||
|
anyway, under five simultaneous conventions on a single host.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Issues
|
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> [--from <base>]
|
||||||
~/.config/mosaic/tools/git/issue-create.sh
|
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # derived path, no side effect
|
||||||
~/.config/mosaic/tools/git/issue-close.sh
|
~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state
|
||||||
|
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
|
||||||
|
~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones
|
||||||
|
```
|
||||||
|
|
||||||
# PRs
|
Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare
|
||||||
~/.config/mosaic/tools/git/pr-create.sh
|
clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and
|
||||||
~/.config/mosaic/tools/git/pr-merge.sh
|
`gc` decide by **evidence, never by size or age**: a worktree is reclaimable only when
|
||||||
|
`git status --porcelain` is empty _and_ `git rev-list --count HEAD --not --remotes` is 0. Anything
|
||||||
|
else is preserved and reported. `--force` exists and is yours to type deliberately.
|
||||||
|
|
||||||
# Milestones
|
`wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see
|
||||||
~/.config/mosaic/tools/git/milestone-create.sh
|
`runtime/claude/settings.json`). It blocks exactly three things and lets everything else through:
|
||||||
|
a `git clone`/`git worktree add` targeting `$HOME`; a raw provider-API **write** to an endpoint that
|
||||||
|
already has a wrapper above (reads are untouched — they are how you gather evidence); and the
|
||||||
|
literal `"event": "APPROVE"`. For a genuine gap no wrapper can express, prefix
|
||||||
|
`MOSAIC_WRAPPER_OVERRIDE=1`. Reaching for the override twice for the same call means the wrapper has
|
||||||
|
a missing flag — extend the wrapper.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/mosaic/tools/git/issue-create.sh --help
|
||||||
|
~/.config/mosaic/tools/git/pr-review.sh --pr 42 --event APPROVED --body "..."
|
||||||
|
|
||||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Review dialect — the reason `pr-review.sh` is not optional.** Gitea's approve event is
|
||||||
|
`APPROVED`; GitHub's is `APPROVE`. Send GitHub's spelling to a Gitea host and it answers **HTTP
|
||||||
|
200**, files the review as PENDING, and then rejects the submit with `422 review stay pending` — the
|
||||||
|
verdict looks placed and is not. (`REQUEST_CHANGES` is spelled identically on both, so only the
|
||||||
|
approve path carries the trap.) `pr-review.sh` sends the correct token for the detected provider.
|
||||||
|
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
|
||||||
|
placed.
|
||||||
|
|
||||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||||
|
|
||||||
### Code Review (Codex)
|
### Code Review (Codex)
|
||||||
|
|||||||
@@ -52,6 +52,16 @@
|
|||||||
"timeout": 10
|
"timeout": 10
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/git/wrapper-guard.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PostToolUse": [
|
"PostToolUse": [
|
||||||
|
|||||||
@@ -51,12 +51,26 @@ Skills are discovered from:
|
|||||||
|
|
||||||
### Extensions
|
### Extensions
|
||||||
|
|
||||||
The Mosaic Pi extension (`~/.config/mosaic/runtime/pi/mosaic-extension.ts`) handles:
|
`mosaic pi` loads framework-owned extensions directly from `~/.config/mosaic/runtime/pi/` in this
|
||||||
|
order:
|
||||||
|
|
||||||
- Session start/end lifecycle hooks
|
1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates,
|
||||||
- Active mission detection and context injection
|
and fleet heartbeat reporting.
|
||||||
- Memory routing to `~/.config/mosaic/memory/`
|
2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction
|
||||||
- MACP queue status reporting
|
checks.
|
||||||
|
|
||||||
|
The goal extension is deployed by Mosaic and MUST NOT be copied into `~/.pi/agent/extensions/`.
|
||||||
|
Use `/goal set <statement>` (or `/goal <statement>`) to start, then `/goal status`, `/goal pause`,
|
||||||
|
`/goal resume`, or `/goal cancel` to control it. An active goal is injected before every model
|
||||||
|
request, restored from branch-specific session entries, and considered achieved only after two
|
||||||
|
consecutive evidence-bearing reports. Common credential shapes are redacted before controller-owned
|
||||||
|
goal-state entries are persisted or
|
||||||
|
displayed; Pi's own model/tool-call history is separate. Goals and reports must contain references
|
||||||
|
and pass/fail summaries rather than secrets or raw sensitive output.
|
||||||
|
|
||||||
|
- `MOSAIC_GOAL_MAX_TURNS` — autonomous turn limit, default `40`, accepted range `1..500`.
|
||||||
|
- `MOSAIC_GOAL_MAX_NO_PROGRESS` — identical no-progress report limit, default `6`, accepted range
|
||||||
|
`1..100`.
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,10 @@ if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then
|
|||||||
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
||||||
MARKER="$TEST_ROOT/loader-marker"
|
MARKER="$TEST_ROOT/loader-marker"
|
||||||
LIBRARY="$TEST_ROOT/marker.so"
|
LIBRARY="$TEST_ROOT/marker.so"
|
||||||
|
FIXTURE_READY="$TEST_ROOT/loader-ready"
|
||||||
|
FIXTURE_FIFO="$TEST_ROOT/loader-block"
|
||||||
HOLDER_HOME="$TEST_ROOT/holder-home"
|
HOLDER_HOME="$TEST_ROOT/holder-home"
|
||||||
|
mkfifo "$FIXTURE_FIFO"
|
||||||
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||||
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
||||||
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||||
@@ -87,7 +90,17 @@ __attribute__((constructor)) static void mark_loader(void) {
|
|||||||
EOF
|
EOF
|
||||||
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
||||||
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
||||||
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60'
|
tmux -L "$TEST_SOCKET" new-session -d -s _holder \
|
||||||
|
"touch '$FIXTURE_READY'; read _ < '$FIXTURE_FIFO'"
|
||||||
|
# tmux starts the pane asynchronously. Wait until its contaminated shell has
|
||||||
|
# loaded the constructor and reached a builtin-only FIFO barrier before
|
||||||
|
# clearing the marker; otherwise that expected constructor can race with the
|
||||||
|
# clean holder assertion below and create a false failure.
|
||||||
|
for _attempt in {1..100}; do
|
||||||
|
[ -e "$FIXTURE_READY" ] && break
|
||||||
|
sleep 0.01
|
||||||
|
done
|
||||||
|
[ -e "$FIXTURE_READY" ] || fail "contaminated fixture pane did not become ready"
|
||||||
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
||||||
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
||||||
: > "$MARKER"
|
: > "$MARKER"
|
||||||
|
|||||||
@@ -254,15 +254,32 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
|
|
||||||
def _origin_and_path(url):
|
def _origin_and_path(url):
|
||||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
#
|
||||||
# port and its explicit default form compare equal.
|
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||||
|
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||||
|
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||||
|
# provider's own correct answer about a write that landed — a deterministic
|
||||||
|
# false negative on every comment posted against such a deployment. The
|
||||||
|
# scheme is also not what this check defends: the forgeries it exists to
|
||||||
|
# catch (look-alike host, decoy path prefix, wrong owner/repo/number) all
|
||||||
|
# vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||||
|
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||||
|
#
|
||||||
|
# Port: an implicit port and its own scheme's default compare equal, so
|
||||||
|
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||||
|
# because a different port is a different service on the same host.
|
||||||
parsed = urlparse(url or "")
|
parsed = urlparse(url or "")
|
||||||
scheme = (parsed.scheme or "").lower()
|
scheme = (parsed.scheme or "").lower()
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
|
if scheme in ("http", "https"):
|
||||||
|
scheme_class = "web"
|
||||||
default_port = 80 if scheme == "http" else 443
|
default_port = 80 if scheme == "http" else 443
|
||||||
port = parsed.port if parsed.port is not None else default_port
|
port = None if parsed.port in (None, default_port) else parsed.port
|
||||||
return (scheme, host, port), parsed.path.rstrip("/")
|
else:
|
||||||
|
scheme_class = scheme
|
||||||
|
port = parsed.port
|
||||||
|
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# mosaic-worktree.sh — the only supported way to create and dispose of a git
|
||||||
|
# worktree on a fleet host.
|
||||||
|
#
|
||||||
|
# Why this exists as a helper and not as a rule: the rule already existed, in
|
||||||
|
# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in
|
||||||
|
# $HOME across 842 directories anyway. Five placement conventions were live on
|
||||||
|
# one fleet host simultaneously. Every one was a decision an agent had to make,
|
||||||
|
# and a decision an agent has to make is a decision that drifts.
|
||||||
|
#
|
||||||
|
# So this script makes NO placement decision available. The caller supplies a
|
||||||
|
# branch name. Every path is DERIVED:
|
||||||
|
#
|
||||||
|
# main worktree <- git worktree list --porcelain (never cwd, which may
|
||||||
|
# itself already be a worktree)
|
||||||
|
# REPO_NAME <- basename of the main worktree
|
||||||
|
# REPO_PARENT <- dirname of the main worktree
|
||||||
|
# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees
|
||||||
|
# SLUG <- branch with '/' replaced by '-'
|
||||||
|
# WT_PATH <- $WT_ROOT/$SLUG
|
||||||
|
#
|
||||||
|
# The derivation puts the worktree on the same filesystem as the object store
|
||||||
|
# it shares, as a sibling of the repo, under one root per repo. Those are the
|
||||||
|
# properties that make the checkout cheap and — via `git worktree list` —
|
||||||
|
# enumerable, which is the only reason automated cleanup can ever be safe.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# mosaic-worktree.sh new <branch> [--from <base>] create (branch may exist)
|
||||||
|
# mosaic-worktree.sh path <branch> print derived path, no side effect
|
||||||
|
# mosaic-worktree.sh list this repo's worktrees + state
|
||||||
|
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
|
||||||
|
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
|
||||||
|
#
|
||||||
|
# `rm` and `gc` refuse to delete a worktree with uncommitted changes, with
|
||||||
|
# commits absent from every remote, or holding ignored files that are not of the
|
||||||
|
# well-known regenerable kind (a `.env` is ignored so it is never committed,
|
||||||
|
# which is also why nothing else holds a copy). That check is by EVIDENCE, never
|
||||||
|
# by size or age. --force overrides it and is yours to type deliberately.
|
||||||
|
#
|
||||||
|
# Run from anywhere inside the repo, or pass --repo <path>.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
REPO_HINT=""
|
||||||
|
ARGS=()
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--repo) REPO_HINT="${2:-}"; shift 2 ;;
|
||||||
|
*) ARGS+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||||
|
|
||||||
|
CMD="${1:-}"
|
||||||
|
[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
# ---- mechanical derivation -------------------------------------------------
|
||||||
|
# The FIRST entry of `git worktree list --porcelain` is always the main
|
||||||
|
# worktree, regardless of which worktree we are standing in. Deriving from cwd
|
||||||
|
# would nest worktrees inside worktrees.
|
||||||
|
resolve_repo() {
|
||||||
|
local start="${REPO_HINT:-$PWD}"
|
||||||
|
git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \
|
||||||
|
|| die "not inside a git repository: $start"
|
||||||
|
MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /{print substr($0,10); exit}')"
|
||||||
|
[ -n "$MAIN_WT" ] || die "could not resolve the main worktree"
|
||||||
|
REPO_NAME="$(basename -- "$MAIN_WT")"
|
||||||
|
REPO_PARENT="$(dirname -- "$MAIN_WT")"
|
||||||
|
WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees"
|
||||||
|
}
|
||||||
|
|
||||||
|
slugify() { printf '%s' "$1" | tr '/' '-'; }
|
||||||
|
|
||||||
|
derive_path() {
|
||||||
|
local branch="$1"
|
||||||
|
[ -n "$branch" ] || die "branch name required"
|
||||||
|
printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A worktree root under $HOME defeats the entire point: wrong filesystem, and
|
||||||
|
# $HOME is for configuration and state, not work products. Refuse rather than
|
||||||
|
# silently produce the layout we are trying to eliminate.
|
||||||
|
assert_not_home() {
|
||||||
|
local p="$1" home_real repo_real
|
||||||
|
home_real="$(cd "$HOME" && pwd -P)"
|
||||||
|
repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")"
|
||||||
|
case "$repo_real/" in
|
||||||
|
"$home_real"/*)
|
||||||
|
die "refusing: derived path is under \$HOME ($p).
|
||||||
|
The repo itself lives under \$HOME, so its worktrees would too. Move the repo
|
||||||
|
to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds
|
||||||
|
configuration, credentials, state and caches — not checkouts." ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- work-loss evidence ----------------------------------------------------
|
||||||
|
# Two independent questions, both answered from git, neither from size or age:
|
||||||
|
# dirty — anything uncommitted in the tree
|
||||||
|
# unpushed — commits reachable from HEAD that no remote ref contains
|
||||||
|
# precious — IGNORED files git will not mention and will not miss
|
||||||
|
#
|
||||||
|
# The third question is not obvious and was missed on the first pass. An
|
||||||
|
# independent reviewer demonstrated it in four commands: a pushed, clean
|
||||||
|
# worktree whose .gitignore covers `*.secret`, holding one `local.secret`.
|
||||||
|
# `git status --porcelain` is empty, `rev-list --count HEAD --not --remotes` is
|
||||||
|
# 0 — the evidence reads SAFE — and `git worktree remove` deletes the file. The
|
||||||
|
# same shape covers `.env`, credentials, scratch notes, downloaded fixtures:
|
||||||
|
# precisely the files that are ignored BECAUSE they must not be committed, which
|
||||||
|
# is also why nothing else is holding a copy.
|
||||||
|
#
|
||||||
|
# So ignored files count as work unless they are the well-known regenerable
|
||||||
|
# kind. Getting that set wrong is asymmetric: an over-broad list preserves a
|
||||||
|
# worktree that could have been reclaimed (cheap, visible, fixable by --force),
|
||||||
|
# an over-narrow one deletes the only copy of a secret (silent, permanent).
|
||||||
|
# The list stays short and conservative for that reason.
|
||||||
|
DISPOSABLE_RE='(^|/)(node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.turbo|\.cache|\.parcel-cache|\.gradle|dist|build|out|target|coverage|\.next|\.nuxt|\.svelte-kit)(/|$)|\.(pyc|pyo|o|class)$'
|
||||||
|
|
||||||
|
# These three run under `set -euo pipefail` inside command substitution, which
|
||||||
|
# makes any nonzero exit ANYWHERE in the pipeline abort the calling function
|
||||||
|
# silently. Two ways that bites, one of which shipped:
|
||||||
|
#
|
||||||
|
# * `grep -v` exits 1 when it filters everything out. A worktree whose only
|
||||||
|
# ignored entry is `node_modules/` is exactly the SAFE case, and it made
|
||||||
|
# `rm` exit 1 with no message and no removal — found by review.
|
||||||
|
# * `head -200` closes the pipe, SIGPIPEs the producer, and turns a worktree
|
||||||
|
# with 201 dirty files into the same silent abort. Not reported; it is the
|
||||||
|
# same defect one step upstream, so the cap is gone. Counting is cheap;
|
||||||
|
# the cap only ever protected output that is now never printed.
|
||||||
|
#
|
||||||
|
# Every one of them therefore ends in a total, and every stage that can
|
||||||
|
# legitimately exit nonzero says so explicitly.
|
||||||
|
wt_dirty() {
|
||||||
|
local out
|
||||||
|
out="$(git -C "$1" status --porcelain 2>/dev/null || true)"
|
||||||
|
if [ -n "$out" ]; then printf '%s\n' "$out" | wc -l; else printf '0'; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || printf '?'; }
|
||||||
|
|
||||||
|
# Default --ignored (not =matching) so a 40k-file node_modules collapses to one
|
||||||
|
# directory entry instead of being enumerated and then discarded.
|
||||||
|
wt_precious() {
|
||||||
|
local ignored
|
||||||
|
ignored="$(git -C "$1" status --porcelain --ignored 2>/dev/null \
|
||||||
|
| awk '/^!! /{print substr($0,4)}' || true)"
|
||||||
|
[ -n "$ignored" ] || { printf '0'; return 0; }
|
||||||
|
printf '%s\n' "$ignored" | grep -Ecv "$DISPOSABLE_RE" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
wt_state() {
|
||||||
|
local wt="$1" d u p
|
||||||
|
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"; p="$(wt_precious "$wt")"
|
||||||
|
if [ "$d" -eq 0 ] && [ "$u" = "0" ] && [ "$p" -eq 0 ]; then
|
||||||
|
printf 'SAFE\tclean; 0 unpushed; no ignored files worth keeping'
|
||||||
|
else
|
||||||
|
printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- commands --------------------------------------------------------------
|
||||||
|
cmd_path() { resolve_repo; derive_path "${1:-}"; echo; }
|
||||||
|
|
||||||
|
cmd_new() {
|
||||||
|
local branch="${1:-}" base=""
|
||||||
|
shift || true
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac
|
||||||
|
done
|
||||||
|
[ -n "$branch" ] || die "usage: mosaic-worktree.sh new <branch> [--from <base>]"
|
||||||
|
|
||||||
|
resolve_repo
|
||||||
|
local path; path="$(derive_path "$branch")"
|
||||||
|
assert_not_home "$path"
|
||||||
|
|
||||||
|
if [ -e "$path" ]; then
|
||||||
|
echo "exists: $path"
|
||||||
|
echo "(already checked out — reuse it, or 'rm' it first)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$WT_ROOT"
|
||||||
|
|
||||||
|
# Existing branch -> check it out. New branch -> create from base (default:
|
||||||
|
# the remote's default branch if resolvable, else current HEAD).
|
||||||
|
if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \
|
||||||
|
|| git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
|
||||||
|
git -C "$MAIN_WT" worktree add "$path" "$branch"
|
||||||
|
else
|
||||||
|
if [ -z "$base" ]; then
|
||||||
|
base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
||||||
|
[ -n "$base" ] || base="HEAD"
|
||||||
|
fi
|
||||||
|
git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
worktree: $path
|
||||||
|
branch: $branch
|
||||||
|
|
||||||
|
Removal is part of this task, not a later chore. When the work is pushed:
|
||||||
|
mosaic-worktree.sh rm $branch
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
resolve_repo
|
||||||
|
printf 'repo: %s\nroot: %s\n\n' "$MAIN_WT" "$WT_ROOT"
|
||||||
|
git -C "$MAIN_WT" worktree list --porcelain \
|
||||||
|
| awk '/^worktree /{print substr($0,10)}' \
|
||||||
|
| while read -r wt; do
|
||||||
|
[ "$wt" = "$MAIN_WT" ] && { printf '%-10s %s (main)\n' "-" "$wt"; continue; }
|
||||||
|
printf '%-10s %s\t%s\n' "$(wt_state "$wt" | cut -f1)" "$wt" "$(wt_state "$wt" | cut -f2)"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_rm() {
|
||||||
|
local branch="${1:-}" force=0
|
||||||
|
shift || true
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in --force) force=1; shift ;; *) die "unknown flag: $1" ;; esac
|
||||||
|
done
|
||||||
|
[ -n "$branch" ] || die "usage: mosaic-worktree.sh rm <branch> [--force]"
|
||||||
|
|
||||||
|
resolve_repo
|
||||||
|
local path; path="$(derive_path "$branch")"
|
||||||
|
[ -d "$path" ] || die "no worktree at $path"
|
||||||
|
|
||||||
|
local d u p
|
||||||
|
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"; p="$(wt_precious "$path")"
|
||||||
|
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ] || [ "$p" -ne 0 ]; }; then
|
||||||
|
die "refusing to remove $path
|
||||||
|
uncommitted files: $d
|
||||||
|
unpushed commits: $u
|
||||||
|
ignored, not disposable: $p
|
||||||
|
Commit and push first — that is the contract. Ignored files are counted because
|
||||||
|
git will neither report them nor miss them: a .env or a *.secret is ignored
|
||||||
|
precisely so it is never committed, which is also why nothing else holds a copy.
|
||||||
|
List them with: git -C $path status --porcelain --ignored | grep '^!!'
|
||||||
|
If this work is genuinely disposable, re-run with --force."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
|
||||||
|
if [ "$force" -eq 1 ]; then
|
||||||
|
git -C "$MAIN_WT" worktree remove --force "$path"
|
||||||
|
else
|
||||||
|
git -C "$MAIN_WT" worktree remove "$path"
|
||||||
|
fi
|
||||||
|
git -C "$MAIN_WT" worktree prune
|
||||||
|
echo "removed: $path"
|
||||||
|
rmdir "$WT_ROOT" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_gc() {
|
||||||
|
local apply=0
|
||||||
|
[ "${1:-}" = "--apply" ] && apply=1
|
||||||
|
resolve_repo
|
||||||
|
git -C "$MAIN_WT" worktree prune
|
||||||
|
git -C "$MAIN_WT" worktree list --porcelain \
|
||||||
|
| awk '/^worktree /{print substr($0,10)}' \
|
||||||
|
| while read -r wt; do
|
||||||
|
[ "$wt" = "$MAIN_WT" ] && continue
|
||||||
|
local_state="$(wt_state "$wt")"
|
||||||
|
case "$local_state" in
|
||||||
|
SAFE*)
|
||||||
|
if [ "$apply" -eq 1 ]; then
|
||||||
|
git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt"
|
||||||
|
else
|
||||||
|
echo "reclaimable (clean + fully pushed): $wt"
|
||||||
|
fi ;;
|
||||||
|
*) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
git -C "$MAIN_WT" worktree prune
|
||||||
|
[ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)'
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
new) cmd_new "$@" ;;
|
||||||
|
path) cmd_path "$@" ;;
|
||||||
|
list) cmd_list "$@" ;;
|
||||||
|
rm) cmd_rm "$@" ;;
|
||||||
|
gc) cmd_gc "$@" ;;
|
||||||
|
-h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||||
|
*) die "unknown command: $CMD (new | path | list | rm | gc)" ;;
|
||||||
|
esac
|
||||||
@@ -243,15 +243,35 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
|
|
||||||
def _origin_and_path(url):
|
def _origin_and_path(url):
|
||||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
#
|
||||||
# port and its explicit default form compare equal.
|
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||||
|
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||||
|
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||||
|
# provider's own correct answer about a comment that landed — a deterministic
|
||||||
|
# false negative on EVERY review comment posted against such a deployment.
|
||||||
|
# That matters more here than anywhere else: on a host where no seat can
|
||||||
|
# create a review OBJECT, the comment-form review record this path produces
|
||||||
|
# is the only gate-16 evidence available, and this check refuses all of it.
|
||||||
|
# The scheme is also not what the check defends: the forgeries it exists to
|
||||||
|
# catch (look-alike host, decoy path prefix, wrong owner/repo/kind/number)
|
||||||
|
# all vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||||
|
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||||
|
#
|
||||||
|
# Port: an implicit port and its own scheme's default compare equal, so
|
||||||
|
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||||
|
# because a different port is a different service on the same host.
|
||||||
parsed = urlparse(url or "")
|
parsed = urlparse(url or "")
|
||||||
scheme = (parsed.scheme or "").lower()
|
scheme = (parsed.scheme or "").lower()
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
|
if scheme in ("http", "https"):
|
||||||
|
scheme_class = "web"
|
||||||
default_port = 80 if scheme == "http" else 443
|
default_port = 80 if scheme == "http" else 443
|
||||||
port = parsed.port if parsed.port is not None else default_port
|
port = None if parsed.port in (None, default_port) else parsed.port
|
||||||
return (scheme, host, port), parsed.path.rstrip("/")
|
else:
|
||||||
|
scheme_class = scheme
|
||||||
|
port = parsed.port
|
||||||
|
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -7,14 +7,40 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
HOME_DIR="$WORK_DIR/home"
|
||||||
LOG_FILE="$WORK_DIR/calls.log"
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
|
||||||
|
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||||
|
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||||
|
#
|
||||||
|
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||||
|
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||||
|
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||||
|
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||||
|
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credentials below are silently
|
||||||
|
# ignored. This suite is the one where the consequence is not subtle: it FAILS
|
||||||
|
# outright on a provisioned seat (rc=1 bare, rc=0 with $HOME sandboxed, one
|
||||||
|
# variable changed) and passes everywhere else, including CI, which has no
|
||||||
|
# per-agent token to leak.
|
||||||
|
#
|
||||||
|
# CONTAINMENT: the sandboxed HOME in the four run helpers below. It only has to
|
||||||
|
# bound a failure that the pin should already have prevented.
|
||||||
|
#
|
||||||
|
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||||
|
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||||
|
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||||
|
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||||
|
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||||
|
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||||
|
#
|
||||||
|
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||||
|
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||||
|
|
||||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
{
|
{
|
||||||
@@ -86,6 +112,7 @@ run_in_repo() {
|
|||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
"$@"
|
"$@"
|
||||||
@@ -283,6 +310,7 @@ run_in_repo2() {
|
|||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR2:$PATH" \
|
PATH="$BIN_DIR2:$PATH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
"$@"
|
"$@"
|
||||||
@@ -343,7 +371,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
|||||||
token_fallback() {
|
token_fallback() {
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
get_gitea_token_for_login "$1" "$2"
|
get_gitea_token_for_login "$1" "$2"
|
||||||
' _ "$1" "$2"
|
' _ "$1" "$2"
|
||||||
@@ -354,7 +382,7 @@ token_fallback() {
|
|||||||
token_pyyaml() {
|
token_pyyaml() {
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
get_gitea_token_for_login "$1" "$2"
|
get_gitea_token_for_login "$1" "$2"
|
||||||
' _ "$1" "$2"
|
' _ "$1" "$2"
|
||||||
|
|||||||
@@ -61,15 +61,54 @@ STATE_FILE="$WORK_DIR/comments.json"
|
|||||||
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
||||||
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
||||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||||
|
HOME_DIR="$WORK_DIR/home"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||||
|
#
|
||||||
|
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||||
|
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||||
|
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||||
|
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||||
|
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
|
||||||
|
# ignored. The stub curl then rejects the unrecognised bearer, and this suite
|
||||||
|
# fails at its FIRST case with `Gitea authenticated-identity read failed with
|
||||||
|
# HTTP 401`. An empty repo-local value shadows the global one and reads back
|
||||||
|
# empty at rc=0. Measured: without this pin the suite is RED on every seat.
|
||||||
|
#
|
||||||
|
# CONTAINMENT: the sandboxed HOME in run_comment(). It only has to bound a
|
||||||
|
# failure that the pin should already have prevented.
|
||||||
|
#
|
||||||
|
# THIS SUITE WAS THE HARDEST OF THE FIVE TO SEE, and the reason is worth stating
|
||||||
|
# because it generalises: run_comment() sends the wrapper's stdout AND stderr to
|
||||||
|
# $OUTPUT_FILE, and the EXIT trap above deletes $WORK_DIR. So the 401 — the only
|
||||||
|
# thing that says what went wrong — exists only inside a directory that is gone
|
||||||
|
# by the time anyone looks. The suite exits 1 with ZERO bytes on stdout and
|
||||||
|
# stderr. A suite that discards or deletes its own evidence turns any post-hoc
|
||||||
|
# assay into a non-measurement: "nothing found" there means "no surviving
|
||||||
|
# trace", never "clean". It was found by intercepting the identity read at its
|
||||||
|
# SOURCE (a PATH shim over `git` logging every `mosaic.gitIdentity` read to a
|
||||||
|
# file outside $WORK_DIR), which is deletion-proof by construction, rather than
|
||||||
|
# by grepping for the symptom.
|
||||||
|
#
|
||||||
|
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||||
|
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||||
|
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||||
|
# by construction and every suite reads clean however vulnerable it is. To
|
||||||
|
# measure, REPLICATE a seat (a decoy HOME whose .gitconfig sets
|
||||||
|
# mosaic.gitIdentity, with no per-slot token) so step 0 reaches its fail-loud
|
||||||
|
# branch — or intercept the read as described above.
|
||||||
|
#
|
||||||
|
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||||
|
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||||
|
|
||||||
ISSUE_NUMBER=7
|
ISSUE_NUMBER=7
|
||||||
REPO_SLUG="mosaicstack/stack"
|
REPO_SLUG="mosaicstack/stack"
|
||||||
@@ -372,6 +411,7 @@ run_comment() {
|
|||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
TMPDIR="$TMP_SCRATCH" \
|
TMPDIR="$TMP_SCRATCH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||||
|
|||||||
@@ -7,13 +7,38 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
HOME_DIR="$WORK_DIR/home"
|
||||||
LOG_FILE="$WORK_DIR/calls.log"
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||||
|
#
|
||||||
|
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||||
|
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||||
|
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||||
|
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||||
|
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
|
||||||
|
# ignored and the suite runs against a production credential. An empty repo-local
|
||||||
|
# value shadows the global one and reads back empty at rc=0. Measured: this suite
|
||||||
|
# resolves a per-slot token without it.
|
||||||
|
#
|
||||||
|
# CONTAINMENT: the sandboxed HOME in run_wrapper(). It only has to bound a failure
|
||||||
|
# that the pin should already have prevented.
|
||||||
|
#
|
||||||
|
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||||
|
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||||
|
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||||
|
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||||
|
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||||
|
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||||
|
#
|
||||||
|
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||||
|
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||||
|
|
||||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
||||||
@@ -50,6 +75,7 @@ run_wrapper() {
|
|||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
|||||||
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
|
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
|
||||||
MOCK_BIN="$SANDBOX/bin"
|
MOCK_BIN="$SANDBOX/bin"
|
||||||
REPO_DIR="$SANDBOX/repo"
|
REPO_DIR="$SANDBOX/repo"
|
||||||
|
HOME_DIR="$SANDBOX/home"
|
||||||
LOG_FILE="$SANDBOX/mock.log"
|
LOG_FILE="$SANDBOX/mock.log"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -15,7 +16,7 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
mkdir -p "$MOCK_BIN" "$REPO_DIR"
|
mkdir -p "$MOCK_BIN" "$REPO_DIR" "$HOME_DIR"
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
|
|
||||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
@@ -109,7 +110,48 @@ chmod +x "$MOCK_BIN/curl"
|
|||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
git init -q
|
git init -q
|
||||||
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||||
|
#
|
||||||
|
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||||
|
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||||
|
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||||
|
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
|
||||||
|
# check, so the `GITEA_TOKEN=redacted-test-token` exported below is silently
|
||||||
|
# overridden and a REAL per-slot token from $HOME is what flows through the
|
||||||
|
# wrapper. Measured on a provisioned seat before this pin: all 5 mock-curl calls
|
||||||
|
# carried the real per-slot token in argv and the fixture token was never used at
|
||||||
|
# ALL. Three consequences specific to this suite:
|
||||||
|
# 1. pr-merge.sh passes the token as `-H "Authorization: token $token"` and the
|
||||||
|
# mock curl logs full argv, so the real credential is written to $LOG_FILE
|
||||||
|
# on disk — transiently: the suite truncates that file between phases and
|
||||||
|
# the EXIT trap removes $SANDBOX, so it leaves NO post-hoc trace. That is
|
||||||
|
# why this suite was the hardest of the three to detect; observing it needs
|
||||||
|
# an instrument that captures argv while the run is live.
|
||||||
|
# 2. Every failure path dumps $OUTPUT/$LOG_FILE to stderr through
|
||||||
|
# `sed 's/redacted-test-token/***REDACTED***/g'` — a redaction pattern that
|
||||||
|
# is the literal fixture string and therefore CANNOT match the token
|
||||||
|
# actually in use.
|
||||||
|
# 3. The leak assertion at "Token leaked to pr-merge.sh output" greps for that
|
||||||
|
# same fixture string, so on a provisioned seat it passes vacuously: it is
|
||||||
|
# searching for a value the run never used.
|
||||||
|
# An empty repo-local value shadows the global one and reads back empty at rc=0.
|
||||||
|
#
|
||||||
|
# CONTAINMENT: the sandboxed HOME exported below. It only has to bound a failure
|
||||||
|
# that the pin should already have prevented.
|
||||||
|
#
|
||||||
|
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||||
|
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||||
|
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||||
|
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||||
|
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||||
|
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||||
|
#
|
||||||
|
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||||
|
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||||
|
|
||||||
|
# $SANDBOX/$HOME_DIR were derived from the real $HOME above, before this export.
|
||||||
|
export HOME="$HOME_DIR"
|
||||||
export PATH="$MOCK_BIN:$PATH"
|
export PATH="$MOCK_BIN:$PATH"
|
||||||
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
||||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||||
|
|||||||
@@ -8,12 +8,68 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
|
|||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
FIXTURE_DIR="$WORK_DIR/fixtures"
|
FIXTURE_DIR="$WORK_DIR/fixtures"
|
||||||
STUB_DIR="$WORK_DIR/stubs"
|
STUB_DIR="$WORK_DIR/stubs"
|
||||||
|
HOME_DIR="$WORK_DIR/home"
|
||||||
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR"
|
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR" "$HOME_DIR"
|
||||||
|
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||||
|
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||||
|
#
|
||||||
|
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||||
|
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||||
|
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||||
|
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
|
||||||
|
# check, so the `GITEA_TOKEN="stub-token"` set in the run helpers below is
|
||||||
|
# silently overridden and a REAL per-slot token from $HOME is what reaches curl.
|
||||||
|
# Measured on a provisioned seat before this pin: both stub-curl calls carried
|
||||||
|
# the real token in argv. An empty repo-local value shadows the global one and
|
||||||
|
# reads back empty at rc=0.
|
||||||
|
#
|
||||||
|
# CONTAINMENT: the sandboxed HOME in the three run helpers below. It only has to
|
||||||
|
# bound a failure that the pin should already have prevented.
|
||||||
|
#
|
||||||
|
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||||
|
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||||
|
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||||
|
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||||
|
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||||
|
# per-slot token) so step 0 reaches its fail-loud branch. See
|
||||||
|
# test-gitea-token-identity.sh for the stronger `env -i HOME=…` form used where a
|
||||||
|
# suite's whole subject IS identity resolution.
|
||||||
|
#
|
||||||
|
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||||
|
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||||
|
|
||||||
|
# The pin above removes step 0, but this suite has a SECOND, independent
|
||||||
|
# dependency on operator state, and closing only the first would leave the suite
|
||||||
|
# red on any hermetic environment. The `GITEA_TOKEN="stub-token"` /
|
||||||
|
# `GITEA_URL="https://git.example.test"` pair the run helpers set is INERT: step 2
|
||||||
|
# of get_gitea_token accepts GITEA_TOKEN only when GITEA_URL matches the remote
|
||||||
|
# host, and this repo's origin is git.uscllc.com, so that pair can never satisfy
|
||||||
|
# it. Before this fixture the only credential that could reach the authenticated
|
||||||
|
# curl branch was a REAL one — from step 0 on an agent seat, or from step 1
|
||||||
|
# reading the operator's own ~/.config/mosaic/credentials.json. That is why the
|
||||||
|
# "curl success path" case passed: not because the stub credential worked, but
|
||||||
|
# because a production credential was available.
|
||||||
|
#
|
||||||
|
# A fixture is used rather than relying on the sandboxed HOME making step 1 find
|
||||||
|
# nothing: a test that passes because production configuration is ABSENT fails
|
||||||
|
# the moment it is present. Step 1 now resolves deterministically to a value that
|
||||||
|
# is a fixture on every machine.
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"usc": {
|
||||||
|
"url": "https://git.uscllc.com",
|
||||||
|
"token": "stub-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
|
cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
|
||||||
{
|
{
|
||||||
@@ -131,6 +187,8 @@ run_curl_success_case() {
|
|||||||
set +e
|
set +e
|
||||||
output=$(cd "$REPO_DIR" && \
|
output=$(cd "$REPO_DIR" && \
|
||||||
PATH="$STUB_DIR:$PATH" \
|
PATH="$STUB_DIR:$PATH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
TMPDIR="$tmpdir" \
|
TMPDIR="$tmpdir" \
|
||||||
GITEA_TOKEN="stub-token" \
|
GITEA_TOKEN="stub-token" \
|
||||||
GITEA_URL="https://git.example.test" \
|
GITEA_URL="https://git.example.test" \
|
||||||
@@ -170,6 +228,8 @@ run_curl_early_exit_cleanup_case() {
|
|||||||
set +e
|
set +e
|
||||||
output=$(cd "$REPO_DIR" && \
|
output=$(cd "$REPO_DIR" && \
|
||||||
PATH="$STUB_DIR:$PATH" \
|
PATH="$STUB_DIR:$PATH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
TMPDIR="$tmpdir" \
|
TMPDIR="$tmpdir" \
|
||||||
GITEA_TOKEN="stub-token" \
|
GITEA_TOKEN="stub-token" \
|
||||||
GITEA_URL="https://git.example.test" \
|
GITEA_URL="https://git.example.test" \
|
||||||
@@ -204,7 +264,8 @@ run_curl_early_exit_cleanup_case() {
|
|||||||
run_case() {
|
run_case() {
|
||||||
local fixture="$1" expected_number="$2" expected_head="$3"
|
local fixture="$1" expected_number="$2" expected_head="$3"
|
||||||
local output
|
local output
|
||||||
output=$(cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
output=$(cd "$REPO_DIR" && HOME="$HOME_DIR" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
||||||
PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY'
|
PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY'
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -439,6 +439,19 @@ elif mode == "comment-url-wrong-repo":
|
|||||||
elif mode == "comment-url-suffix-injection":
|
elif mode == "comment-url-suffix-injection":
|
||||||
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
||||||
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
||||||
|
elif mode == "comment-url-wrong-port":
|
||||||
|
# #991 bound: an EXPLICIT non-default port is a different service on the same
|
||||||
|
# host. Relaxing http-vs-https must NOT relax this.
|
||||||
|
pr_url = f"{_p.scheme}://{_p.hostname}:8443{_slug}/pulls/123"
|
||||||
|
elif mode == "comment-url-non-web-scheme":
|
||||||
|
# #991 bound: ONLY http/https collapse; any other scheme stays distinguishing.
|
||||||
|
pr_url = f"ftp://{_p.netloc}{_slug}/pulls/123"
|
||||||
|
elif mode == "comment-url-scheme-downgrade":
|
||||||
|
# #991, and the only URL mode here that must be ACCEPTED. A Gitea whose
|
||||||
|
# ROOT_URL is http:// returns http:// object URLs for a repo reached over
|
||||||
|
# https://. Same host, same path, correct record — a truthful provider
|
||||||
|
# answer about a comment that landed, not a forgery.
|
||||||
|
pr_url = f"http://{_p.netloc}{_slug}/pulls/123"
|
||||||
elif mode == "comment-mixed-case-slug":
|
elif mode == "comment-mixed-case-slug":
|
||||||
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
|
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
|
||||||
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
|
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
|
||||||
@@ -893,11 +906,16 @@ fi
|
|||||||
assert_no_temp_leak "review-body-reuse"
|
assert_no_temp_leak "review-body-reuse"
|
||||||
|
|
||||||
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
|
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
|
||||||
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
|
# but whose provider-returned pull_request_url does not belong to this PR must
|
||||||
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
|
# FAIL CLOSED. Verification pins the URL's ORIGIN (scheme-class + host + explicit
|
||||||
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
|
# non-default port) and FULL path (deployment prefix + exact owner/repo + kind +
|
||||||
# test would wrongly accept the look-alike-host and prefix-injection variants.
|
# number); a bare endswith/suffix test would wrongly accept the look-alike-host
|
||||||
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
|
# and prefix-injection variants. comment-url-wrong-port and
|
||||||
|
# comment-url-non-web-scheme (#991) bound the scheme relaxation from the other
|
||||||
|
# side: collapsing http/https must not also collapse a different port or a
|
||||||
|
# different scheme family.
|
||||||
|
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo \
|
||||||
|
comment-url-suffix-injection comment-url-wrong-port comment-url-non-web-scheme; do
|
||||||
if run_review "$bad_mode" comment durable-body; then
|
if run_review "$bad_mode" comment durable-body; then
|
||||||
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
||||||
cat "$OUTPUT_FILE" >&2
|
cat "$OUTPUT_FILE" >&2
|
||||||
@@ -923,6 +941,19 @@ run_review comment-mixed-case-slug comment durable-body https://git.mosaicstack.
|
|||||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||||
assert_no_temp_leak "comment-mixed-case-slug"
|
assert_no_temp_leak "comment-mixed-case-slug"
|
||||||
|
|
||||||
|
# Case 15c (#991): the deployment's Gitea ROOT_URL is http:// while every client
|
||||||
|
# reaches it over https://, so the provider returns an http:// pull_request_url
|
||||||
|
# for a comment that is otherwise entirely correct. Same class as 15b — a
|
||||||
|
# legitimate provider response, not a spoof — and a scheme-strict compare
|
||||||
|
# rejects it on EVERY comment, deterministically. That is not a cosmetic false
|
||||||
|
# negative here: on a host where no seat can create a review OBJECT, this
|
||||||
|
# comment-form record is the only gate-16 evidence obtainable, and the wrapper
|
||||||
|
# refuses all of it while the comment sits durably on the PR. Host, path, owner,
|
||||||
|
# repo, kind and number stay strict; only http-vs-https is relaxed.
|
||||||
|
run_review comment-url-scheme-downgrade comment durable-body
|
||||||
|
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||||
|
assert_no_temp_leak "comment-url-scheme-downgrade"
|
||||||
|
|
||||||
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
||||||
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
||||||
# review is genuinely created and verified as pinned to the OLD head, but the
|
# review is genuinely created and verified as pinned to the OLD head, but the
|
||||||
|
|||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-wrapper-guard.sh — hermetic behavioural regression for wrapper-guard.sh.
|
||||||
|
#
|
||||||
|
# Resolves no credentials, touches no network, and creates no repository: the
|
||||||
|
# guard reads a hook payload on stdin and answers with an exit code, so the whole
|
||||||
|
# contract is testable from fixtures.
|
||||||
|
#
|
||||||
|
# The fixtures are written to a temp file rather than passed inline, and this is
|
||||||
|
# not stylistic. The guard inspects the literal text of the Bash command it is
|
||||||
|
# handed. A test that embeds `git clone ... $HOME` inside its own command line
|
||||||
|
# trips the guard on the harness instead of on the fixture — which is exactly
|
||||||
|
# what happened the first time this was checked by hand. Substring matching over
|
||||||
|
# whole command text is the guard's deliberate fail-closed posture; a test that
|
||||||
|
# does not account for it silently measures the wrong thing.
|
||||||
|
#
|
||||||
|
# Exit: 0 = every fixture behaved as specified · 1 = at least one did not
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
GUARD="${1:-$HERE/wrapper-guard.sh}"
|
||||||
|
[ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; }
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
FIXTURES="$TMP/fixtures.tsv"
|
||||||
|
|
||||||
|
# Each line: <expected-exit> TAB <hook payload> TAB <what it proves>
|
||||||
|
# [ TAB <substring the block message must contain> ]
|
||||||
|
# 0 = allowed, 2 = blocked. The optional fourth field is how the remediation
|
||||||
|
# itself gets checked; without it a block is only asserted to have happened,
|
||||||
|
# not to have been useful.
|
||||||
|
{
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone https://example.invalid/x ~/wt"}}\tcheckout into $HOME is refused\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git worktree add ~/wt topic"}}\tworktree into $HOME is refused\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -s -X GET https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\treads are never blocked\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\treview write has a wrapper\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tmerge write has a wrapper\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://api.github.com/repos/a/b/issues"}}\tGitHub host is covered too\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/releases"}}\tan endpoint with no wrapper passes\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVE\\"} https://example.invalid/x"}}\tthe APPROVE token is caught anywhere\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"ls -la /src"}}\tordinary commands are untouched\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=1 curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tbreak-glass works\n'
|
||||||
|
printf '0\t{"tool_input":{}}\tan empty payload does not block the session\n'
|
||||||
|
# --- bypasses an independent reviewer demonstrated against the first version.
|
||||||
|
# Each of these returned 0 (allowed) and each is a real write. They are pinned
|
||||||
|
# as fixtures rather than fixed-and-forgotten because the class is recurring:
|
||||||
|
# the guard reads text, so every spelling it does not know is a hole.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t-d@body with no space is still a body\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl --request=POST -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t--request=POST equals-form is still a method\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@b https://git.example.invalid${p}${q}"}}\ta path split across variables is still that path\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl --data-binary @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\t--data-binary is a body\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -F f=@b https://git.example.invalid/api/v1/repos/a/b/issues"}}\t-F multipart is a body\n'
|
||||||
|
# Reads must survive every one of those broadenings, or the guard gets disabled.
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tno body and no verb is a read\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"grep -rn /pulls/ src/ | head -20"}}\ta path fragment in a grep is not an API call\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://registry.example.invalid/v2/x/manifests/latest"}}\tan unwrapped API is not this guard'"'"'s business\n'
|
||||||
|
# --- round two of the same review. Splitting the ENDPOINT TOKEN defeats any
|
||||||
|
# amount of fragment matching, because the endpoint does not exist until the
|
||||||
|
# shell expands it. The guard now refuses to clear a write whose URL it cannot
|
||||||
|
# read, rather than pretending it read one.
|
||||||
|
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/iss; b=ues/1/comments; curl -d@body https://git.example.invalid${a}${b}"}}\tan endpoint token split across variables is unreadable, not absent\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/pu; b=lls/1/reviews; curl -d@body https://git.example.invalid${a}${b}"}}\tsame split, review endpoint\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @payload https://hooks.example.invalid/services/${WEBHOOK_ID}"}}\tan opaque URL that is not forge-shaped stays allowed\n'
|
||||||
|
# --- round six changed the contract in this direction, and these fixtures are
|
||||||
|
# where it shows. They used to assert that discussing a call is not making one.
|
||||||
|
# Five rounds proved there is no textual way to tell a quoted example from a
|
||||||
|
# quoted command, so the guard stopped trying: it judges the payload, and a
|
||||||
|
# payload inside quotes is still a payload. Quoting one of these on a Bash
|
||||||
|
# command line is now refused, and the way to write the example is a
|
||||||
|
# file-writing tool. This is the deliberate cost of the mechanism change.
|
||||||
|
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tquoting a wrapped write is refused even in a grep\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\t...and when written into a file\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\t...and when printed from another language\n'
|
||||||
|
# The boundary that keeps this from being "block everything": what is refused
|
||||||
|
# is a WRITE to a WRAPPED endpoint. Mentioning either alone still passes, and
|
||||||
|
# these are asserted as hard as the blocks above.
|
||||||
|
printf '0\t{"tool_input":{"command":"grep -R \\"curl -s https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/"}}\tquoting a READ example is untouched\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"echo \\"the wrapped endpoint is https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tnaming the endpoint without a body flag is untouched\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/releases\\" docs/"}}\tquoting a write to an UNWRAPPED endpoint is untouched\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"issue-comment.sh --repo a/b --issue 1 --body @msg.md"}}\tthe wrapper itself carries a body flag and must never trip its own guard\n'
|
||||||
|
# Command position must still catch the real thing behind operators and env.
|
||||||
|
printf '2\t{"tool_input":{"command":"cd /tmp && GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\ta real call behind && and an assignment is still a call\n'
|
||||||
|
# --- the case the AUTHOR hit twice while chasing the above: sending a message
|
||||||
|
# that QUOTED one of these fixtures. Under the old contract that was a defect
|
||||||
|
# to be parsed away; under this one it is the documented cost, and the message
|
||||||
|
# gets composed with a file-writing tool instead.
|
||||||
|
printf '2\t{"tool_input":{"command":"send.sh -m \\"repro was: cd /tmp && curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tquoting the repro in a message is refused too\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues\\nEOF"}}\ta heredoc body carrying the payload is refused with it\n'
|
||||||
|
# ...but quotes stop being data the moment something executes them.
|
||||||
|
printf '2\t{"tool_input":{"command":"bash -c \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tbash -c makes the quoted text code again\n'
|
||||||
|
# --- round three. Each of these four is a real write that a bare-name match
|
||||||
|
# for the client could not see, because an ordinary word sat in front of it.
|
||||||
|
# They are kept as fixtures after the mechanism change even though the guard no
|
||||||
|
# longer looks for a client at all: they are the evidence for WHY it stopped,
|
||||||
|
# and a future re-narrowing that reintroduced position would fail here first.
|
||||||
|
printf '2\t{"tool_input":{"command":"env GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tenv VAR=... in front of the client is still the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"command curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tcommand in front of the client is still the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\ttimeout N in front of the client is still the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"/usr/bin/curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan absolute path to the client is still the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"echo timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments >> notes.md"}}\tnaming the call after echo carries the payload, so it is refused\n'
|
||||||
|
# A shell standing between quoted data and execution makes that data code,
|
||||||
|
# and the pipe is the form agents actually use. Filing it as data allowed the
|
||||||
|
# call to vanish from the skeleton while still running.
|
||||||
|
printf '2\t{"tool_input":{"command":"printf '"'"'%%s\\\\n'"'"' '"'"'curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments'"'"' | sh"}}\tquoted code piped to a shell is code\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"cat <<EOF | sh\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\ta heredoc piped to a shell is code\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"sh -s <<EOF\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\tsh -s reads its script from the heredoc\n'
|
||||||
|
# ...and the questions that used to follow — is the pipe target a shell, does a
|
||||||
|
# shell on one line execute a string on another — no longer have to be answered
|
||||||
|
# at all. Both of these carry the payload, both are refused, and neither
|
||||||
|
# outcome depends on parsing what the pipe or the other line does.
|
||||||
|
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/ | wc -l"}}\tpiping the payload to wc is refused without asking what wc is\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"docker run --rm alpine sh -c '"'"'echo hi'"'"'\\necho \\"example: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tan unrelated shell on another line no longer changes the answer either way\n'
|
||||||
|
# --- round four. The guard was still reading the command as typed rather than
|
||||||
|
# as the shell will run it: a backslash before a newline is removed before
|
||||||
|
# anything else happens, so the endpoint token can be split across the join.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments"}}\ta line continuation inside the endpoint token is still that endpoint\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pu\\\\\\nlls/1/reviews"}}\tsame join, review endpoint\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments\\nEOF"}}\tthe join still runs first, and the joined payload is refused in a document too\n'
|
||||||
|
# Transparent prefixes take option VALUES, and the value was a word the list
|
||||||
|
# did not know — so the client went missing again behind an ordinary `sudo -u`.
|
||||||
|
printf '2\t{"tool_input":{"command":"sudo -u root curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option value after a prefix does not hide the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"timeout --signal TERM 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option pair plus a duration does not hide the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"xargs echo curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe payload behind xargs echo is refused rather than adjudicated\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"sudo apt-get install curl"}}\tinstalling the client is not calling it\n'
|
||||||
|
# Execution through another command needed its own case under the old design.
|
||||||
|
printf '2\t{"tool_input":{"command":"find . -maxdepth 0 -exec curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments ;"}}\tfind -exec runs the client\n'
|
||||||
|
# --- round five, and the finding that ended the parser. Command substitution
|
||||||
|
# inside a double-quoted span EXECUTES, while the skeleton was discarding that
|
||||||
|
# span as inert prose. The unquoted and process-substitution forms already
|
||||||
|
# blocked, which is what made it a classification defect rather than a spelling
|
||||||
|
# one: the same call was refused or allowed depending on a quote character.
|
||||||
|
printf '2\t{"tool_input":{"command":"echo \\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tcommand substitution inside double quotes executes\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"echo \\"`curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments`\\""}}\tso does the backtick form\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"msg=\\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tand an assignment RHS is not data either\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"echo $(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tthe unquoted form, which blocked before and must keep blocking\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"cat <(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tprocess substitution, same\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"bash --command \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\""}}\tthe long-option spelling of bash -c needs no entry in any list now\n'
|
||||||
|
# A client the guard was never taught is the point of dropping client
|
||||||
|
# detection: neither of these names curl at all.
|
||||||
|
printf '2\t{"tool_input":{"command":"python3 -c '"'"'import requests; requests.post(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\", json={})'"'"'"}}\ta library call is a write with no flag and no curl\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"wget --post-data=x https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\twget spells its body differently and is still a write\n'
|
||||||
|
|
||||||
|
# Round six scoped the guard on `https?://`, and review found the absence shape
|
||||||
|
# had simply moved to that new boundary: a raw provider CLI carries no scheme,
|
||||||
|
# so the guard never reached the write question. These are the reported repros.
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues -f title=x -f body=y"}}\tgh api is a raw write with no URL scheme at all\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE"}}\tand it reaches the endpoint the review wrapper owns\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"tea api -X POST repos/a/b/issues/1/comments -f body=x"}}\ttea api, same shape, different CLI\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues"}}\ta scheme-less host path is still an API write\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues -f title=x"}}\tgh POSTs implicitly when handed a field, exactly as curl does with -d\n'
|
||||||
|
# ...and the boundary that stops a broader scope gate becoming block-everything.
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api repos/a/b/pulls/1"}}\treading through a provider CLI stays untouched\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/releases -f tag_name=v1"}}\tno wrapper owns releases, whoever calls it\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"tea pulls create --title x --repo a/b"}}\tprovider PORCELAIN is out of scope by decision, not by accident\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"rm -f /var/tmp/api/v1-issues-notes.txt"}}\t-f is only a body when it carries key=value\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"grep -f patterns.txt /src/api/v1/repos/a/b/issues.log"}}\tsame, on the flag agents actually collide with\n'
|
||||||
|
|
||||||
|
# Wrong remediation is its own defect: /issues/1/labels used to block with
|
||||||
|
# "use issue-create.sh", which is not the wrapper for that call. Round seven
|
||||||
|
# answered that by letting EVERY path under a numbered issue or PR through,
|
||||||
|
# and review showed the reasoning ("no wrapper owns these") was false in this
|
||||||
|
# tree. These are the reported repros, all rc 0 before round eight, and each
|
||||||
|
# asserts the wrapper the advice must name — not merely that a block happened.
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f title=x"}}\tan issue edit is issue-edit.sh, not a wrapper gap\tissue-edit.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1"}}\tsame call through curl, same wrapper\tissue-edit.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1/labels -f labels[]=bug"}}\tlabels are wrapped, and the advice says by which\tissue-edit.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/assignees -f assignees[]=u"}}\tassignees are issue-assign.sh\tissue-assign.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues/1/assignees -f assignees[]=u"}}\tthe array field spelling is a body with no -X at all\tissue-assign.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/labels"}}\ta PR is an issue where labels live, so the issue wrapper owns them\tissue-edit.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\tPR state is pr-close.sh, and the gap in that arm is stated\tpr-close.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/4"}}\ta milestone state change is milestone-close.sh, not the create wrapper\tmilestone-close.sh\n'
|
||||||
|
# SPAN. A wrapper that owns a slice of an endpoint must not be advertised as
|
||||||
|
# owning the endpoint. milestone-close.sh takes only -t <title> and sends
|
||||||
|
# state=closed, so a title/description/due-date edit is a gap and the message
|
||||||
|
# has to say so — round eight named the wrapper and stopped there.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/1"}}\ta milestone edit blocks, but the advice states the close-only span\towns the CLOSE only\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f assignee=u"}}\tissue-edit.sh cannot set an assignee, so the message names the one that can\tissue-assign.sh owns the assignee\n'
|
||||||
|
# The residue: still genuinely owned by nothing, and still flowing through.
|
||||||
|
# Requesting a reviewer is not submitting one; pr-review.sh files verdicts and
|
||||||
|
# nothing in the tree adds a requested reviewer.
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/requested_reviewers -f reviewers[]=u"}}\tno wrapper requests a reviewer, so it is not refused with pr-review.sh\n'
|
||||||
|
# SPAN, applied to the guard's OWN fail-closed rule rather than to a wrapper.
|
||||||
|
# The scope gate admits three shapes; the unreadable-endpoint rule asked only
|
||||||
|
# for `https?://`, so a split endpoint in the other two was in scope to block,
|
||||||
|
# produced no readable endpoint, and fell through to allow. Same defect class
|
||||||
|
# as the milestone arm, one layer up. Each shape gets its own fixture, because
|
||||||
|
# a single one would have passed on the arm that already worked.
|
||||||
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x"}}\ta split endpoint in a provider-CLI api call is unreadable, not absent\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/issues/1/comm; q=ents; gh api -X POST ${p}${q} -f body=x"}}\tsame, comments\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/pulls/1/rev; q=iews; gh api -X POST ${p}${q} -f event=APPROVED"}}\tsame, and a verdict is the costliest one to lose\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; tea api -X POST ${p}${q} -f title=x"}}\tevery CLI the scope gate admits, not just gh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x git.example.invalid${p}${q}"}}\ta schemeless forge host with a split path is unreadable too\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"h=git.example.invalid; q=ues; curl -X POST -d x ${h}/api/v1/repos/a/b/iss${q}"}}\tthe expansion may come first; the token is what matters\n'
|
||||||
|
# And the reason this is not "any variable blocks a write": a payload in a
|
||||||
|
# variable is the SAFE way to pass one and leaves the endpoint fully legible.
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api repos/a/b/git/refs -f sha=$SHA"}}\tan expansion in a body value leaves the endpoint readable\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d \\"$BODY\\" https://git.example.invalid/api/v1/repos/a/b/git/refs"}}\tsame for a quoted body on an unwrapped endpoint\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api repos/${OWNER}/${REPO}/git/refs"}}\ta read with a split endpoint is still a read\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/comments/5"}}\tediting a comment has no wrapper; only creating one does\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/stopwatch/start"}}\tno wrapper owns a stopwatch, and none is invented for it\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/times -f time=60"}}\tnor time tracking\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=+1"}}\tnor reactions\n'
|
||||||
|
# ...and the residue must be decided by the SEGMENT, never by a stray slash.
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=see-/docs/x"}}\ta slash inside the body is not a subresource\tissue-edit.sh\n'
|
||||||
|
# The arms above the numbered ones must keep blocking, with their own wrappers.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe wrapped subresource must not fall through\tissue-comment.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues"}}\tnor may issue creation\tissue-create.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tPR creation is wrapped and must not fall through with them\tpr-create.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tand a review still names the review wrapper\tpr-review.sh\n'
|
||||||
|
|
||||||
|
# SPAN a third time, now in the scope gate itself: it asked for `/api/v[0-9]`,
|
||||||
|
# which is Gitea's spelling. GitHub's API carries no version segment at all
|
||||||
|
# (`api.github.com/repos/...`), so the schemeless Gitea write was in scope and
|
||||||
|
# the schemeless GitHub one was not — a gate calibrated to one dialect rather
|
||||||
|
# than to what identifies a provider API. `/repos/` is the marker both share.
|
||||||
|
# These endpoints are READABLE, so each asserts the wrapper it must name; a
|
||||||
|
# rc-only fixture here would pass on the unreadable arm and prove nothing.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues"}}\ta schemeless GitHub host is a provider API even with no version segment\tissue-create.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues/1/comments"}}\tsame, and the subresource still names its own wrapper\tissue-comment.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"host=api.github.com; curl -X POST -d x ${host}/repos/a/b/issues"}}\tthe host may be a variable; the path is what the guard reads\tissue-create.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/pulls/1/reviews"}}\ta verdict is the costliest call to lose to a spelling\tpr-review.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=/repos/a/b/iss; q=ues; curl -X POST -d x api.github.com${p}${q}"}}\tand the split form of it is unreadable, not absent\n'
|
||||||
|
# The end-of-options marker, which is the one option not spelled like one.
|
||||||
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST -- ${p}${q} -f title=x"}}\ta bare -- must not walk the endpoint past the scanner\n'
|
||||||
|
# Widening a scope gate may not create a block. Reads and unwrapped endpoints
|
||||||
|
# in the newly admitted shape have to stay allowed, or this is a regression
|
||||||
|
# wearing a fix'"'"'s clothes.
|
||||||
|
printf '0\t{"tool_input":{"command":"curl api.github.com/repos/a/b/issues"}}\tadmitting a shape to the gate does not make a read a write\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/git/refs"}}\tno wrapper owns git refs, on GitHub'"'"'s spelling either\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api -- repos/a/b/issues"}}\tthe marker in a read is still a read\n'
|
||||||
|
|
||||||
|
# The APPROVE trap, in the spelling a provider CLI uses, and the value that
|
||||||
|
# must never trip it.
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVED\\"} https://git.example.invalid/api/v1/repos/a/b/releases"}}\tAPPROVED is the correct value and is never the trap\n'
|
||||||
|
# Documented over-block, pinned so it is a known boundary and not a surprise.
|
||||||
|
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments .post(\\")'"'"'"}}\tprose carrying .post( near a wrapped URL is refused, by the same payload rule\n'
|
||||||
|
} > "$FIXTURES"
|
||||||
|
|
||||||
|
fail=0 n=0
|
||||||
|
while IFS=$'\t' read -r want payload why remedy; do
|
||||||
|
[ -n "${want:-}" ] || continue
|
||||||
|
n=$((n + 1))
|
||||||
|
out="$(printf '%s' "$payload" | "$GUARD" 2>&1)"
|
||||||
|
got=$?
|
||||||
|
if [ "$got" != "$want" ]; then
|
||||||
|
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
|
||||||
|
fail=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# A block that names the wrong wrapper is a defect in its own right, and until
|
||||||
|
# now it was invisible here: the harness read the exit code and nothing else,
|
||||||
|
# so /issues/1/labels blocking with "use issue-create.sh" passed every run for
|
||||||
|
# six rounds. Where a fixture states the remediation it expects, assert it.
|
||||||
|
if [ -n "${remedy:-}" ] && ! printf '%s' "$out" | grep -Fq -- "$remedy"; then
|
||||||
|
printf 'FAIL %s (blocked, but the advice does not name %s)\n' "$why" "$remedy"
|
||||||
|
fail=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
printf 'ok %s\n' "$why"
|
||||||
|
done < "$FIXTURES"
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if [ "$fail" -eq 0 ]; then
|
||||||
|
printf 'wrapper-guard: %d/%d fixtures behaved as specified.\n' "$n" "$n"
|
||||||
|
else
|
||||||
|
cat <<'EOF'
|
||||||
|
wrapper-guard drifted from its contract.
|
||||||
|
|
||||||
|
A guard that blocks too much gets routed around, and a guard that blocks too
|
||||||
|
little is decoration. Both directions are failures here, which is why the
|
||||||
|
allowed cases are asserted as hard as the blocked ones.
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
exit "$fail"
|
||||||
+484
@@ -0,0 +1,484 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# wrapper-guard.sh — PreToolUse hook on Bash.
|
||||||
|
#
|
||||||
|
# Blocks three specific, mechanically-detectable mistakes that prose has
|
||||||
|
# repeatedly failed to prevent:
|
||||||
|
#
|
||||||
|
# 1. A checkout (git clone / git worktree add) targeting $HOME.
|
||||||
|
# Root cause of a fleet host's /home filling to 100% — 255 GB, 842 dirs.
|
||||||
|
#
|
||||||
|
# 2. A raw provider API WRITE against an endpoint that already has a Mosaic
|
||||||
|
# wrapper. Constitution gate 7 requires the wrapper; the wrapper knows
|
||||||
|
# provider dialect, identity, and queue-guard ordering that raw curl does
|
||||||
|
# not. Reads are untouched — they are how you gather evidence.
|
||||||
|
#
|
||||||
|
# 3. The literal review event "APPROVE". Gitea's vocabulary is APPROVED;
|
||||||
|
# it accepts APPROVE with HTTP 200, silently files the review PENDING,
|
||||||
|
# and then 422s on submit. This one is unconditionally wrong on Gitea and
|
||||||
|
# is what a verdict silently failing to land looks like.
|
||||||
|
#
|
||||||
|
# Design constraint: this hook must not become something agents route around.
|
||||||
|
# It blocks WRITES to endpoints with a known wrapper, and nothing else. Raw
|
||||||
|
# curl for reads, for registry/manifest calls, and for endpoints with no
|
||||||
|
# wrapper (there are many) all pass untouched.
|
||||||
|
#
|
||||||
|
# One consequence is worth knowing before it surprises you: it judges the
|
||||||
|
# payload, not the caller, so a command that merely QUOTES such a write is
|
||||||
|
# refused as well. See the long note at section 2 for why that trade was made.
|
||||||
|
#
|
||||||
|
# Break-glass, for a genuine gap where no wrapper can express the call:
|
||||||
|
# MOSAIC_WRAPPER_OVERRIDE=1 <command>
|
||||||
|
# Using it means "no wrapper covers this" — if that is wrong, the fix is to
|
||||||
|
# extend the wrapper, not to keep typing the override.
|
||||||
|
#
|
||||||
|
# Exit codes (Claude Code PreToolUse): 0 = allow, 2 = block with message.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INPUT="$(cat)"
|
||||||
|
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
|
||||||
|
[ -z "$CMD" ] && exit 0
|
||||||
|
|
||||||
|
# Read the command the SHELL will run, not the text as typed. A backslash before
|
||||||
|
# a newline is removed before anything else happens, so
|
||||||
|
# curl -d@b https://host/api/v1/repos/a/b/iss\
|
||||||
|
# ues/1/comments
|
||||||
|
# executes the comments endpoint while the literal token `issues` never appears
|
||||||
|
# in the text. Every check below — position, URL, body, endpoint — reads the
|
||||||
|
# joined form, because that is the command.
|
||||||
|
CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
|
||||||
|
|
||||||
|
# Honour the override only when it is set in the command itself or the env.
|
||||||
|
case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac
|
||||||
|
[ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0
|
||||||
|
|
||||||
|
# The wrappers this guard points at are its own siblings. Resolving relative to
|
||||||
|
# this file — rather than to a hardcoded $HOME/.config/mosaic — means the guard
|
||||||
|
# names the wrappers from the same install it was launched from, and that it
|
||||||
|
# still works from a repo checkout with no installed mosaic home (which is how it
|
||||||
|
# is exercised in CI). $HOME remains the fallback for a guard invoked by an
|
||||||
|
# absolute path from somewhere unusual.
|
||||||
|
W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
[ -x "$W/pr-review.sh" ] || W="$HOME/.config/mosaic/tools/git"
|
||||||
|
|
||||||
|
# ---- 1. checkout into $HOME ------------------------------------------------
|
||||||
|
if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then
|
||||||
|
# Any argument that resolves under $HOME and is not under a work filesystem.
|
||||||
|
if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])(~|\\\$HOME|$HOME)/"; then
|
||||||
|
cat <<EOF
|
||||||
|
BLOCKED: this checks a repository out under \$HOME.
|
||||||
|
|
||||||
|
\$HOME holds configuration, credentials, state and caches. It does not hold
|
||||||
|
checkouts, worktrees, scratch files, or build output. One fleet host's /home hit
|
||||||
|
100% (394 G) with 255 GB of agent workspaces accumulated exactly this way.
|
||||||
|
|
||||||
|
Use the helper, which derives the path so you do not have to choose one:
|
||||||
|
|
||||||
|
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> # /src/<repo>-worktrees/<slug>
|
||||||
|
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # show where it would go
|
||||||
|
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
|
||||||
|
|
||||||
|
Worktrees, not clones: they share the object store, and \`git worktree list\`
|
||||||
|
makes every one of them enumerable — which is the only reason cleanup can
|
||||||
|
ever be safe.
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 2/3. provider API writes ---------------------------------------------
|
||||||
|
# A raw provider write this guard cares about is two things: a WRITE, and a URL
|
||||||
|
# naming an endpoint a Mosaic wrapper already owns. Reads are untouched — they
|
||||||
|
# are how you gather evidence — and the many endpoints with no wrapper flow
|
||||||
|
# through.
|
||||||
|
#
|
||||||
|
# It deliberately does NOT ask which program makes the call, or whether that
|
||||||
|
# program sits at shell command position. It used to, and that is the whole
|
||||||
|
# history of this file. Answering "is this code or is this data" from the text
|
||||||
|
# of a shell command required a skeleton with quoted spans and heredoc bodies
|
||||||
|
# removed, an invoker list for the forms where a shell executes quoted text, a
|
||||||
|
# prefix list for `env`/`sudo`/`timeout`, option-value skipping, and
|
||||||
|
# backslash-newline joining. Five rounds of adversarial review put nineteen
|
||||||
|
# writes straight through it, and every one had the same shape: the client was
|
||||||
|
# ABSENT from the skeleton, so the guard allowed. Variables, line continuations,
|
||||||
|
# command prefixes, option values, pipes into a shell, and finally command
|
||||||
|
# substitution inside the very quotes the skeleton was discarding:
|
||||||
|
# echo "$(curl -d@b .../issues/1/comments)"
|
||||||
|
# msg="$(curl -d@b .../issues/1/comments)"
|
||||||
|
# Classifying code against data in shell text with sed and awk is not a hard
|
||||||
|
# problem, it is the wrong problem. It was not even portable: under CI's busybox
|
||||||
|
# awk the quote-stripping silently failed, the skeleton kept every quoted span,
|
||||||
|
# and the guard started refusing ordinary prose instead — which is the other way
|
||||||
|
# a control like this dies.
|
||||||
|
#
|
||||||
|
# So the client detection is gone, and with it that entire failure class: what
|
||||||
|
# is left cannot fail open by hiding the caller, because it never looks for one.
|
||||||
|
# It looks for the payload. Something that names a wrapped endpoint and carries
|
||||||
|
# a body is refused however it is spelled — curl, wget, `python -c`, or a form
|
||||||
|
# nobody has thought of yet.
|
||||||
|
#
|
||||||
|
# The cost is real and belongs in the open, because over-blocking is how a hook
|
||||||
|
# gets switched off: QUOTING one of these calls on a Bash command line now
|
||||||
|
# blocks too. `grep -R "curl -d .../issues" docs/` is refused, and so is echoing
|
||||||
|
# an example into a file. There is no textual way to tell a quoted example from
|
||||||
|
# a quoted command — that is exactly the finding above — so the rule is the one
|
||||||
|
# an agent can hold in mind without a parser:
|
||||||
|
#
|
||||||
|
# do not put a raw write to a wrapped forge endpoint on a Bash command line,
|
||||||
|
# not even inside quotes.
|
||||||
|
#
|
||||||
|
# Write the example with a file-writing tool, or leave the body flag out of it.
|
||||||
|
# That is a deliberate narrowing of scope, not an oversight. This hook stops
|
||||||
|
# mistakes; it is not a sandbox, and pretending otherwise is how you get a
|
||||||
|
# control nobody can trust the boundaries of.
|
||||||
|
#
|
||||||
|
# Scoped to commands that are provider-API-shaped, so nothing else is even
|
||||||
|
# considered. The first version of this scope gate asked only for `https?://`,
|
||||||
|
# and review found the absence shape had simply moved to the new boundary:
|
||||||
|
# gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE
|
||||||
|
# tea api -X POST repos/a/b/issues/1/comments -f body=x
|
||||||
|
# curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues
|
||||||
|
# all carry a real write to a wrapped endpoint and none carries a scheme, so the
|
||||||
|
# guard never asked the write question at all. Gate 7 covers raw provider CLIs,
|
||||||
|
# so these are in scope and the gate now names the shapes they come in.
|
||||||
|
#
|
||||||
|
# Then review found the same absence at the same boundary a second time, in the
|
||||||
|
# one provider whose paths carry no version marker at all:
|
||||||
|
# curl -X POST -d x api.github.com/repos/a/b/issues
|
||||||
|
# GitHub's API is `api.github.com/repos/...`; Gitea's is `/api/v1/repos/...`.
|
||||||
|
# Asking for `/api/v[0-9]` therefore admitted the schemeless Gitea write and
|
||||||
|
# excluded the schemeless GitHub one — a gate calibrated to one dialect's
|
||||||
|
# spelling rather than to what identifies a provider API. So the gate names both
|
||||||
|
# markers: a version segment, and the `/repos/` path that every forge API uses
|
||||||
|
# to address a repository.
|
||||||
|
#
|
||||||
|
# Adding alternatives to a scope gate can only make it stricter — it cannot
|
||||||
|
# create a new allow — which is why this is a list of triggers rather than a
|
||||||
|
# model of any one caller. Downstream, a block still requires a body flag AND
|
||||||
|
# either a mapped endpoint or an unreadable one, so widening the gate widens
|
||||||
|
# what is CONSIDERED, not what is refused.
|
||||||
|
#
|
||||||
|
# Residual, stated rather than implied: a caller who splits `/repos/` itself in
|
||||||
|
# a schemeless GitHub URL (`h=api.github.com/rep; q=os/a/b/issues`) leaves no
|
||||||
|
# literal marker anywhere and is out of scope. That is the same boundary as
|
||||||
|
# splitting the hostname — no longer a mistake anyone makes by accident.
|
||||||
|
#
|
||||||
|
# Boundary, deliberate and worth stating: this covers the `api` subcommand,
|
||||||
|
# which is a raw API call wearing a CLI. Provider PORCELAIN (`tea pulls create`,
|
||||||
|
# `gh pr merge`) is NOT covered — catching that means modelling every CLI's verb
|
||||||
|
# grammar, which is the parser mistake again in a new costume. Porcelain is a
|
||||||
|
# gate-7 gap for prose and review to hold, not this hook.
|
||||||
|
API_SHAPED='https?://|/api/v[0-9]|/repos/'
|
||||||
|
API_SHAPED="$API_SHAPED"'|(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)'
|
||||||
|
if printf '%s' "$CMD" | grep -Eq "$API_SHAPED"; then
|
||||||
|
|
||||||
|
# Write detection, now client-agnostic. Every spelling curl accepts, because
|
||||||
|
# the guard is defeated by the one spelling it does not know: `-d@body` (no
|
||||||
|
# space) and `--request=POST` (equals form) both slipped past the first
|
||||||
|
# version. Plus wget's forms and a library call, which a client-shaped test
|
||||||
|
# could not have seen at all.
|
||||||
|
#
|
||||||
|
# A body flag is read as a body flag wherever it appears. `ls -d */ && curl -s
|
||||||
|
# .../issues/1/comments` is therefore refused, which is a read wearing a
|
||||||
|
# write's flag. That direction is the acceptable one: it costs an override on
|
||||||
|
# a rare command, where the reverse costs a silent raw write.
|
||||||
|
is_write=0
|
||||||
|
printf '%s' "$CMD" | grep -Eq -- \
|
||||||
|
'-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--(request|method)[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1
|
||||||
|
# curl sends POST implicitly when handed a body, in any of these forms.
|
||||||
|
printf '%s' "$CMD" | grep -Eq -- \
|
||||||
|
'(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file|--post-(data|file)[[:space:]=]' && is_write=1
|
||||||
|
# The provider CLIs POST implicitly the same way curl does, when handed a
|
||||||
|
# field. Matched only in `-f key=value` shape, so the far more common `rm -f`
|
||||||
|
# and `grep -f` cannot be read as a body. The trailing `[]` is the array
|
||||||
|
# spelling the provider CLIs use for repeated fields (`-f labels[]=bug`), and
|
||||||
|
# without it the key class stopped at the bracket and the field was not seen
|
||||||
|
# as a body at all — found while pinning the labels/assignees repros, both of
|
||||||
|
# which carry it.
|
||||||
|
printf '%s' "$CMD" | grep -Eq -- \
|
||||||
|
'(^|[[:space:]])(-f|--field|--raw-field)[[:space:]]+[A-Za-z_][A-Za-z0-9_.-]*(\[\])?=|--input[[:space:]=]' && is_write=1
|
||||||
|
# ...and a library call is a write without any flag at all.
|
||||||
|
#
|
||||||
|
# Second documented over-block, and broader than the body flags because it
|
||||||
|
# needs no flag: any text carrying `.post(` near a wrapped URL is refused,
|
||||||
|
# including prose that merely quotes it. That follows from the same rule as
|
||||||
|
# the quoted-curl cost above — the payload is judged, not the caller — and it
|
||||||
|
# is stated here so it is a known boundary rather than a surprise.
|
||||||
|
printf '%s' "$CMD" | grep -Eq -- \
|
||||||
|
'\.(post|put|patch|delete)\(' && is_write=1
|
||||||
|
|
||||||
|
if [ "$is_write" -eq 1 ]; then
|
||||||
|
# The endpoint map, and the rule that keeps it honest: an arm exists here
|
||||||
|
# ONLY because a wrapper in this directory owns that call. It is an
|
||||||
|
# inventory, not a model — read off `ls tools/git/*.sh` and the flags each
|
||||||
|
# script accepts, so it can be re-derived and checked rather than believed.
|
||||||
|
# Second column is what the wrapper SPANS; a partial span must be stated in
|
||||||
|
# the block message, never rounded up to ownership of the whole endpoint.
|
||||||
|
#
|
||||||
|
# /pulls/{n}/reviews pr-review.sh full
|
||||||
|
# /pulls/{n}/merge pr-merge.sh full (-m method, -d)
|
||||||
|
# /issues/{n}/comments issue-comment.sh create only (POST)
|
||||||
|
# /issues|pulls/{n}/assignees issue-assign.sh full (-a, -r)
|
||||||
|
# /issues|pulls/{n}/labels issue-edit.sh sets the whole list;
|
||||||
|
# issue-assign.sh -l same
|
||||||
|
# /milestones/{n} milestone-close.sh CLOSE ONLY — title,
|
||||||
|
# description, due date
|
||||||
|
# are a wrapper gap
|
||||||
|
# /issues/{n} issue-edit.sh title/body/labels/
|
||||||
|
# milestone; close/reopen
|
||||||
|
# for state; assignee is
|
||||||
|
# issue-assign.sh
|
||||||
|
# /pulls/{n} pr-close.sh state only — title and
|
||||||
|
# body are a wrapper gap
|
||||||
|
# /pulls, /issues, the create wrappers full
|
||||||
|
# /milestones
|
||||||
|
#
|
||||||
|
# Owned by nothing, so they flow through: /issues/comments/{id} (a comment
|
||||||
|
# EDIT), /pulls/{n}/requested_reviewers, and the residue caught by
|
||||||
|
# subtraction below.
|
||||||
|
#
|
||||||
|
# Round seven had a single arm allowing EVERY path under a numbered issue or
|
||||||
|
# PR, on the reasoning that no wrapper owned any of them. Review showed that
|
||||||
|
# was false in this tree — issue-edit.sh takes --title/--body/--labels/
|
||||||
|
# --milestone and issue-assign.sh takes assignee/labels/milestone — so the
|
||||||
|
# guard was answering "allow" because wrapper ownership had been ASSUMED
|
||||||
|
# absent instead of looked up. That is the same absence-driven allow the
|
||||||
|
# whole file exists to remove, committed inside the fix for it. The lesson
|
||||||
|
# is not "block more"; it is that ownership is an inventory question and an
|
||||||
|
# inventory has to be read.
|
||||||
|
#
|
||||||
|
# Wrong advice remains its own defect — a block an agent cannot comply with
|
||||||
|
# teaches that the hook is broken and the override is routine, and a routine
|
||||||
|
# override is a guard that is off. So the fix is precision in BOTH
|
||||||
|
# directions: every arm names the wrapper that actually owns the call, and
|
||||||
|
# anything genuinely unowned still flows through (below).
|
||||||
|
#
|
||||||
|
# Round eight got the inventory right and the SPAN wrong, which review caught
|
||||||
|
# on /milestones/{n}: milestone-close.sh takes only -t <title> and hardcodes
|
||||||
|
# state=closed, so it cannot express a title, description or due-date edit,
|
||||||
|
# and naming it there told an agent to use a wrapper that cannot make the
|
||||||
|
# call. "Which wrapper touches this endpoint" is the wrong question; "does
|
||||||
|
# the wrapper SPAN this endpoint" is the right one. Where a wrapper owns only
|
||||||
|
# a slice, `alsoown` must say which slice and name the rest as a gap — the
|
||||||
|
# treatment /pulls/{n} already had, and that two other arms did not, so this
|
||||||
|
# was a consistency failure rather than a missing idea. Auditing every arm
|
||||||
|
# for span (not just the reported one) is what found requested_reviewers.
|
||||||
|
endpoint=""; wrapper=""; alsoown=""
|
||||||
|
case "$CMD" in
|
||||||
|
# Requesting a reviewer is not submitting one. pr-review.sh takes
|
||||||
|
# -a <action> -c <comment> and files a verdict; nothing in the tree adds a
|
||||||
|
# requested reviewer. Unowned, so it flows through — placed above the
|
||||||
|
# reviews arm so it cannot be refused with "use pr-review.sh".
|
||||||
|
*"/pulls/"*"/requested_reviewers"*) : ;;
|
||||||
|
*"/pulls/"*"/reviews"*) endpoint="pull-request review"; wrapper="pr-review.sh" ;;
|
||||||
|
*"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;;
|
||||||
|
# A comment EDIT/DELETE lives at /issues/comments/{id} — a sibling of the
|
||||||
|
# numbered issue, not a child of it. issue-comment.sh only creates, so
|
||||||
|
# nothing owns this one. Placed above the create arm so it cannot be
|
||||||
|
# refused with "use issue-comment.sh", which would be the wrong call.
|
||||||
|
*"/issues/comments/"*) : ;;
|
||||||
|
*"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="issue-comment.sh" ;;
|
||||||
|
*"/issues/"*"/assignees"*|*"/pulls/"*"/assignees"*)
|
||||||
|
endpoint="issue assignee"; wrapper="issue-assign.sh" ;;
|
||||||
|
*"/issues/"*"/labels"*|*"/pulls/"*"/labels"*)
|
||||||
|
endpoint="issue label"; wrapper="issue-edit.sh"
|
||||||
|
alsoown="issue-assign.sh -l sets labels too (and the milestone)." ;;
|
||||||
|
*"/milestones/"[0-9]*) endpoint="milestone"; wrapper="milestone-close.sh"
|
||||||
|
alsoown="milestone-close.sh owns the CLOSE only — it takes -t <title>
|
||||||
|
and sends state=closed. A milestone's title, description or due date is a real
|
||||||
|
wrapper gap: no tool in this tree edits them, and the override exists for it." ;;
|
||||||
|
# The numbered object itself. These two arms are the fuzzy ones — they
|
||||||
|
# match a number and then anything — so they are refined immediately
|
||||||
|
# below rather than trusted as written.
|
||||||
|
*"/issues/"[0-9]*) endpoint="issue edit"; wrapper="issue-edit.sh"
|
||||||
|
alsoown="issue-close.sh and issue-reopen.sh own the state change, and
|
||||||
|
issue-assign.sh owns the assignee, labels and milestone fields at this same
|
||||||
|
number — issue-edit.sh does not set an assignee." ;;
|
||||||
|
*"/pulls/"[0-9]*) endpoint="pull-request edit"; wrapper="pr-close.sh"
|
||||||
|
alsoown="pr-close.sh owns state=closed. A PR's labels, assignee and
|
||||||
|
milestone are the ISSUE object on both providers, so issue-edit.sh and
|
||||||
|
issue-assign.sh own those at the same number. Nothing wraps a PR title/body
|
||||||
|
edit — that one is a real wrapper gap, and the override exists for it." ;;
|
||||||
|
*"/pulls"*) endpoint="pull request"; wrapper="pr-create.sh" ;;
|
||||||
|
*"/issues"*) endpoint="issue"; wrapper="issue-create.sh" ;;
|
||||||
|
*"/milestones"*) endpoint="milestone"; wrapper="milestone-create.sh" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Refine the two fuzzy arms, and note WHY this is a regex and not another
|
||||||
|
# case arm: `case` globs cannot express a path SEGMENT, so an allow arm
|
||||||
|
# written as *"/issues/"[0-9]*"/"* would clear
|
||||||
|
# gh api -X PATCH repos/a/b/issues/1 -f body="see /docs"
|
||||||
|
# on the strength of a slash inside the body. An allow decided by a glob
|
||||||
|
# over the whole command is exactly the fail-open shape this file keeps
|
||||||
|
# finding; the regex pins the segment to the number.
|
||||||
|
#
|
||||||
|
# The residue is defined by SUBTRACTION rather than by listing provider API
|
||||||
|
# surface: every subresource a wrapper owns was consumed by an arm above, so
|
||||||
|
# whatever still carries /issues|pulls/{n}/<segment> here is owned by
|
||||||
|
# nothing — times, stopwatch, reactions, subscriptions, dependencies, a PR's
|
||||||
|
# files or commits. Listing them instead would rot the moment a provider
|
||||||
|
# adds one, and rot in the blocking direction with wrong advice.
|
||||||
|
case "$endpoint" in
|
||||||
|
"issue edit"|"pull-request edit")
|
||||||
|
if printf '%s' "$CMD" | grep -Eq '/(issues|pulls)/[0-9]+/[A-Za-z_]'; then
|
||||||
|
endpoint=""; wrapper=""; alsoown=""
|
||||||
|
fi ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# An endpoint the guard cannot READ is an endpoint the guard must not CLEAR.
|
||||||
|
#
|
||||||
|
# Round one fixed one spelling of this and review immediately produced the
|
||||||
|
# general form: split the endpoint token itself across two variables —
|
||||||
|
# a=/api/v1/repos/o/r/iss; b=ues/1/comments
|
||||||
|
# curl -d@body "https://host${a}${b}"
|
||||||
|
# — and no fragment above ever appears contiguously. Chasing that with more
|
||||||
|
# fragments is unwinnable: the endpoint does not exist until the shell
|
||||||
|
# expands it, and this hook runs before that.
|
||||||
|
#
|
||||||
|
# So stop pretending to read it. If a write's endpoint contains an expansion,
|
||||||
|
# the guard has no endpoint to judge, and "no endpoint" must not mean
|
||||||
|
# "allowed" — that is the same absence-driven allow as the missing-wrapper
|
||||||
|
# case, wearing different clothes.
|
||||||
|
#
|
||||||
|
# SPAN, and the defect review found here: a fail-closed rule must cover the
|
||||||
|
# same surface as the block it guards. This test asked only for `https?://`
|
||||||
|
# while the scope gate above had already been widened to three shapes, so
|
||||||
|
# p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x
|
||||||
|
# p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x git.example.invalid${p}${q}
|
||||||
|
# were in scope to be blocked, produced no readable endpoint, and then fell
|
||||||
|
# through to ALLOW — while the identical split behind a literal `https://`
|
||||||
|
# blocked. Same shape as the milestone arm one round earlier: the correct
|
||||||
|
# treatment already existed and was applied to one of the surfaces it
|
||||||
|
# covered. A control is only as wide as its narrowest arm.
|
||||||
|
#
|
||||||
|
# Three arms, one per shape the scope gate admits:
|
||||||
|
# A a scheme-bearing URL token carrying an expansion
|
||||||
|
# B a schemeless token carrying BOTH a forge fragment and an expansion
|
||||||
|
# C the endpoint argument of a provider-CLI `api` call carrying one
|
||||||
|
#
|
||||||
|
# Stated limits, because a control may not claim more than it measures. B
|
||||||
|
# requires the fragment and the expansion in the SAME shell token, so a
|
||||||
|
# caller who splits the hostname and `/api/` as well gets through. C reads
|
||||||
|
# the endpoint positionally — the first bare token after `api` and its option
|
||||||
|
# run — so an endpoint pushed past an option whose value itself contains
|
||||||
|
# whitespace is not seen. Both are deliberate: this hook stops mistakes, it
|
||||||
|
# is not a sandbox, and pretending otherwise is how you get a control nobody
|
||||||
|
# can trust the boundaries of.
|
||||||
|
#
|
||||||
|
# C's option run also accepts the bare `--` end-of-options marker, because
|
||||||
|
# review found that `gh api -X POST -- ${p}${q} -f title=x` walked straight
|
||||||
|
# past an option class that required a letter after the dashes. The marker is
|
||||||
|
# the one "option" that is not spelled like one, and a scanner that skips
|
||||||
|
# options had to be told that.
|
||||||
|
#
|
||||||
|
# Note what is NOT unreadable: an expansion in a BODY (`-d "$BODY"`,
|
||||||
|
# `-f sha=$SHA`) leaves the endpoint perfectly legible, and blocking it would
|
||||||
|
# punish the safest way to pass a payload. Only the endpoint region counts.
|
||||||
|
URLTOK='[^[:space:]"'"'"'|;&)]*'
|
||||||
|
FORGE='(/api/v[0-9]|/repos/|git\.|gitea|github\.com|gitlab|forgejo)'
|
||||||
|
unreadable=0
|
||||||
|
if printf '%s' "$CMD" | grep -Eq "https?://$URLTOK"'[$`]' \
|
||||||
|
&& printf '%s' "$CMD" | grep -Eq "$FORGE"; then unreadable=1; fi
|
||||||
|
printf '%s' "$CMD" | grep -Eq \
|
||||||
|
"$URLTOK($FORGE$URLTOK"'[$`]'"|"'[$`]'"$URLTOK$FORGE)" && unreadable=1
|
||||||
|
printf '%s' "$CMD" | grep -Eq \
|
||||||
|
'(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]' \
|
||||||
|
&& unreadable=1
|
||||||
|
|
||||||
|
if [ -z "$endpoint" ] && [ "$unreadable" -eq 1 ]; then
|
||||||
|
cat <<EOF
|
||||||
|
BLOCKED: raw provider API write whose endpoint this guard cannot read.
|
||||||
|
|
||||||
|
The endpoint is assembled from shell expansions, so the path it names does not
|
||||||
|
exist until the shell builds it — after this check runs. The guard cannot tell
|
||||||
|
whether it is a wrapped endpoint, and an unreadable endpoint is not a cleared
|
||||||
|
one.
|
||||||
|
|
||||||
|
$W/ <- the wrappers; use the one for the endpoint you are calling
|
||||||
|
|
||||||
|
If you are calling a wrapped endpoint (reviews, merges, comments, pulls,
|
||||||
|
issues, milestones), use the wrapper — it also resolves identity explicitly,
|
||||||
|
which matters on a host whose default provider login is an admin account.
|
||||||
|
|
||||||
|
If this is genuinely not a provider endpoint, either write the endpoint
|
||||||
|
literally so the guard can see what it is, or prefix MOSAIC_WRAPPER_OVERRIDE=1.
|
||||||
|
A variable in the BODY is fine and does not trigger this; only the endpoint
|
||||||
|
itself has to be legible.
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Block on the ENDPOINT, never on whether the wrapper file happens to exist.
|
||||||
|
# The previous version required `[ -x "$W/$wrapper" ]`, which meant a host
|
||||||
|
# with a broken or absent install allowed exactly the raw writes the guard
|
||||||
|
# exists to stop — an absence-driven allow, and the second one found in this
|
||||||
|
# file. A missing wrapper is a broken install; it is not a licence to bypass
|
||||||
|
# gate 7. Say so, and say which is which.
|
||||||
|
if [ -n "$endpoint" ]; then
|
||||||
|
if [ -x "$W/$wrapper" ]; then
|
||||||
|
remedy="Use the wrapper the Constitution (gate 7) requires:
|
||||||
|
|
||||||
|
$W/$wrapper
|
||||||
|
|
||||||
|
Run \`$wrapper --help\` for the flags."
|
||||||
|
# Several wrappers can own one endpoint (labels are settable from both
|
||||||
|
# issue-edit.sh and issue-assign.sh; state has its own pair). Naming
|
||||||
|
# only one of them is how a correct block still ends up reading as
|
||||||
|
# wrong advice, so say which wrapper owns which part of the call.
|
||||||
|
[ -n "$alsoown" ] && remedy="$remedy
|
||||||
|
|
||||||
|
$alsoown"
|
||||||
|
else
|
||||||
|
remedy="The wrapper that covers this endpoint is \`$wrapper\`, and it is NOT
|
||||||
|
present or not executable at:
|
||||||
|
|
||||||
|
$W/$wrapper
|
||||||
|
|
||||||
|
That is a broken or incomplete install, not permission to send the call raw.
|
||||||
|
Repair the install (\`mosaic doctor\`) and use the wrapper."
|
||||||
|
[ -n "$alsoown" ] && remedy="$remedy
|
||||||
|
|
||||||
|
$alsoown"
|
||||||
|
fi
|
||||||
|
cat <<EOF
|
||||||
|
BLOCKED: raw provider API write to the $endpoint endpoint.
|
||||||
|
|
||||||
|
$remedy
|
||||||
|
|
||||||
|
The wrappers are not a formality. They carry provider-dialect differences that
|
||||||
|
raw curl silently gets wrong — Gitea's review event is APPROVED, GitHub's is
|
||||||
|
APPROVE, and Gitea accepts the wrong one with HTTP 200 while filing the review
|
||||||
|
as PENDING. They also resolve identity explicitly, which matters on a host
|
||||||
|
where the default login is an admin account.
|
||||||
|
|
||||||
|
If no wrapper flag can express this call, that is a wrapper gap: extend the
|
||||||
|
wrapper. To proceed anyway for a genuine gap, prefix MOSAIC_WRAPPER_OVERRIDE=1.
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 3. the APPROVE/APPROVED trap, wherever it appears ---------------------
|
||||||
|
# Both spellings the trap arrives in: the JSON body `"event": "APPROVE"` and the
|
||||||
|
# provider-CLI field `-f event=APPROVE`. The trailing [^A-Z] is what keeps the
|
||||||
|
# correct value out of it — APPROVED must never match.
|
||||||
|
if printf '%s' "$CMD" | grep -Eq 'event"?[[:space:]]*[=:][[:space:]]*"?APPROVE([^A-Z]|$)'; then
|
||||||
|
cat <<EOF
|
||||||
|
BLOCKED: review event "APPROVE" is not valid on Gitea.
|
||||||
|
|
||||||
|
Gitea's vocabulary is "APPROVED". It accepts "APPROVE" with HTTP 200, silently
|
||||||
|
files the review as PENDING, and then fails the submit endpoint with
|
||||||
|
422 "review stay pending" — so the verdict looks placed and is not.
|
||||||
|
|
||||||
|
("REQUEST_CHANGES" is spelled identically on both providers; only the approve
|
||||||
|
path carries this trap.)
|
||||||
|
|
||||||
|
Use $W/pr-review.sh, which sends the correct token for the detected provider.
|
||||||
|
Whatever you use, re-read GET /pulls/{n}/reviews and assert state==APPROVED
|
||||||
|
before reporting a verdict placed.
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check-tools-index.sh — assert every shipped tool is discoverable from the
|
||||||
|
# resident documentation an agent actually has in context.
|
||||||
|
#
|
||||||
|
# WHY THIS GATE EXISTS
|
||||||
|
# --------------------
|
||||||
|
# The framework ships 26 git wrappers. Before this gate, 20 of them were named
|
||||||
|
# in neither `defaults/TOOLS.md` nor `guides/TOOLS-REFERENCE.md`. One of the
|
||||||
|
# undocumented ones was `pr-review.sh` — the wrapper that carries the
|
||||||
|
# APPROVED/APPROVE provider-dialect split.
|
||||||
|
#
|
||||||
|
# The observable consequence, on a live fleet host: an agent needing to place a
|
||||||
|
# review verdict reached for raw `curl`, sent GitHub's `APPROVE` to a Gitea
|
||||||
|
# host, and got HTTP 200 with the review silently filed PENDING — three times,
|
||||||
|
# because nothing about the failure pointed at the wrapper that already handled
|
||||||
|
# it correctly. The agent was not ignoring Constitution gate 7. It was obeying
|
||||||
|
# an index that said the tool did not exist.
|
||||||
|
#
|
||||||
|
# That is not a discipline problem and no amount of prose fixes it. A wrapper
|
||||||
|
# that is not in the resident index is, from inside a session, indistinguishable
|
||||||
|
# from a wrapper that was never written. So the invariant is mechanical:
|
||||||
|
#
|
||||||
|
# shipping a tool and documenting it are the same commit, or CI fails.
|
||||||
|
#
|
||||||
|
# WHAT IT CHECKS
|
||||||
|
# --------------
|
||||||
|
# forward every non-excluded tool in an ENFORCED suite is named in at least
|
||||||
|
# one index document (missing tool -> undiscoverable -> FAIL)
|
||||||
|
# reverse every `<name>.sh` an index document attributes to an enforced
|
||||||
|
# suite exists on disk (stale reference -> agent runs a ghost -> FAIL)
|
||||||
|
#
|
||||||
|
# Suites outside the enforced set are reported with a coverage percentage but do
|
||||||
|
# not fail the build, so the ratchet can be tightened one suite per PR instead of
|
||||||
|
# landing as one unreviewable sweep. `--strict` fails on those too.
|
||||||
|
#
|
||||||
|
# WHY THE ENFORCED LIST LIVES HERE AND NOT IN A MARKER INSIDE THE DOC
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# `TOOLS.md` is operator-owned (see framework-manifest.txt). A marker inside it
|
||||||
|
# would let an operator silence this gate by editing their own copy — the gate
|
||||||
|
# would then be strongest exactly where it is least needed and absent where it
|
||||||
|
# is needed most. The list is framework-owned and changes only through a
|
||||||
|
# reviewed PR.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# check-tools-index.sh [--tools-dir DIR] [--doc FILE]... [--strict] [--self-test]
|
||||||
|
#
|
||||||
|
# Exit: 0 = every enforced suite fully discoverable · 1 = drift · 2 = bad usage
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Suites whose coverage is a HARD requirement. Add a suite here only together
|
||||||
|
# with the doc changes that make it pass.
|
||||||
|
#
|
||||||
|
# `git` is first because it is the suite Constitution gates 6-8 make mandatory:
|
||||||
|
# an undiscoverable git wrapper converts a hard gate into a coin flip.
|
||||||
|
ENFORCED_SUITES=(git)
|
||||||
|
|
||||||
|
# Files that are not agent-callable tools and must not be required in an index.
|
||||||
|
EXCLUDE_GLOBS=(
|
||||||
|
'test-*' # hermetic regression scripts, invoked by CI not by agents
|
||||||
|
'_*' # private helpers (_lib, _scripts internals)
|
||||||
|
'*.bak' # editor/installer debris
|
||||||
|
'*.pre-*' # pre-change backups (e.g. ci-queue-wait.sh.pre-404fix-bak)
|
||||||
|
'README.md'
|
||||||
|
)
|
||||||
|
|
||||||
|
STRICT=0
|
||||||
|
SELF_TEST=0
|
||||||
|
TOOLS_DIR=""
|
||||||
|
DOCS=()
|
||||||
|
|
||||||
|
die() { printf 'check-tools-index: %s\n' "$*" >&2; exit 2; }
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--tools-dir) TOOLS_DIR="${2:-}"; shift 2 ;;
|
||||||
|
--doc) DOCS+=("${2:-}"); shift 2 ;;
|
||||||
|
--strict) STRICT=1; shift ;;
|
||||||
|
--self-test) SELF_TEST=1; shift ;;
|
||||||
|
-h|--help) sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
*) die "unknown argument: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- location resolution ---------------------------------------------------
|
||||||
|
# Runs from two places with different layouts, and must not silently check the
|
||||||
|
# wrong tree: a CI checkout (repo-relative) and an installed host ($MOSAIC_HOME).
|
||||||
|
resolve_locations() {
|
||||||
|
local here framework
|
||||||
|
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# .../framework/tools/quality/scripts -> .../framework
|
||||||
|
framework="$(cd -- "$here/../../.." && pwd)"
|
||||||
|
|
||||||
|
if [ -z "$TOOLS_DIR" ]; then
|
||||||
|
if [ -d "$framework/tools" ]; then
|
||||||
|
TOOLS_DIR="$framework/tools"
|
||||||
|
else
|
||||||
|
TOOLS_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}/tools"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#DOCS[@]} -eq 0 ]; then
|
||||||
|
# The two layouts are mutually exclusive on purpose. Unioning them would let
|
||||||
|
# a well-maintained operator TOOLS.md on the developer's own machine mask a
|
||||||
|
# gap in the shipped defaults — the check would pass locally and the defect
|
||||||
|
# would still install on every other host. Repo layout wins when present.
|
||||||
|
if [ -f "$framework/defaults/TOOLS.md" ]; then
|
||||||
|
DOCS+=("$framework/defaults/TOOLS.md")
|
||||||
|
[ -f "$framework/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$framework/guides/TOOLS-REFERENCE.md")
|
||||||
|
else
|
||||||
|
local mosaic_home="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||||
|
[ -f "$mosaic_home/TOOLS.md" ] && DOCS+=("$mosaic_home/TOOLS.md")
|
||||||
|
[ -f "$mosaic_home/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$mosaic_home/guides/TOOLS-REFERENCE.md")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -d "$TOOLS_DIR" ] || die "tools dir not found: $TOOLS_DIR"
|
||||||
|
[ ${#DOCS[@]} -gt 0 ] || die "no index documents found (pass --doc FILE)"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_excluded() {
|
||||||
|
local name="$1" glob
|
||||||
|
for glob in "${EXCLUDE_GLOBS[@]}"; do
|
||||||
|
# shellcheck disable=SC2254 # glob is intentionally a pattern
|
||||||
|
case "$name" in $glob) return 0 ;; esac
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# A tool counts as documented when its basename appears anywhere in the corpus.
|
||||||
|
# Deliberately permissive about *form* (table cell, code fence, prose) and strict
|
||||||
|
# about *presence*: the gate's job is "an agent can find it", not house style.
|
||||||
|
documented() { grep -qF -- "$1" "$CORPUS"; }
|
||||||
|
|
||||||
|
# ---- the check -------------------------------------------------------------
|
||||||
|
run_check() {
|
||||||
|
local rc=0 suite dir tool base enforced
|
||||||
|
|
||||||
|
CORPUS="$(mktemp)"; trap 'rm -f "$CORPUS"' RETURN
|
||||||
|
cat "${DOCS[@]}" > "$CORPUS"
|
||||||
|
|
||||||
|
printf 'tools: %s\n' "$TOOLS_DIR"
|
||||||
|
for d in "${DOCS[@]}"; do printf 'index: %s\n' "$d"; done
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
for dir in "$TOOLS_DIR"/*/; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
suite="$(basename -- "$dir")"
|
||||||
|
case " ${ENFORCED_SUITES[*]} " in *" $suite "*) enforced=1 ;; *) enforced=0 ;; esac
|
||||||
|
[ "$STRICT" -eq 1 ] && enforced=1
|
||||||
|
case "$suite" in _*) continue ;; esac
|
||||||
|
|
||||||
|
local total=0 found=0
|
||||||
|
local -a suite_missing=() suite_noexec=()
|
||||||
|
for tool in "$dir"*.sh; do
|
||||||
|
[ -e "$tool" ] || continue
|
||||||
|
base="$(basename -- "$tool")"
|
||||||
|
is_excluded "$base" && continue
|
||||||
|
total=$((total + 1))
|
||||||
|
if documented "$base"; then
|
||||||
|
found=$((found + 1))
|
||||||
|
# Documented AND present is not enough. The index presents these as
|
||||||
|
# commands to run, and every caller — the wrapper guard included —
|
||||||
|
# decides "is this tool here?" with `[ -x ]`. A 0644 wrapper is
|
||||||
|
# documented, present, and dead: it reads as absent to every check that
|
||||||
|
# matters while scoring 100% here. That is a false green, which is worse
|
||||||
|
# than a red, so it fails rather than warns.
|
||||||
|
[ -x "$tool" ] || suite_noexec+=("$base")
|
||||||
|
else
|
||||||
|
suite_missing+=("$base")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$total" -eq 0 ] && continue
|
||||||
|
|
||||||
|
local pct=$(( found * 100 / total ))
|
||||||
|
if [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -gt 0 ]; then
|
||||||
|
printf 'FAIL %-12s %3d%% (%d/%d) documented but not executable: %s\n' \
|
||||||
|
"$suite" "$pct" "$found" "$total" "${suite_noexec[*]}"
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
if [ "$enforced" -eq 1 ] && [ ${#suite_missing[@]} -gt 0 ]; then
|
||||||
|
printf 'FAIL %-12s %3d%% (%d/%d) undocumented: %s\n' \
|
||||||
|
"$suite" "$pct" "$found" "$total" "${suite_missing[*]}"
|
||||||
|
rc=1
|
||||||
|
elif [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -eq 0 ]; then
|
||||||
|
printf 'ok %-12s %3d%% (%d/%d) [enforced]\n' "$suite" "$pct" "$found" "$total"
|
||||||
|
else
|
||||||
|
printf 'info %-12s %3d%% (%d/%d) not yet enforced\n' "$suite" "$pct" "$found" "$total"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reverse: an index that names a tool this suite does not have sends agents
|
||||||
|
# after something that cannot run. Only checked for enforced suites, where
|
||||||
|
# the naming is unambiguous enough to attribute.
|
||||||
|
if [ "$enforced" -eq 1 ]; then
|
||||||
|
local -a stale=()
|
||||||
|
local ref
|
||||||
|
while read -r ref; do
|
||||||
|
[ -n "$ref" ] || continue
|
||||||
|
is_excluded "$ref" && continue
|
||||||
|
[ -e "$dir$ref" ] || stale+=("$ref")
|
||||||
|
done < <(grep -oE "$suite/[a-z0-9][a-z0-9._-]*\.sh" "$CORPUS" \
|
||||||
|
| sed "s|^$suite/||" | sort -u)
|
||||||
|
if [ ${#stale[@]} -gt 0 ]; then
|
||||||
|
printf 'FAIL %-12s stale index references (no such file): %s\n' \
|
||||||
|
"$suite" "${stale[*]}"
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
cat <<EOF
|
||||||
|
Undocumented tools are undiscoverable. An agent cannot obey a hard gate that
|
||||||
|
tells it to use a wrapper it has no way to learn exists — it will reach for raw
|
||||||
|
curl/gh/tea instead, and the wrapper's provider-dialect handling will be lost.
|
||||||
|
|
||||||
|
Fix by naming each tool above in one of the index documents listed at the top,
|
||||||
|
in the same commit that ships it.
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
printf 'every enforced suite is fully discoverable.\n'
|
||||||
|
fi
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- self-test -------------------------------------------------------------
|
||||||
|
# Proves the gate can actually fail. A checker that only ever passes is
|
||||||
|
# indistinguishable from one that is not running, which is the failure mode this
|
||||||
|
# whole file exists to prevent — so it must demonstrate a red on demand.
|
||||||
|
self_test() {
|
||||||
|
local tmp rc
|
||||||
|
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN
|
||||||
|
mkdir -p "$tmp/tools/git"
|
||||||
|
printf '#!/bin/sh\n' > "$tmp/tools/git/documented-tool.sh"
|
||||||
|
printf '#!/bin/sh\n' > "$tmp/tools/git/test-ignored.sh"
|
||||||
|
chmod +x "$tmp/tools/git/documented-tool.sh" "$tmp/tools/git/test-ignored.sh"
|
||||||
|
|
||||||
|
# run_check reads the TOOLS_DIR / DOCS globals; an array cannot ride in a
|
||||||
|
# command-prefix assignment, so point the globals at the fixture directly.
|
||||||
|
TOOLS_DIR="$tmp/tools"
|
||||||
|
DOCS=("$tmp/doc.md")
|
||||||
|
|
||||||
|
# Case 1: fully documented -> pass.
|
||||||
|
printf 'see tools/git/documented-tool.sh for details\n' > "$tmp/doc.md"
|
||||||
|
if run_check >/dev/null; then
|
||||||
|
printf 'self-test 1/4 ok (complete index passes)\n'
|
||||||
|
else
|
||||||
|
printf 'self-test 1/4 FAIL (complete index should pass)\n'; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 2: an undocumented tool -> fail.
|
||||||
|
printf '#!/bin/sh\n' > "$tmp/tools/git/undocumented-tool.sh"
|
||||||
|
rc=0; run_check >/dev/null || rc=$?
|
||||||
|
if [ "$rc" -eq 1 ]; then
|
||||||
|
printf 'self-test 2/4 ok (undocumented tool fails the gate)\n'
|
||||||
|
else
|
||||||
|
printf 'self-test 2/4 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 3: a stale index reference -> fail.
|
||||||
|
rm "$tmp/tools/git/undocumented-tool.sh"
|
||||||
|
printf 'also tools/git/deleted-tool.sh\n' >> "$tmp/doc.md"
|
||||||
|
rc=0; run_check >/dev/null || rc=$?
|
||||||
|
if [ "$rc" -eq 1 ]; then
|
||||||
|
printf 'self-test 3/4 ok (stale index reference fails the gate)\n'
|
||||||
|
else
|
||||||
|
printf 'self-test 3/4 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 4: documented, present, and NOT executable -> fail. Found by an
|
||||||
|
# independent reviewer: a 0644 wrapper scored 100% here while reading as
|
||||||
|
# absent to every `[ -x ]` in the fleet, including the wrapper guard's.
|
||||||
|
sed -i '/deleted-tool/d' "$tmp/doc.md"
|
||||||
|
printf '#!/bin/sh\n' > "$tmp/tools/git/noexec-tool.sh"
|
||||||
|
chmod 0644 "$tmp/tools/git/noexec-tool.sh"
|
||||||
|
printf 'and tools/git/noexec-tool.sh\n' >> "$tmp/doc.md"
|
||||||
|
rc=0; run_check >/dev/null || rc=$?
|
||||||
|
if [ "$rc" -eq 1 ]; then
|
||||||
|
printf 'self-test 4/4 ok (documented but non-executable tool fails the gate)\n'
|
||||||
|
else
|
||||||
|
printf 'self-test 4/4 FAIL (non-executable tool should fail, got rc=%s)\n' "$rc"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\nself-test passed: the gate demonstrably reds on every drift direction.\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$SELF_TEST" -eq 1 ]; then
|
||||||
|
self_test
|
||||||
|
else
|
||||||
|
resolve_locations
|
||||||
|
run_check
|
||||||
|
fi
|
||||||
@@ -26,6 +26,9 @@ chk "F1 fresh: CONSTITUTION/AGENTS/STANDARDS/TOOLS seeded" \
|
|||||||
"[ -f '$T1/CONSTITUTION.md' ] && [ -f '$T1/AGENTS.md' ] && [ -f '$T1/STANDARDS.md' ] && [ -f '$T1/TOOLS.md' ]"
|
"[ -f '$T1/CONSTITUTION.md' ] && [ -f '$T1/AGENTS.md' ] && [ -f '$T1/STANDARDS.md' ] && [ -f '$T1/TOOLS.md' ]"
|
||||||
chk "F1 fresh: AGENTS == shipped default" "cmp -s '$T1/AGENTS.md' '$DEFA/AGENTS.md'"
|
chk "F1 fresh: AGENTS == shipped default" "cmp -s '$T1/AGENTS.md' '$DEFA/AGENTS.md'"
|
||||||
chk "F1 fresh: framework-version stamped 3" "[ \"\$(cat '$T1/.framework-version' 2>/dev/null)\" = 3 ]"
|
chk "F1 fresh: framework-version stamped 3" "[ \"\$(cat '$T1/.framework-version' 2>/dev/null)\" = 3 ]"
|
||||||
|
chk "F1 fresh: Pi goal extension deploys under Mosaic runtime" \
|
||||||
|
"cmp -s '$T1/runtime/pi/goal-extension.ts' '$FW/runtime/pi/goal-extension.ts'"
|
||||||
|
chk "F1 fresh: installer creates no nested main Pi config" "[ ! -e '$T1/.pi' ]"
|
||||||
|
|
||||||
# F2 — legacy install with a user-edited AGENTS.md (the sanctioned pre-constitution customization)
|
# F2 — legacy install with a user-edited AGENTS.md (the sanctioned pre-constitution customization)
|
||||||
T2=$(mktemp -d); mkdir -p "$T2/credentials"
|
T2=$(mktemp -d); mkdir -p "$T2/credentials"
|
||||||
@@ -89,6 +92,8 @@ chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.en
|
|||||||
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
|
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
|
||||||
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||||
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
|
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
|
||||||
|
chk "F6 reseed: Pi goal extension is refreshed from framework source" \
|
||||||
|
"cmp -s '$T6/runtime/pi/goal-extension.ts' '$FW/runtime/pi/goal-extension.ts'"
|
||||||
|
|
||||||
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
|
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -564,7 +564,7 @@ echo "== Q16 (guard): Q2's ENUM-B fixture must STAY address-free — the reconci
|
|||||||
# Token concatenated so THIS guard's own source lines never contain the
|
# Token concatenated so THIS guard's own source lines never contain the
|
||||||
# literal fixture id and cannot self-match.
|
# literal fixture id and cannot self-match.
|
||||||
enum_id='ENUM''-B'
|
enum_id='ENUM''-B'
|
||||||
fixture_line="$(has_match -F "\"id\":\"$enum_id\"" "$self" | has_match -F '"observed_seq":5' | head -n1)"
|
fixture_lines="$(has_match -F "\"id\":\"$enum_id\"" "$self")"; fixture_matches="$(has_match -F '"observed_seq":5' <<<"$fixture_lines")"; fixture_line="${fixture_matches%%$'\n'*}"
|
||||||
[ -n "$fixture_line" ] || fail_msg "Q16: could not locate Q2's $enum_id fixture line (renamed/renumbered? update this guard)"
|
[ -n "$fixture_line" ] || fail_msg "Q16: could not locate Q2's $enum_id fixture line (renamed/renumbered? update this guard)"
|
||||||
fixture_json="$(printf '%s' "$fixture_line" | sed "s/.*'\({.*}\)'.*/\1/")"
|
fixture_json="$(printf '%s' "$fixture_line" | sed "s/.*'\({.*}\)'.*/\1/")"
|
||||||
# Positive controls FIRST (blind-instrument rule): the extraction must yield
|
# Positive controls FIRST (blind-instrument rule): the extraction must yield
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ echo "== P3: detector orders the cause line BEFORE the delta it explains =="
|
|||||||
printf 'r1 state v2\n' >"$fx/repo_r1"
|
printf 'r1 state v2\n' >"$fx/repo_r1"
|
||||||
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: second poll failed"
|
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: second poll failed"
|
||||||
sd="$(state_dir)"
|
sd="$(state_dir)"
|
||||||
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
pre_seq="$(jq -nr 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
||||||
src_seq="$(jq -r 'select(.locators.kind == "repo") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
src_seq="$(jq -nr 'first(inputs | select(.locators.kind == "repo") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
||||||
[ -n "$pre_seq" ] || fail_msg "P3: no preimage cause entry enqueued"
|
[ -n "$pre_seq" ] || fail_msg "P3: no preimage cause entry enqueued"
|
||||||
[ -n "$src_seq" ] || fail_msg "P3: no source delta entry enqueued"
|
[ -n "$src_seq" ] || fail_msg "P3: no source delta entry enqueued"
|
||||||
if [ -n "$pre_seq" ] && [ -n "$src_seq" ]; then
|
if [ -n "$pre_seq" ] && [ -n "$src_seq" ]; then
|
||||||
@@ -343,8 +343,8 @@ echo "== P11: reconcile surfaces the cause line before its enumerations =="
|
|||||||
printf '# adapter changed while detector down\n' >>"$fx/adapter.sh"
|
printf '# adapter changed while detector down\n' >>"$fx/adapter.sh"
|
||||||
"$RECON" reconcile >/dev/null 2>&1 # rc 1 expected (unaccounted enumerated)
|
"$RECON" reconcile >/dev/null 2>&1 # rc 1 expected (unaccounted enumerated)
|
||||||
sd="$(state_dir)"
|
sd="$(state_dir)"
|
||||||
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
pre_seq="$(jq -nr 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
||||||
enum_seq="$(jq -r 'select(.locators.reconciled == true) | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
enum_seq="$(jq -nr 'first(inputs | select(.locators.reconciled == true) | .observed_seq) // empty' "$sd/pending.jsonl")"
|
||||||
[ -n "$pre_seq" ] || fail_msg "P11: reconcile must enqueue the preimage cause line"
|
[ -n "$pre_seq" ] || fail_msg "P11: reconcile must enqueue the preimage cause line"
|
||||||
[ -n "$enum_seq" ] || fail_msg "P11: reconcile must still enumerate the unaccounted source"
|
[ -n "$enum_seq" ] || fail_msg "P11: reconcile must still enumerate the unaccounted source"
|
||||||
if [ -n "$pre_seq" ] && [ -n "$enum_seq" ]; then
|
if [ -n "$pre_seq" ] && [ -n "$enum_seq" ]; then
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ site_line() { # site_line FILE MARKER -> first physical line of that call
|
|||||||
local f="$1" marker="$2" ln
|
local f="$1" marker="$2" ln
|
||||||
ln="$(grep -n "# SITE:${marker}\$" "$f" | cut -d: -f1)"
|
ln="$(grep -n "# SITE:${marker}\$" "$f" | cut -d: -f1)"
|
||||||
# continuation marker sits on the tail line; the call starts one line up
|
# continuation marker sits on the tail line; the call starts one line up
|
||||||
if ! sed -n "${ln}p" "$f" | grep -Eq 'has_match|count_lines'; then
|
source_line="$(sed -n "${ln}p" "$f")"; if ! grep -Eq 'has_match|count_lines' <<<"$source_line"; then
|
||||||
ln=$((ln - 1))
|
ln=$((ln - 1))
|
||||||
fi
|
fi
|
||||||
printf '%s' "$ln"
|
printf '%s' "$ln"
|
||||||
@@ -167,13 +167,13 @@ rcB=$?
|
|||||||
sort "$LEDGER" >"$TMP/got-c1"
|
sort "$LEDGER" >"$TMP/got-c1"
|
||||||
n_expected="$(grep -c . "$TMP/expected-c1")"
|
n_expected="$(grep -c . "$TMP/expected-c1")"
|
||||||
if [ "$rcA" -eq 0 ] && [ "$rcB" -eq 0 ] &&
|
if [ "$rcA" -eq 0 ] && [ "$rcB" -eq 0 ] &&
|
||||||
printf '%s' "$outA" | grep -q 'mini-a: OK' &&
|
grep -q 'mini-a: OK' <<<"$outA"&&
|
||||||
printf '%s' "$outB" | grep -q 'mini-b: OK' &&
|
grep -q 'mini-b: OK' <<<"$outB"&&
|
||||||
[ "$n_expected" -gt 1 ] &&
|
[ "$n_expected" -gt 1 ] &&
|
||||||
cmp -s "$TMP/expected-c1" "$TMP/got-c1"; then
|
cmp -s "$TMP/expected-c1" "$TMP/got-c1"; then
|
||||||
check C1 0 ""
|
check C1 0 ""
|
||||||
else
|
else
|
||||||
check C1 1 "rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff "$TMP/expected-c1" "$TMP/got-c1" 2>&1 | head -n 10 | tr '\n' ' ')"
|
check C1 1 "rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff "$TMP/expected-c1" "$TMP/got-c1" 2>&1 | sed -n '1,10p' | tr '\n' ' ')"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- C2: early exit -> short ledger, comparison catches it -----------------
|
# --- C2: early exit -> short ledger, comparison catches it -----------------
|
||||||
@@ -201,12 +201,12 @@ abort_case() { # abort_case NAME MARKER HELPER
|
|||||||
bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -ne 0 ] &&
|
if [ "$rc" -ne 0 ] &&
|
||||||
! printf '%s' "$out" | grep -q 'mini-a: OK' &&
|
! grep -q 'mini-a: OK' <<<"$out"&&
|
||||||
! printf '%s' "$out" | grep -q 'mini-a: FAILED' &&
|
! grep -q 'mini-a: FAILED' <<<"$out"&&
|
||||||
printf '%s' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" &&
|
grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" <<<"$out"&&
|
||||||
printf '%s' "$out" | grep -q "WAKE-ASSERT ABORT" &&
|
grep -q "WAKE-ASSERT ABORT" <<<"$out"&&
|
||||||
printf '%s' "$out" | grep -q "$site" &&
|
grep -q "$site" <<<"$out"&&
|
||||||
printf '%s' "$out" | grep -q "grep exit 2" &&
|
grep -q "grep exit 2" <<<"$out"&&
|
||||||
grep -q "^${helper} ${site}\$" "$ledger"; then
|
grep -q "^${helper} ${site}\$" "$ledger"; then
|
||||||
check "$name" 0 ""
|
check "$name" 0 ""
|
||||||
else
|
else
|
||||||
@@ -230,8 +230,8 @@ if [ "$got" = "1" ]; then check C8 0 ""; else check C8 1 "env-prefix did not rea
|
|||||||
out="$(WAKE_ASSERT_FORCE_GREP_ERROR_AT="mini-a.sh:9999" bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
out="$(WAKE_ASSERT_FORCE_GREP_ERROR_AT="mini-a.sh:9999" bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -eq 0 ] &&
|
if [ "$rc" -eq 0 ] &&
|
||||||
printf '%s' "$out" | grep -q 'mini-a: OK' &&
|
grep -q 'mini-a: OK' <<<"$out"&&
|
||||||
! printf '%s' "$out" | grep -q 'WAKE-ASSERT ARMED'; then
|
! grep -q 'WAKE-ASSERT ARMED' <<<"$out"; then
|
||||||
check C9 0 ""
|
check C9 0 ""
|
||||||
else
|
else
|
||||||
check C9 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
|
check C9 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
|
||||||
@@ -248,8 +248,8 @@ chmod +x "$TMP/fake-bash"
|
|||||||
out="$(WAKE_ASSERT_PIN_BASH="$TMP/fake-bash" bash -c '. "$WAKE_COMMON" && wake_assert_init && echo REACHED-PAST-INIT' 2>&1)"
|
out="$(WAKE_ASSERT_PIN_BASH="$TMP/fake-bash" bash -c '. "$WAKE_COMMON" && wake_assert_init && echo REACHED-PAST-INIT' 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -ne 0 ] &&
|
if [ "$rc" -ne 0 ] &&
|
||||||
! printf '%s' "$out" | grep -q 'REACHED-PAST-INIT' &&
|
! grep -q 'REACHED-PAST-INIT' <<<"$out"&&
|
||||||
printf '%s' "$out" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then
|
grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated' <<<"$out"; then
|
||||||
check C10 0 ""
|
check C10 0 ""
|
||||||
else
|
else
|
||||||
check C10 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 2 | tr '\n' ' ')"
|
check C10 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 2 | tr '\n' ' ')"
|
||||||
@@ -283,8 +283,8 @@ out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$TMP/mini-c.sh" "$TMP" 2>&1)"
|
|||||||
rc=$?
|
rc=$?
|
||||||
summary_ln="$(site_line "$TMP/mini-c.sh" c-summary)"
|
summary_ln="$(site_line "$TMP/mini-c.sh" c-summary)"
|
||||||
if [ "$rc" -eq 1 ] &&
|
if [ "$rc" -eq 1 ] &&
|
||||||
printf '%s' "$out" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&
|
grep -q 'wake mini-c harness: FAILED (1 assertion(s))' <<<"$out"&&
|
||||||
! printf '%s' "$out" | grep -q 'all invariants passed' &&
|
! grep -q 'all invariants passed' <<<"$out"&&
|
||||||
grep -q "^count_lines mini-c.sh:${summary_ln}\$" "$LEDGER"; then
|
grep -q "^count_lines mini-c.sh:${summary_ln}\$" "$LEDGER"; then
|
||||||
check C11 0 ""
|
check C11 0 ""
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ if cmp -s "$TMP/expected.txt" "$TMP/static.txt"; then
|
|||||||
echo "STATIC-INVENTORY equals expected set ($(grep -c . "$TMP/static.txt") rows from source text)"
|
echo "STATIC-INVENTORY equals expected set ($(grep -c . "$TMP/static.txt") rows from source text)"
|
||||||
else
|
else
|
||||||
flag "static inventory (source text) differs from expected set (artifact):"
|
flag "static inventory (source text) differs from expected set (artifact):"
|
||||||
diff "$TMP/expected.txt" "$TMP/static.txt" | head -n 20 | sed 's/^/ /'
|
diff "$TMP/expected.txt" "$TMP/static.txt" | sed -n '1,20p' | sed 's/^/ /'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 3: green instrumented run ----------------------------------------------
|
# --- 3: green instrumented run ----------------------------------------------
|
||||||
@@ -116,7 +116,7 @@ LEDGER="$TMP/ledger"
|
|||||||
for s in "${SUITES[@]}"; do
|
for s in "${SUITES[@]}"; do
|
||||||
out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$WAKE/$s" 2>&1)"
|
out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$WAKE/$s" 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$s")"; then
|
if grep -Eq "$(sentinel_for "$s")" <<<"$out"; then
|
||||||
sent="present"
|
sent="present"
|
||||||
else
|
else
|
||||||
sent="ABSENT"
|
sent="ABSENT"
|
||||||
@@ -177,14 +177,14 @@ while read -r helper site form; do
|
|||||||
rc=$?
|
rc=$?
|
||||||
bad=""
|
bad=""
|
||||||
[ "$rc" -ne 0 ] || bad="$bad exit=0"
|
[ "$rc" -ne 0 ] || bad="$bad exit=0"
|
||||||
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" ||
|
grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" <<<"$out"||
|
||||||
bad="$bad no-ARMED-line"
|
bad="$bad no-ARMED-line"
|
||||||
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" ||
|
grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" <<<"$out"||
|
||||||
bad="$bad no-ABORT-line"
|
bad="$bad no-ABORT-line"
|
||||||
# AND-polarity check (a match is the defect): a grep error (rc>=2) must be
|
# AND-polarity check (a match is the defect): a grep error (rc>=2) must be
|
||||||
# its own loud arm — it cannot fall through as "no sentinel = pass".
|
# its own loud arm — it cannot fall through as "no sentinel = pass".
|
||||||
rc_sent=0
|
rc_sent=0
|
||||||
printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$f")" || rc_sent=$?
|
grep -Eq "$(sentinel_for "$f")" <<<"$out"|| rc_sent=$?
|
||||||
case "$rc_sent" in
|
case "$rc_sent" in
|
||||||
0) bad="$bad sentinel-emitted" ;;
|
0) bad="$bad sentinel-emitted" ;;
|
||||||
1) : ;;
|
1) : ;;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
buildPiSkillArgs,
|
buildPiSkillArgs,
|
||||||
|
discoverPiExtensionArgs,
|
||||||
enumerateSkillDirs,
|
enumerateSkillDirs,
|
||||||
piForceSkillNames,
|
piForceSkillNames,
|
||||||
registerRuntimeLaunchers,
|
registerRuntimeLaunchers,
|
||||||
@@ -178,6 +179,52 @@ describe('buildPiSkillArgs', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('discoverPiExtensionArgs', () => {
|
||||||
|
it('loads the core and goal extensions in deterministic order from Mosaic home', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-'));
|
||||||
|
const runtimeDir = join(root, 'runtime', 'pi');
|
||||||
|
mkdirSync(runtimeDir, { recursive: true });
|
||||||
|
writeFileSync(join(runtimeDir, 'goal-extension.ts'), '// goal\n');
|
||||||
|
writeFileSync(join(runtimeDir, 'mosaic-extension.ts'), '// core\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(discoverPiExtensionArgs(root)).toEqual([
|
||||||
|
'--extension',
|
||||||
|
join(runtimeDir, 'mosaic-extension.ts'),
|
||||||
|
'--extension',
|
||||||
|
join(runtimeDir, 'goal-extension.ts'),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remains backward-compatible when the optional goal extension is absent', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-'));
|
||||||
|
const runtimeDir = join(root, 'runtime', 'pi');
|
||||||
|
mkdirSync(runtimeDir, { recursive: true });
|
||||||
|
writeFileSync(join(runtimeDir, 'mosaic-extension.ts'), '// core\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(discoverPiExtensionArgs(root)).toEqual([
|
||||||
|
'--extension',
|
||||||
|
join(runtimeDir, 'mosaic-extension.ts'),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits no extension arguments when Mosaic runtime assets are absent', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-'));
|
||||||
|
try {
|
||||||
|
expect(discoverPiExtensionArgs(root)).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('enumerateSkillDirs (real FS)', () => {
|
describe('enumerateSkillDirs (real FS)', () => {
|
||||||
let root: string;
|
let root: string;
|
||||||
|
|
||||||
|
|||||||
@@ -715,9 +715,15 @@ export function buildPiSkillArgs(
|
|||||||
return ['--no-skills', ...forcedSkillArgs];
|
return ['--no-skills', ...forcedSkillArgs];
|
||||||
}
|
}
|
||||||
|
|
||||||
function discoverPiExtension(): string[] {
|
const PI_EXTENSION_FILES = ['mosaic-extension.ts', 'goal-extension.ts'] as const;
|
||||||
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
|
|
||||||
return existsSync(ext) ? ['--extension', ext] : [];
|
export function discoverPiExtensionArgs(mosaicHome: string = MOSAIC_HOME): string[] {
|
||||||
|
const args: string[] = [];
|
||||||
|
for (const fileName of PI_EXTENSION_FILES) {
|
||||||
|
const extensionPath = join(mosaicHome, 'runtime', 'pi', fileName);
|
||||||
|
if (existsSync(extensionPath)) args.push('--extension', extensionPath);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Launch functions ────────────────────────────────────────────────────────
|
// ─── Launch functions ────────────────────────────────────────────────────────
|
||||||
@@ -792,7 +798,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
const prompt = buildRuntimePrompt('pi');
|
const prompt = buildRuntimePrompt('pi');
|
||||||
const cliArgs = ['--append-system-prompt', prompt];
|
const cliArgs = ['--append-system-prompt', prompt];
|
||||||
cliArgs.push(...buildPiSkillArgs(args));
|
cliArgs.push(...buildPiSkillArgs(args));
|
||||||
cliArgs.push(...discoverPiExtension());
|
cliArgs.push(...discoverPiExtensionArgs());
|
||||||
if (hasMissionNoArgs) {
|
if (hasMissionNoArgs) {
|
||||||
cliArgs.push(missionPrompt);
|
cliArgs.push(missionPrompt);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -209,6 +209,20 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deploys the Mosaic-owned Pi goal extension only inside the Mosaic runtime tree', async () => {
|
||||||
|
const sourceRuntime = join(fixture.sourceDir, 'runtime', 'pi');
|
||||||
|
mkdirSync(sourceRuntime, { recursive: true });
|
||||||
|
writeFileSync(join(sourceRuntime, 'goal-extension.ts'), '// persistent goal extension\n');
|
||||||
|
|
||||||
|
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||||
|
await adapter.syncFramework('fresh');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
readFileSync(join(fixture.mosaicHome, 'runtime', 'pi', 'goal-extension.ts'), 'utf-8'),
|
||||||
|
).toBe('// persistent goal extension\n');
|
||||||
|
expect(existsSync(join(fixture.mosaicHome, '.pi'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||||
rmSync(fixture.defaultsDir, { recursive: true });
|
rmSync(fixture.defaultsDir, { recursive: true });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,796 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
interface FakeEntry {
|
||||||
|
type: string;
|
||||||
|
customType?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SentMessage {
|
||||||
|
message: {
|
||||||
|
customType: string;
|
||||||
|
content: string;
|
||||||
|
display: boolean;
|
||||||
|
};
|
||||||
|
options?: {
|
||||||
|
triggerTurn?: boolean;
|
||||||
|
deliverAs?: 'steer' | 'followUp' | 'nextTurn';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeContext {
|
||||||
|
cwd: string;
|
||||||
|
ui: {
|
||||||
|
notifications: Array<{ message: string; level?: string }>;
|
||||||
|
statuses: Map<string, string | undefined>;
|
||||||
|
notify(message: string, level?: string): void;
|
||||||
|
setStatus(key: string, value: string | undefined): void;
|
||||||
|
};
|
||||||
|
sessionManager: {
|
||||||
|
getBranch(): FakeEntry[];
|
||||||
|
};
|
||||||
|
isIdle(): boolean;
|
||||||
|
hasPendingMessages(): boolean;
|
||||||
|
abort(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventHandler = (
|
||||||
|
event: Record<string, unknown>,
|
||||||
|
context: FakeContext,
|
||||||
|
) => unknown | Promise<unknown>;
|
||||||
|
|
||||||
|
type CommandHandler = (args: string, context: FakeContext) => unknown | Promise<unknown>;
|
||||||
|
|
||||||
|
interface FakeToolResult {
|
||||||
|
content: Array<{ type: string; text: string }>;
|
||||||
|
details?: unknown;
|
||||||
|
terminate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeTool {
|
||||||
|
name: string;
|
||||||
|
execute(
|
||||||
|
toolCallId: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
onUpdate: undefined,
|
||||||
|
context: FakeContext,
|
||||||
|
): Promise<FakeToolResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoalExtensionFactory {
|
||||||
|
(api: FakePiApi): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoalExtensionModule {
|
||||||
|
default: GoalExtensionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakePiApi {
|
||||||
|
on(event: string, handler: EventHandler): void;
|
||||||
|
registerCommand(name: string, options: { description: string; handler: CommandHandler }): void;
|
||||||
|
registerTool(tool: FakeTool): void;
|
||||||
|
appendEntry(customType: string, data?: unknown): void;
|
||||||
|
sendMessage(message: SentMessage['message'], options?: SentMessage['options']): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGoalExtensionModule(value: unknown): value is GoalExtensionModule {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
return typeof Reflect.get(value, 'default') === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
const goalExtensionUrl = new URL('../../framework/runtime/pi/goal-extension.ts', import.meta.url)
|
||||||
|
.href;
|
||||||
|
const importedGoalExtension: unknown = await import(goalExtensionUrl);
|
||||||
|
if (!isGoalExtensionModule(importedGoalExtension)) {
|
||||||
|
throw new Error('Pi goal extension must export a default registration function');
|
||||||
|
}
|
||||||
|
const registerGoalExtension = importedGoalExtension.default;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakePi {
|
||||||
|
readonly handlers = new Map<string, EventHandler[]>();
|
||||||
|
readonly commands = new Map<string, CommandHandler>();
|
||||||
|
readonly tools = new Map<string, FakeTool>();
|
||||||
|
readonly entries: FakeEntry[] = [];
|
||||||
|
readonly sentMessages: SentMessage[] = [];
|
||||||
|
readonly notifications: Array<{ message: string; level?: string }> = [];
|
||||||
|
readonly statuses = new Map<string, string | undefined>();
|
||||||
|
branch: FakeEntry[] = [];
|
||||||
|
idle = true;
|
||||||
|
pending = false;
|
||||||
|
abortCount = 0;
|
||||||
|
|
||||||
|
readonly context: FakeContext = {
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
ui: {
|
||||||
|
notifications: this.notifications,
|
||||||
|
statuses: this.statuses,
|
||||||
|
notify: (message: string, level?: string): void => {
|
||||||
|
this.notifications.push({ message, level });
|
||||||
|
},
|
||||||
|
setStatus: (key: string, value: string | undefined): void => {
|
||||||
|
this.statuses.set(key, value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessionManager: {
|
||||||
|
getBranch: (): FakeEntry[] => [...this.branch],
|
||||||
|
},
|
||||||
|
isIdle: (): boolean => this.idle,
|
||||||
|
hasPendingMessages: (): boolean => this.pending,
|
||||||
|
abort: (): void => {
|
||||||
|
this.abortCount += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly api: FakePiApi = {
|
||||||
|
on: (event: string, handler: EventHandler): void => {
|
||||||
|
this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]);
|
||||||
|
},
|
||||||
|
registerCommand: (
|
||||||
|
name: string,
|
||||||
|
options: { description: string; handler: CommandHandler },
|
||||||
|
): void => {
|
||||||
|
this.commands.set(name, options.handler);
|
||||||
|
},
|
||||||
|
registerTool: (tool: FakeTool): void => {
|
||||||
|
this.tools.set(tool.name, tool);
|
||||||
|
},
|
||||||
|
appendEntry: (customType: string, data?: unknown): void => {
|
||||||
|
const entry: FakeEntry = { type: 'custom', customType, data };
|
||||||
|
this.entries.push(entry);
|
||||||
|
this.branch.push(entry);
|
||||||
|
},
|
||||||
|
sendMessage: (message: SentMessage['message'], options?: SentMessage['options']): void => {
|
||||||
|
this.sentMessages.push({ message, options });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(initialBranch: FakeEntry[] = []) {
|
||||||
|
this.branch = [...initialBranch];
|
||||||
|
registerGoalExtension(this.api);
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit(event: string, value: Record<string, unknown> = {}): Promise<unknown[]> {
|
||||||
|
const results: unknown[] = [];
|
||||||
|
for (const handler of this.handlers.get(event) ?? []) {
|
||||||
|
results.push(await handler(value, this.context));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async goal(args: string): Promise<void> {
|
||||||
|
const handler = this.commands.get('goal');
|
||||||
|
if (handler === undefined) throw new Error('/goal was not registered');
|
||||||
|
await handler(args, this.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
async report(params: Record<string, unknown>): Promise<FakeToolResult> {
|
||||||
|
const tool = this.tools.get('mosaic_goal_report');
|
||||||
|
if (tool === undefined) throw new Error('mosaic_goal_report was not registered');
|
||||||
|
return await tool.execute('goal-report-1', params, undefined, undefined, this.context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestGoalStateData(pi: FakePi): Record<string, unknown> {
|
||||||
|
for (let index = pi.entries.length - 1; index >= 0; index -= 1) {
|
||||||
|
const entry = pi.entries[index];
|
||||||
|
if (entry?.customType === 'mosaic-goal-state' && isRecord(entry.data)) return entry.data;
|
||||||
|
}
|
||||||
|
throw new Error('No persisted Mosaic goal state found');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateField(pi: FakePi, field: string): unknown {
|
||||||
|
return latestGoalStateData(pi)[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeGoalStatementFromContext(result: unknown): string {
|
||||||
|
if (!isRecord(result)) throw new Error('Context handler did not return an object');
|
||||||
|
const messages = result['messages'];
|
||||||
|
if (!Array.isArray(messages)) throw new Error('Context result did not include messages');
|
||||||
|
const goalMessage = messages.find(
|
||||||
|
(message: unknown): boolean =>
|
||||||
|
isRecord(message) && message['customType'] === 'mosaic-goal-context',
|
||||||
|
);
|
||||||
|
if (!isRecord(goalMessage) || typeof goalMessage['content'] !== 'string') {
|
||||||
|
throw new Error('Goal context message was not injected');
|
||||||
|
}
|
||||||
|
return goalMessage['content'];
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Mosaic Pi goal extension commands', () => {
|
||||||
|
it('shows help and handles controls safely when no goal exists', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
|
||||||
|
await pi.goal('');
|
||||||
|
expect(pi.notifications.at(-1)?.message).toContain('/goal set');
|
||||||
|
await pi.goal('status');
|
||||||
|
expect(pi.notifications.at(-1)?.message).toContain('No Mosaic goal is set');
|
||||||
|
|
||||||
|
for (const command of ['pause', 'resume', 'cancel']) {
|
||||||
|
await pi.goal(command);
|
||||||
|
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||||
|
}
|
||||||
|
expect(pi.entries).toHaveLength(0);
|
||||||
|
expect(pi.sentMessages).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets, reports, pauses, resumes, and cancels a bounded goal', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
|
||||||
|
await pi.goal('set Deliver the local goal extension with tests');
|
||||||
|
expect(stateField(pi, 'phase')).toBe('active');
|
||||||
|
expect(stateField(pi, 'statement')).toBe('Deliver the local goal extension with tests');
|
||||||
|
expect(pi.sentMessages).toHaveLength(1);
|
||||||
|
expect(pi.sentMessages[0]?.options?.triggerTurn).toBe(true);
|
||||||
|
|
||||||
|
await pi.goal('status');
|
||||||
|
expect(pi.notifications.at(-1)?.message).toContain('Deliver the local goal extension');
|
||||||
|
expect(pi.notifications.at(-1)?.message).toContain('active');
|
||||||
|
|
||||||
|
await pi.goal('pause');
|
||||||
|
expect(stateField(pi, 'phase')).toBe('paused');
|
||||||
|
pi.sentMessages.length = 0;
|
||||||
|
await pi.emit('agent_settled');
|
||||||
|
expect(pi.sentMessages).toHaveLength(0);
|
||||||
|
|
||||||
|
await pi.goal('resume');
|
||||||
|
expect(stateField(pi, 'phase')).toBe('active');
|
||||||
|
expect(pi.sentMessages).toHaveLength(1);
|
||||||
|
|
||||||
|
await pi.goal('cancel');
|
||||||
|
expect(stateField(pi, 'phase')).toBe('cancelled');
|
||||||
|
pi.sentMessages.length = 0;
|
||||||
|
await pi.emit('agent_settled');
|
||||||
|
expect(pi.sentMessages).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts /goal <statement> shorthand but refuses to replace an active goal', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
|
||||||
|
await pi.goal('First goal');
|
||||||
|
const firstGoalId = stateField(pi, 'goalId');
|
||||||
|
await pi.goal('set Second goal');
|
||||||
|
|
||||||
|
expect(stateField(pi, 'goalId')).toBe(firstGoalId);
|
||||||
|
expect(stateField(pi, 'statement')).toBe('First goal');
|
||||||
|
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||||
|
expect(pi.notifications.at(-1)?.message).toContain('/goal cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid phase transitions, aborts busy work, and supports clear as cancel', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Preserve transition safety');
|
||||||
|
|
||||||
|
await pi.goal('resume');
|
||||||
|
expect(pi.notifications.at(-1)?.message).toContain('cannot be resumed');
|
||||||
|
pi.idle = false;
|
||||||
|
await pi.goal('pause maintenance window');
|
||||||
|
expect(stateField(pi, 'stopReason')).toBe('maintenance window');
|
||||||
|
expect(pi.abortCount).toBe(1);
|
||||||
|
await pi.goal('pause');
|
||||||
|
expect(pi.notifications.at(-1)?.message).toContain('cannot be paused');
|
||||||
|
await pi.goal('clear');
|
||||||
|
expect(stateField(pi, 'phase')).toBe('cancelled');
|
||||||
|
expect(pi.abortCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty and oversized goal statements without starting a run', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
|
||||||
|
await pi.goal('set');
|
||||||
|
await pi.goal(`set ${'x'.repeat(8_001)}`);
|
||||||
|
|
||||||
|
expect(pi.entries).toHaveLength(0);
|
||||||
|
expect(pi.sentMessages).toHaveLength(0);
|
||||||
|
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Mosaic Pi goal lifecycle', () => {
|
||||||
|
it('injects one fresh active contract before every model context', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Keep the agent oriented');
|
||||||
|
|
||||||
|
const existingGoalContext = {
|
||||||
|
role: 'custom',
|
||||||
|
customType: 'mosaic-goal-context',
|
||||||
|
content: 'stale',
|
||||||
|
};
|
||||||
|
const existingContinuation = {
|
||||||
|
role: 'custom',
|
||||||
|
customType: 'mosaic-goal-continuation',
|
||||||
|
content: 'stale continuation',
|
||||||
|
};
|
||||||
|
const first = await pi.emit('context', {
|
||||||
|
messages: [existingGoalContext, existingContinuation],
|
||||||
|
});
|
||||||
|
const second = await pi.emit('context', { messages: [] });
|
||||||
|
|
||||||
|
expect(activeGoalStatementFromContext(first[0])).toContain('Keep the agent oriented');
|
||||||
|
expect(activeGoalStatementFromContext(first[0])).toContain('mosaic_goal_report');
|
||||||
|
expect(activeGoalStatementFromContext(first[0])).not.toContain('stale');
|
||||||
|
if (!isRecord(first[0]) || !Array.isArray(first[0]['messages'])) {
|
||||||
|
throw new Error('Expected filtered context messages');
|
||||||
|
}
|
||||||
|
expect(first[0]['messages']).toHaveLength(1);
|
||||||
|
expect(activeGoalStatementFromContext(second[0])).toContain('Keep the agent oriented');
|
||||||
|
|
||||||
|
await pi.goal('cancel');
|
||||||
|
expect(
|
||||||
|
await pi.emit('context', {
|
||||||
|
messages: [existingGoalContext, existingContinuation],
|
||||||
|
}),
|
||||||
|
).toEqual([{ messages: [] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an existing busy run adopt the goal and waits behind a pending message', async () => {
|
||||||
|
const busy = new FakePi();
|
||||||
|
busy.idle = false;
|
||||||
|
await busy.goal('set Join the current run safely');
|
||||||
|
expect(busy.sentMessages).toHaveLength(0);
|
||||||
|
|
||||||
|
busy.pending = true;
|
||||||
|
await busy.emit('agent_settled');
|
||||||
|
expect(busy.sentMessages).toHaveLength(0);
|
||||||
|
busy.pending = false;
|
||||||
|
busy.idle = true;
|
||||||
|
await busy.emit('agent_settled');
|
||||||
|
expect(busy.sentMessages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records every turn and continues once when an active run settles', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Finish all acceptance criteria');
|
||||||
|
pi.sentMessages.length = 0;
|
||||||
|
|
||||||
|
await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] });
|
||||||
|
expect(stateField(pi, 'turnCount')).toBe(1);
|
||||||
|
expect(stateField(pi, 'lastCheckSource')).toBe('turn');
|
||||||
|
|
||||||
|
await pi.emit('agent_settled');
|
||||||
|
await pi.emit('agent_settled');
|
||||||
|
expect(pi.sentMessages).toHaveLength(1);
|
||||||
|
expect(pi.sentMessages[0]?.message.content).toContain('Goal remains active');
|
||||||
|
|
||||||
|
await pi.emit('agent_start');
|
||||||
|
await pi.emit('agent_settled');
|
||||||
|
expect(pi.sentMessages).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires two consecutive evidence-bearing achievement reports', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Prove the feature works');
|
||||||
|
pi.sentMessages.length = 0;
|
||||||
|
|
||||||
|
const first = await pi.report({
|
||||||
|
status: 'achieved',
|
||||||
|
summary: 'Focused tests pass',
|
||||||
|
evidence: ['pnpm test: 12 passed'],
|
||||||
|
});
|
||||||
|
expect(first.terminate).toBe(true);
|
||||||
|
expect(stateField(pi, 'phase')).toBe('verifying');
|
||||||
|
expect(stateField(pi, 'verificationPasses')).toBe(1);
|
||||||
|
|
||||||
|
await pi.emit('agent_settled');
|
||||||
|
expect(pi.sentMessages).toHaveLength(1);
|
||||||
|
expect(pi.sentMessages[0]?.message.content).toContain('verification pass');
|
||||||
|
|
||||||
|
await pi.emit('agent_start');
|
||||||
|
const second = await pi.report({
|
||||||
|
status: 'achieved',
|
||||||
|
summary: 'Independent recheck confirms completion',
|
||||||
|
evidence: ['rerun: 12 passed', 'framework path verified'],
|
||||||
|
});
|
||||||
|
expect(second.terminate).toBe(true);
|
||||||
|
expect(stateField(pi, 'phase')).toBe('achieved');
|
||||||
|
expect(stateField(pi, 'verificationPasses')).toBe(2);
|
||||||
|
|
||||||
|
pi.sentMessages.length = 0;
|
||||||
|
await pi.emit('agent_settled');
|
||||||
|
expect(pi.sentMessages).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed progress reports and reports submitted without an active goal', async () => {
|
||||||
|
const noGoal = new FakePi();
|
||||||
|
await expect(
|
||||||
|
noGoal.report({ status: 'continue', summary: 'work', evidence: [] }),
|
||||||
|
).rejects.toThrow(/No active Mosaic goal/);
|
||||||
|
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Validate report boundaries');
|
||||||
|
const invalidReports: Record<string, unknown>[] = [
|
||||||
|
{},
|
||||||
|
{ status: 'invalid', summary: 'work', evidence: [] },
|
||||||
|
{ status: 'continue', evidence: [] },
|
||||||
|
{ status: 'continue', summary: ' ', evidence: [] },
|
||||||
|
{ status: 'continue', summary: 'x'.repeat(2_001), evidence: [] },
|
||||||
|
{ status: 'continue', summary: 'work', evidence: 'not-an-array' },
|
||||||
|
{ status: 'continue', summary: 'work', evidence: Array.from({ length: 21 }, () => 'x') },
|
||||||
|
{ status: 'continue', summary: 'work', evidence: [4] },
|
||||||
|
{ status: 'continue', summary: 'work', evidence: [''] },
|
||||||
|
{ status: 'continue', summary: 'work', evidence: ['x'.repeat(1_001)] },
|
||||||
|
{ status: 'continue', summary: 'work', evidence: [], nextStep: 4 },
|
||||||
|
{ status: 'continue', summary: 'work', evidence: [], nextStep: ' ' },
|
||||||
|
{ status: 'continue', summary: 'work', evidence: [], nextStep: 'x'.repeat(2_001) },
|
||||||
|
];
|
||||||
|
for (const report of invalidReports) {
|
||||||
|
await expect(pi.report(report)).rejects.toThrow();
|
||||||
|
}
|
||||||
|
expect(stateField(pi, 'phase')).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an achievement claim without evidence', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Require evidence');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
pi.report({ status: 'achieved', summary: 'Trust me', evidence: [] }),
|
||||||
|
).rejects.toThrow(/evidence/i);
|
||||||
|
expect(stateField(pi, 'phase')).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts credential-shaped goal and report text before persistence or display', async () => {
|
||||||
|
const githubToken = `ghp_${'a'.repeat(32)}`;
|
||||||
|
const anthropicKey = `sk-ant-api03-${'b'.repeat(40)}`;
|
||||||
|
const bearerToken = 'header.payload.signature-canary';
|
||||||
|
const databaseUrl = 'postgresql://mosaic:[email protected]/mosaic';
|
||||||
|
const password = 'password-canary';
|
||||||
|
const pi = new FakePi();
|
||||||
|
|
||||||
|
await pi.goal(`set Rotate ${githubToken} without retaining it`);
|
||||||
|
const context = await pi.emit('context', { messages: [] });
|
||||||
|
expect(activeGoalStatementFromContext(context[0])).not.toContain(githubToken);
|
||||||
|
|
||||||
|
const result = await pi.report({
|
||||||
|
status: 'achieved',
|
||||||
|
summary: `Validated ${anthropicKey}`,
|
||||||
|
evidence: [`Authorization: Bearer ${bearerToken}`, `DATABASE_URL=${databaseUrl}`],
|
||||||
|
nextStep: `password=${password}`,
|
||||||
|
});
|
||||||
|
await pi.goal('status');
|
||||||
|
|
||||||
|
const persisted = JSON.stringify(latestGoalStateData(pi));
|
||||||
|
const displayed = pi.notifications.at(-1)?.message ?? '';
|
||||||
|
const toolOutput = JSON.stringify(result);
|
||||||
|
for (const secret of [githubToken, anthropicKey, bearerToken, databaseUrl, password]) {
|
||||||
|
expect(persisted).not.toContain(secret);
|
||||||
|
expect(displayed).not.toContain(secret);
|
||||||
|
expect(toolOutput).not.toContain(secret);
|
||||||
|
}
|
||||||
|
expect(persisted).toContain('[REDACTED-SECRET]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves ordinary typed fields that resemble sensitive assignment names', async () => {
|
||||||
|
const typedFields = 'token: string, password: boolean, secret: false';
|
||||||
|
const pi = new FakePi();
|
||||||
|
|
||||||
|
await pi.goal(`set Preserve TypeScript fields: ${typedFields}`);
|
||||||
|
await pi.report({
|
||||||
|
status: 'continue',
|
||||||
|
summary: `Schema still contains ${typedFields}`,
|
||||||
|
evidence: [`interface Config { ${typedFields} }`],
|
||||||
|
nextStep: `Keep ${typedFields} unchanged`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stateField(pi, 'statement')).toContain(typedFields);
|
||||||
|
expect(JSON.stringify(stateField(pi, 'lastReport'))).toContain(typedFields);
|
||||||
|
expect(JSON.stringify(latestGoalStateData(pi))).not.toContain('[REDACTED-SECRET]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops autonomous continuation when the max-turn limit is reached', async () => {
|
||||||
|
vi.stubEnv('MOSAIC_GOAL_MAX_TURNS', '2');
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Bound this run');
|
||||||
|
pi.sentMessages.length = 0;
|
||||||
|
|
||||||
|
await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] });
|
||||||
|
await pi.emit('turn_end', { turnIndex: 1, message: {}, toolResults: [] });
|
||||||
|
|
||||||
|
expect(stateField(pi, 'phase')).toBe('exhausted');
|
||||||
|
expect(pi.abortCount).toBe(1);
|
||||||
|
await pi.emit('agent_settled');
|
||||||
|
expect(pi.sentMessages).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the no-progress sequence when a continuation report changes', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Track changing progress');
|
||||||
|
|
||||||
|
await pi.report({
|
||||||
|
status: 'continue',
|
||||||
|
summary: 'First checkpoint',
|
||||||
|
evidence: ['file A changed'],
|
||||||
|
nextStep: 'Run focused tests',
|
||||||
|
});
|
||||||
|
await pi.report({
|
||||||
|
status: 'continue',
|
||||||
|
summary: 'Second checkpoint',
|
||||||
|
evidence: ['focused tests passed'],
|
||||||
|
nextStep: 'Review the diff',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stateField(pi, 'phase')).toBe('active');
|
||||||
|
expect(stateField(pi, 'noProgressReports')).toBe(1);
|
||||||
|
await pi.goal('status');
|
||||||
|
expect(pi.notifications.at(-1)?.message).toContain('Next step: Review the diff');
|
||||||
|
expect(pi.notifications.at(-1)?.message).toContain('focused tests passed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops after a bounded number of identical no-progress reports', async () => {
|
||||||
|
vi.stubEnv('MOSAIC_GOAL_MAX_NO_PROGRESS', '2');
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Detect stalled work');
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
status: 'continue',
|
||||||
|
summary: 'No change yet',
|
||||||
|
evidence: ['same observation'],
|
||||||
|
nextStep: 'Try again',
|
||||||
|
};
|
||||||
|
await pi.report(report);
|
||||||
|
await pi.report(report);
|
||||||
|
|
||||||
|
expect(stateField(pi, 'phase')).toBe('exhausted');
|
||||||
|
expect(stateField(pi, 'noProgressReports')).toBe(2);
|
||||||
|
pi.sentMessages.length = 0;
|
||||||
|
await pi.emit('agent_settled');
|
||||||
|
expect(pi.sentMessages).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a goal report mixed with another tool result in the same turn', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Require a sole final report');
|
||||||
|
await pi.report({
|
||||||
|
status: 'achieved',
|
||||||
|
summary: 'Premature mixed claim',
|
||||||
|
evidence: ['one observation'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await pi.emit('turn_end', {
|
||||||
|
turnIndex: 0,
|
||||||
|
message: {},
|
||||||
|
toolResults: [{ toolName: 'mosaic_goal_report' }, { toolName: 'read' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stateField(pi, 'phase')).toBe('active');
|
||||||
|
expect(stateField(pi, 'verificationPasses')).toBe(0);
|
||||||
|
expect(stateField(pi, 'lastCheckOutcome')).toBe('mixed-goal-report-rejected');
|
||||||
|
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks blocked reports terminal until the operator resumes', async () => {
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Stop on a real blocker');
|
||||||
|
|
||||||
|
await pi.report({
|
||||||
|
status: 'blocked',
|
||||||
|
summary: 'Missing required access',
|
||||||
|
evidence: ['provider returned 403'],
|
||||||
|
});
|
||||||
|
expect(stateField(pi, 'phase')).toBe('blocked');
|
||||||
|
|
||||||
|
pi.sentMessages.length = 0;
|
||||||
|
await pi.emit('agent_settled');
|
||||||
|
expect(pi.sentMessages).toHaveLength(0);
|
||||||
|
|
||||||
|
await pi.goal('resume');
|
||||||
|
expect(stateField(pi, 'phase')).toBe('active');
|
||||||
|
expect(stateField(pi, 'turnCount')).toBe(0);
|
||||||
|
expect(pi.sentMessages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Mosaic Pi goal compaction and restoration', () => {
|
||||||
|
it('resets provisional verification and defers manual-compaction continuation until idle', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Survive compaction');
|
||||||
|
await pi.report({
|
||||||
|
status: 'achieved',
|
||||||
|
summary: 'Initial claim',
|
||||||
|
evidence: ['focused test passed'],
|
||||||
|
});
|
||||||
|
expect(stateField(pi, 'phase')).toBe('verifying');
|
||||||
|
pi.sentMessages.length = 0;
|
||||||
|
|
||||||
|
await pi.emit('session_compact', { reason: 'manual', willRetry: false });
|
||||||
|
expect(stateField(pi, 'phase')).toBe('active');
|
||||||
|
expect(stateField(pi, 'verificationPasses')).toBe(0);
|
||||||
|
expect(stateField(pi, 'compactionCount')).toBe(1);
|
||||||
|
expect(stateField(pi, 'lastCheckSource')).toBe('compact');
|
||||||
|
expect(pi.sentMessages).toHaveLength(0);
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(pi.sentMessages).toHaveLength(1);
|
||||||
|
expect(pi.sentMessages[0]?.message.content).toContain('compaction');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-enter an active automatic compaction and relies on the settled backstop', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Avoid compaction races');
|
||||||
|
pi.sentMessages.length = 0;
|
||||||
|
pi.idle = false;
|
||||||
|
|
||||||
|
await pi.emit('session_compact', { reason: 'threshold', willRetry: false });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(pi.sentMessages).toHaveLength(0);
|
||||||
|
|
||||||
|
pi.idle = true;
|
||||||
|
await pi.emit('agent_settled');
|
||||||
|
expect(pi.sentMessages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores branch-specific state on session start and tree navigation', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const source = new FakePi();
|
||||||
|
await source.goal('set Restore this exact branch goal');
|
||||||
|
const activeState = latestGoalStateData(source);
|
||||||
|
|
||||||
|
const restored = new FakePi([
|
||||||
|
{ type: 'custom', customType: 'mosaic-goal-state', data: activeState },
|
||||||
|
]);
|
||||||
|
await restored.emit('session_start', { reason: 'resume' });
|
||||||
|
expect(restored.statuses.get('mosaic-goal')).toContain('active');
|
||||||
|
const context = await restored.emit('context', { messages: [] });
|
||||||
|
expect(activeGoalStatementFromContext(context[0])).toContain('Restore this exact branch goal');
|
||||||
|
|
||||||
|
await restored.goal('pause');
|
||||||
|
const pausedState = latestGoalStateData(restored);
|
||||||
|
restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: pausedState }];
|
||||||
|
await restored.emit('session_tree', {});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(stateField(restored, 'phase')).toBe('paused');
|
||||||
|
expect(restored.sentMessages).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores only fully valid persisted states and ignores malformed entries', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const source = new FakePi();
|
||||||
|
await source.goal('set Validate persisted branch state');
|
||||||
|
await source.report({
|
||||||
|
status: 'continue',
|
||||||
|
summary: 'Valid report',
|
||||||
|
evidence: ['valid evidence'],
|
||||||
|
nextStep: 'Continue validation',
|
||||||
|
});
|
||||||
|
const valid = latestGoalStateData(source);
|
||||||
|
|
||||||
|
const validRestore = new FakePi([
|
||||||
|
{ type: 'custom', customType: 'mosaic-goal-state', data: valid },
|
||||||
|
]);
|
||||||
|
await validRestore.emit('session_start', { reason: 'resume' });
|
||||||
|
expect(stateField(validRestore, 'statement')).toBe('Validate persisted branch state');
|
||||||
|
await validRestore.emit('session_shutdown', { reason: 'reload' });
|
||||||
|
|
||||||
|
const validReport = latestGoalStateData(source)['lastReport'];
|
||||||
|
if (!isRecord(validReport)) throw new Error('Expected a valid persisted report fixture');
|
||||||
|
const integerFields = [
|
||||||
|
'turnCount',
|
||||||
|
'reportCount',
|
||||||
|
'verificationPasses',
|
||||||
|
'requiredVerificationPasses',
|
||||||
|
'noProgressReports',
|
||||||
|
'maxTurns',
|
||||||
|
'maxNoProgressReports',
|
||||||
|
'compactionCount',
|
||||||
|
];
|
||||||
|
const corruptions: Array<(state: Record<string, unknown>) => unknown> = [
|
||||||
|
(): unknown => null,
|
||||||
|
(state): unknown => ({ ...state, version: 99 }),
|
||||||
|
(state): unknown => ({ ...state, goalId: '' }),
|
||||||
|
(state): unknown => ({ ...state, statement: '' }),
|
||||||
|
(state): unknown => ({ ...state, statement: 'x'.repeat(8_001) }),
|
||||||
|
(state): unknown => ({ ...state, phase: 'unknown' }),
|
||||||
|
(state): unknown => ({ ...state, lastCheckSource: 'unknown' }),
|
||||||
|
(state): unknown => ({ ...state, startedAt: 4 }),
|
||||||
|
(state): unknown => ({ ...state, lastCheckAt: 4 }),
|
||||||
|
(state): unknown => ({ ...state, lastReport: null }),
|
||||||
|
(state): unknown => ({ ...state, lastProgressFingerprint: 4 }),
|
||||||
|
(state): unknown => ({ ...state, stopReason: 4 }),
|
||||||
|
...integerFields.map((field: string) => (state: Record<string, unknown>): unknown => ({
|
||||||
|
...state,
|
||||||
|
[field]: -1,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
corruptions.push(
|
||||||
|
(state: Record<string, unknown>): unknown => ({ ...state, maxTurns: 501 }),
|
||||||
|
(state: Record<string, unknown>): unknown => ({ ...state, maxNoProgressReports: 101 }),
|
||||||
|
(state: Record<string, unknown>): unknown => ({ ...state, requiredVerificationPasses: 3 }),
|
||||||
|
);
|
||||||
|
const reportCorruptions: Array<Record<string, unknown>> = [
|
||||||
|
{ ...validReport, status: 'bad' },
|
||||||
|
{ ...validReport, summary: '' },
|
||||||
|
{ ...validReport, evidence: 'bad' },
|
||||||
|
{ ...validReport, evidence: [4] },
|
||||||
|
{ ...validReport, fingerprint: '' },
|
||||||
|
{ ...validReport, reportedAt: '' },
|
||||||
|
{ ...validReport, nextStep: 4 },
|
||||||
|
];
|
||||||
|
for (const corruptReport of reportCorruptions) {
|
||||||
|
corruptions.push((state: Record<string, unknown>): unknown => ({
|
||||||
|
...state,
|
||||||
|
lastReport: corruptReport,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const corrupt of corruptions) {
|
||||||
|
const candidate = corrupt(structuredClone(valid));
|
||||||
|
const restored = new FakePi([
|
||||||
|
{ type: 'custom', customType: 'mosaic-goal-state', data: candidate },
|
||||||
|
]);
|
||||||
|
await restored.emit('session_start', { reason: 'resume' });
|
||||||
|
expect(restored.statuses.get('mosaic-goal')).toBeUndefined();
|
||||||
|
expect(await restored.emit('context', { messages: [] })).toEqual([undefined]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failClosed = new FakePi([
|
||||||
|
{ type: 'custom', customType: 'mosaic-goal-state', data: valid },
|
||||||
|
{ type: 'custom', customType: 'mosaic-goal-state', data: { ...valid, version: 99 } },
|
||||||
|
]);
|
||||||
|
await failClosed.emit('session_start', { reason: 'resume' });
|
||||||
|
expect(failClosed.statuses.get('mosaic-goal')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed instead of reusing credential-bearing legacy branch state', async () => {
|
||||||
|
const source = new FakePi();
|
||||||
|
await source.goal('set Build a valid restore fixture');
|
||||||
|
const cleanState = latestGoalStateData(source);
|
||||||
|
const legacyState = structuredClone(cleanState);
|
||||||
|
legacyState['statement'] = `Legacy secret ghp_${'z'.repeat(32)}`;
|
||||||
|
|
||||||
|
const restored = new FakePi([
|
||||||
|
{ type: 'custom', customType: 'mosaic-goal-state', data: legacyState },
|
||||||
|
{ type: 'custom', customType: 'mosaic-goal-state', data: cleanState },
|
||||||
|
]);
|
||||||
|
await restored.emit('session_start', { reason: 'resume' });
|
||||||
|
|
||||||
|
expect(restored.statuses.get('mosaic-goal')).toBeUndefined();
|
||||||
|
expect(await restored.emit('context', { messages: [] })).toEqual([undefined]);
|
||||||
|
expect(restored.notifications.at(-1)?.level).toBe('warning');
|
||||||
|
expect(restored.notifications.at(-1)?.message).toContain('was not restored');
|
||||||
|
expect(restored.entries).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules an active tree-restored goal and preserves terminal state through compaction', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const source = new FakePi();
|
||||||
|
await source.goal('set Restore active tree work');
|
||||||
|
const active = latestGoalStateData(source);
|
||||||
|
|
||||||
|
const restored = new FakePi();
|
||||||
|
restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: active }];
|
||||||
|
await restored.emit('session_tree', {});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(restored.sentMessages).toHaveLength(1);
|
||||||
|
expect(restored.sentMessages[0]?.message.content).toContain('tree navigation');
|
||||||
|
|
||||||
|
await restored.goal('cancel');
|
||||||
|
await restored.emit('session_compact', { reason: 'manual', willRetry: false });
|
||||||
|
expect(stateField(restored, 'phase')).toBe('cancelled');
|
||||||
|
expect(stateField(restored, 'compactionCount')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels deferred continuation when the session shuts down', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const pi = new FakePi();
|
||||||
|
await pi.goal('set Do not leak a stale timer');
|
||||||
|
pi.sentMessages.length = 0;
|
||||||
|
|
||||||
|
await pi.emit('session_compact', { reason: 'manual', willRetry: false });
|
||||||
|
await pi.emit('session_shutdown', { reason: 'reload' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(pi.sentMessages).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[
|
||||||
|
"packages/mosaic/framework/tools/wake/test-wake-digest-quarantine.sh:fixture_line=\"$(has_match -F \"\\\"id\\\":\\\"$enum_id\\\"\" \"$self\" | has_match -F '\"observed_seq\":5' | head -n1)\"",
|
||||||
|
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||||
|
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:src_seq=\"$(jq -r 'select(.locators.kind == \"repo\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||||
|
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||||
|
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:enum_seq=\"$(jq -r 'select(.locators.reconciled == true) | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:if ! sed -n \"${ln}p\" \"$f\" | grep -Eq 'has_match|count_lines'; then",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outA\" | grep -q 'mini-a: OK' &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outB\" | grep -q 'mini-b: OK' &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:check C1 1 \"rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff \"$TMP/expected-c1\" \"$TMP/got-c1\" 2>&1 | head -n 10 | tr '\\n' ' ')\"",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: FAILED' &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ABORT\" &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"$site\" &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"grep exit 2\" &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'WAKE-ASSERT ARMED'; then",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'REACHED-PAST-INIT' &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'all invariants passed' &&",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:diff \"$TMP/expected.txt\" \"$TMP/static.txt\" | head -n 20 | sed 's/^/ /'",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:if printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$s\")\"; then",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" ||",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit\" ||",
|
||||||
|
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$f\")\" || rc_sent=$?"
|
||||||
|
]
|
||||||
@@ -8,6 +8,7 @@ import test from 'node:test';
|
|||||||
const ROOT = new URL('../', import.meta.url);
|
const ROOT = new URL('../', import.meta.url);
|
||||||
const EXPECTED_BASELINE_SITES = 26;
|
const EXPECTED_BASELINE_SITES = 26;
|
||||||
const EXPECTED_TEST_BASELINE_SITES = 22;
|
const EXPECTED_TEST_BASELINE_SITES = 22;
|
||||||
|
const EXPECTED_WAKE_BASELINE_SITES = 26;
|
||||||
const TARGETS = [
|
const TARGETS = [
|
||||||
'tools/matrix-presence-harness/run.sh',
|
'tools/matrix-presence-harness/run.sh',
|
||||||
'tools/e2e-install-test.sh',
|
'tools/e2e-install-test.sh',
|
||||||
@@ -37,6 +38,10 @@ const TARGETS = [
|
|||||||
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh',
|
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh',
|
||||||
'packages/mosaic/framework/tools/tmux/test-send-message-socket.sh',
|
'packages/mosaic/framework/tools/tmux/test-send-message-socket.sh',
|
||||||
'packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh',
|
'packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh',
|
||||||
|
'packages/mosaic/framework/tools/wake/test-wake-digest-quarantine.sh',
|
||||||
|
'packages/mosaic/framework/tools/wake/test-wake-preimage.sh',
|
||||||
|
'packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh',
|
||||||
|
'packages/mosaic/framework/tools/wake/validate-973/validate-973.sh',
|
||||||
];
|
];
|
||||||
|
|
||||||
// These statuses are explicitly non-load-bearing or unreachable at designed input.
|
// These statuses are explicitly non-load-bearing or unreachable at designed input.
|
||||||
@@ -106,6 +111,14 @@ test('the registered test baseline denominator is exactly 22 unsafe sites', asyn
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the registered wake baseline denominator is exactly 26 unsafe sites', async () => {
|
||||||
|
await assertBaselineFixture(
|
||||||
|
'scripts/fixtures/pipefail-early-exit-wake-baseline.json',
|
||||||
|
EXPECTED_WAKE_BASELINE_SITES,
|
||||||
|
25,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('load-bearing pipefail paths do not pipe into early-exiting consumers', async () => {
|
test('load-bearing pipefail paths do not pipe into early-exiting consumers', async () => {
|
||||||
assert.deepEqual(scan(await currentSources()), []);
|
assert.deepEqual(scan(await currentSources()), []);
|
||||||
});
|
});
|
||||||
@@ -138,6 +151,40 @@ test('gateway verify capability preserves the complete help-probe truth table',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('wake JSONL selectors take the first match across the complete input stream', async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL('packages/mosaic/framework/tools/wake/test-wake-preimage.sh', ROOT),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.equal((source.match(/jq -nr 'first\(inputs \| select\(/g) ?? []).length, 4);
|
||||||
|
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), 'wake-jsonl-first-'));
|
||||||
|
const input = path.join(directory, 'pending.jsonl');
|
||||||
|
const filter = 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty';
|
||||||
|
try {
|
||||||
|
await writeFile(
|
||||||
|
input,
|
||||||
|
'{"locators":{"kind":"repo"},"observed_seq":1}\n' +
|
||||||
|
'{"locators":{"kind":"preimage"},"observed_seq":4}\n' +
|
||||||
|
'{"locators":{"kind":"preimage"},"observed_seq":9}\n',
|
||||||
|
);
|
||||||
|
let result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stdout, '4\n');
|
||||||
|
|
||||||
|
await writeFile(input, '{"locators":{"kind":"repo"},"observed_seq":1}\n');
|
||||||
|
result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stdout, '');
|
||||||
|
|
||||||
|
await writeFile(input, '{invalid json}\n');
|
||||||
|
result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('board-history preserves non-git data-dir as a non-detectable result', async () => {
|
test('board-history preserves non-git data-dir as a non-detectable result', async () => {
|
||||||
const directory = await mkdtemp(path.join(tmpdir(), 'reflect-board-non-git-'));
|
const directory = await mkdtemp(path.join(tmpdir(), 'reflect-board-non-git-'));
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user