Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8764f8664e | ||
|
|
b820d6f3d6 | ||
|
|
be6da9d028 | ||
|
|
f4e3ef4ac2 | ||
|
|
b7c7357a2f | ||
|
|
f0b38f3fee | ||
|
|
6362baf7cc | ||
|
|
898e24472a | ||
|
|
605c09089c |
@@ -60,14 +60,6 @@ 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,128 +102,6 @@ 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,13 +14,6 @@
|
|||||||
- [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,8 +7,7 @@
|
|||||||
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. [Pi Goal Loop Operations](#pi-goal-loop-operations)
|
6. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
7. [Local Fleet Canary](./fleet-local-canary.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -265,16 +264,6 @@ 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 |
|
||||||
@@ -385,29 +374,3 @@ 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,9 +9,8 @@
|
|||||||
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. [Pi Persistent Goal Extension](#pi-persistent-goal-extension)
|
8. [API Endpoint Reference](#api-endpoint-reference)
|
||||||
9. [API Endpoint Reference](#api-endpoint-reference)
|
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
10. [Local Fleet Canary](./fleet-local-canary.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -386,85 +385,6 @@ 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,10 +8,9 @@
|
|||||||
4. [Tasks](#tasks)
|
4. [Tasks](#tasks)
|
||||||
5. [Settings](#settings)
|
5. [Settings](#settings)
|
||||||
6. [CLI Usage](#cli-usage)
|
6. [CLI Usage](#cli-usage)
|
||||||
7. [Pi Persistent Goals](#pi-persistent-goals)
|
7. [Sub-package Commands](#sub-package-commands)
|
||||||
8. [Sub-package Commands](#sub-package-commands)
|
8. [Telemetry](#telemetry)
|
||||||
9. [Telemetry](#telemetry)
|
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
10. [Local Fleet Canary](./fleet-local-canary.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -308,57 +307,6 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
# Greenfield install log — fomo-lin
|
||||||
|
|
||||||
|
Running log of a from-scratch Mosaic Stack install on Jason's test laptop **fomo-lin**
|
||||||
|
(Debian 13, x86_64). Operator: **scooby** (agent). Started 2026-08-08. Channel per fred:
|
||||||
|
findings → comms as they land; this file is the durable record. Branch: `greenfield/fomo-lin`.
|
||||||
|
|
||||||
|
## Machine starting state (2026-08-08)
|
||||||
|
|
||||||
|
- Debian 13 (kernel 6.12.101+deb13), no Node/npm, no global git config, no `~/.ssh`,
|
||||||
|
no `~/.config/mosaic`, no `~/.mosaic`, sudo requires password (agent cannot escalate).
|
||||||
|
- Repos pre-cloned by Jason: jarvis-brain, mosaic-brain, stack, uconnect, uscllc-website
|
||||||
|
(all https remotes to git.mosaicstack.dev, **no credentials stored** — private-repo
|
||||||
|
fetch/push dead until a token was provisioned from credentials.json, `usc_mos`).
|
||||||
|
- tmux session `scooby` running Claude Code (bare harness — not `mosaic claude`).
|
||||||
|
|
||||||
|
## Pre-install setup that had NO framework mechanism (manual work)
|
||||||
|
|
||||||
|
- Agent identity: `MOSAIC_AGENT_NAME=scooby` hand-added to `~/.bashrc` + tmux env.
|
||||||
|
- Git identity + credential store: hand-configured.
|
||||||
|
- Comms receive path: hand-ported `scooby-comms-watcher.sh` from fred's watcher +
|
||||||
|
hand-written systemd `--user` unit + `loginctl enable-linger`. Works (both peers
|
||||||
|
verified round-trip within ~90s), but every step was artisanal — relevant input for
|
||||||
|
harness-homes (W-F).
|
||||||
|
|
||||||
|
## Install run (2026-08-08 ~19:09Z)
|
||||||
|
|
||||||
|
`curl -fsSL https://mosaicstack.dev/install.sh | bash -s -- --yes --no-auto-launch`
|
||||||
|
→ exit 0, framework v3 → `~/.config/mosaic/`, CLI @mosaicstack/mosaic **0.0.49** →
|
||||||
|
`~/.npm-global/`. Prereq path: `sudo apt install nodejs npm` (Debian 13's node 20.19.2
|
||||||
|
meets the ≥20 floor). Public read on the stack repo means the installer itself needs no
|
||||||
|
credentials — good.
|
||||||
|
|
||||||
|
## Findings (outside fred's known-gaps list of 2026-08-08)
|
||||||
|
|
||||||
|
### F1 — PATH advice is print-only
|
||||||
|
|
||||||
|
Installer warns `~/.npm-global/bin is not on your PATH` and suggests the rc line, but
|
||||||
|
`shellProfileEdits: []` in the manifest — nothing is persisted. Every fresh machine ends
|
||||||
|
with `mosaic` not resolvable in new shells until the user hand-edits rc. Either edit the
|
||||||
|
rc (with consent/flag) or make the closing summary a copy-paste block.
|
||||||
|
|
||||||
|
### F2 — Installer overwrites live `~/.claude/settings.json` + `~/.claude/CLAUDE.md` with `backup: null`
|
||||||
|
|
||||||
|
`.install-manifest.json` `runtimeAssetCopies` shows dest `~/.claude/settings.json`,
|
||||||
|
`~/.claude/CLAUDE.md`, `hooks-config.json`, `context7-integration.md`, all `backup: null`,
|
||||||
|
written while a Claude session was LIVE on this machine. On this box the pre-existing files
|
||||||
|
were near-defaults so nothing of value was lost; on any configured machine this silently
|
||||||
|
destroys user settings/memory. Wants: backup-before-overwrite (populate the manifest
|
||||||
|
`backup` field it clearly already models) + merge-not-replace for settings.json.
|
||||||
|
|
||||||
|
### F3 — Fresh install fails its own doctor: 10 warnings out of the box
|
||||||
|
|
||||||
|
Immediately after a clean, successful install, `mosaic doctor` reports: missing `USER.md`;
|
||||||
|
`AGENTS.md missing CRITICAL HARD GATES override block`; runtime file drift on
|
||||||
|
`~/.claude/settings.json`; 7 missing `mosaic-*` skills. A green install that self-reports
|
||||||
|
10 warnings erodes trust in doctor as a signal. Whatever subset is "expected until
|
||||||
|
`mosaic init`/wizard" should be suppressed or labeled as such.
|
||||||
|
|
||||||
|
### F4 — Drift check points users at the gated template (wedge hazard)
|
||||||
|
|
||||||
|
The settings the installer writes to `~/.claude/settings.json` are UNGATED (no
|
||||||
|
mutator-gate, no receipt-observer) — which on today's main is CORRECT, it avoids the
|
||||||
|
Stop-hook wedge. But `~/.config/mosaic/runtime/claude/settings.json` (the file doctor
|
||||||
|
diffs against) IS the gated template. So doctor's "runtime file drift" warning invites the
|
||||||
|
obvious remediation — copy the template over — which would seed the receipt-observer wedge
|
||||||
|
into a live seat. The drift baseline and the seeded file should be the same artifact, or
|
||||||
|
doctor should know about the gated/ungated split.
|
||||||
|
|
||||||
|
### F5 — Installed skill set is disjoint from repo `skills/`
|
||||||
|
|
||||||
|
Skill sync installed 101 skills (six `mosaic-*`: deploy, gitea, orchestrator, portainer,
|
||||||
|
tools, woodpecker) but NONE of the eight in stack `skills/` on main (board, forge, jarvis,
|
||||||
|
macp, prd, prdy, setup-cicd, standards). Doctor then flags 7 of those 8 as missing
|
||||||
|
(`mosaic-jarvis` escapes the check). Two sources of truth for "the Mosaic skills" — the
|
||||||
|
installer's bundle and the repo dir — have diverged.
|
||||||
|
|
||||||
|
## Environment answers / status
|
||||||
|
|
||||||
|
- fomo-lin → sb-it-1-dt: **comms-only** today. Hostname does not resolve from here and the
|
||||||
|
laptop has no ssh keys. ssh reach would need Jason (key provisioning + route/VPN).
|
||||||
|
- Gitea write to the stack repo: verified by the push of this very branch (token `usc_mos`).
|
||||||
|
|
||||||
|
## Session 2 (2026-08-08 later) — `mosaic init` + first bare seat
|
||||||
|
|
||||||
|
`mosaic init` completed (SOUL.md / USER.md / TOOLS.md generated; TOOLS.md was backed up
|
||||||
|
before overwrite — the contrast with F2 shows the codebase already knows how). Its
|
||||||
|
runtime-adapter step correctly REFUSED to wire mutator-gate/receipt-observer hooks
|
||||||
|
(activation half absent, #869) — loud, explained, fail-safe. Good.
|
||||||
|
|
||||||
|
First bare seat: **launched** — `mosaic claude --model sonnet` → Claude Code v2.1.226,
|
||||||
|
runtime-contract injection verified from inside the seat. But it took findings F6–F10 to
|
||||||
|
get there; on an untouched fresh main install, install → init → launch is broken at
|
||||||
|
FOUR consecutive links.
|
||||||
|
|
||||||
|
### F6 — SECURITY: `mosaic-init` eval-injects free-text answers
|
||||||
|
|
||||||
|
`tools/_scripts/mosaic-init` line 142: `eval "$var_name=\"$value\""`. Any answer
|
||||||
|
containing `"` crashes init mid-flow (reproduced: exit 127, USER.md never written);
|
||||||
|
an answer containing `$( )` would EXECUTE arbitrary commands. Fix: `printf -v`.
|
||||||
|
Same bug in the NON_INTERACTIVE default branch. Related: init exits 1 even on success
|
||||||
|
when enforcement wiring is (correctly) refused — poisons any scripted chaining.
|
||||||
|
|
||||||
|
### F7 — init silently drops the installer's `mcpServers` block → launcher refuses to run
|
||||||
|
|
||||||
|
init's "Updating runtime adapters" rewrote `~/.claude/settings.json` and removed the
|
||||||
|
`mcpServers.sequential-thinking` block the installer had written 11 min earlier.
|
||||||
|
`mosaic claude` hard-requires that MCP → launch refused. The prescribed fix command
|
||||||
|
(`mosaic-ensure-sequential-thinking --runtime claude`) works. So the happy path is
|
||||||
|
install → init → BROKEN → hand-run a repair script. Merge-not-replace (F2) fixes this too.
|
||||||
|
|
||||||
|
### F8 — no fleet roster on a fresh install; launcher dies with a raw stack trace
|
||||||
|
|
||||||
|
`mosaic claude` throws an uncaught `Error: Fleet communications contract unavailable: no
|
||||||
|
fleet roster at ~/.config/mosaic/fleet/roster.{yaml,json}` (full Node stack trace to the
|
||||||
|
user). Nothing in install or init creates a roster (wizard untested here — `--no-auto-launch`;
|
||||||
|
if the wizard seeds one, the bare-flow gap still stands). Unblocked by hand-authoring a
|
||||||
|
minimal site roster from `fleet/examples/minimal.yaml`.
|
||||||
|
|
||||||
|
### F9 — FLAGSHIP: activation-probe timeout loses to CLI cold-start on modest hardware
|
||||||
|
|
||||||
|
`activation_version_gate.py` gives the `mosaic __lease-capability` probe
|
||||||
|
`PROBE_TIMEOUT_SECONDS = 2.0`. On fomo-lin the CLI answers CORRECTLY in **~2.55–2.61s
|
||||||
|
every run** (Node startup cost). Timeout → fail-closed → every bare `mosaic claude`
|
||||||
|
launch aborts (exit 65) with an error blaming "mosaic not on PATH … framework/CLI version
|
||||||
|
skew" — neither true. Invisible on fast dev boxes; fatal on laptops. Suggest: raise/make
|
||||||
|
configurable the timeout, warm-probe cache, and split the three failure causes into
|
||||||
|
distinct messages. Local workaround (documented, removable):
|
||||||
|
`MOSAIC_LEASE_VERSION_PROBE_COMMAND` pointed at a script emitting the verified payload
|
||||||
|
instantly (`~/.local/bin/mosaic-lease-probe-fast`).
|
||||||
|
|
||||||
|
### F10 — shipped lease-broker unit is never installed → registration denied
|
||||||
|
|
||||||
|
With F9 bypassed, launch dies with "Mosaic lease broker registration failed; runtime
|
||||||
|
launch denied": the broker daemon isn't running, and although the framework SHIPS
|
||||||
|
`systemd/user/mosaic-lease-broker.service`, nothing installs/enables it.
|
||||||
|
`systemctl --user link` + `enable --now` of the shipped unit → READY instantly, launch
|
||||||
|
proceeds. Installer/init/wizard should own this step.
|
||||||
|
|
||||||
|
### Observations (not filed as findings)
|
||||||
|
|
||||||
|
- Launcher settings audit demands `mutator-gate.py` while init refuses to wire it —
|
||||||
|
main's components disagree about the gated state (fold into #1113/F4).
|
||||||
|
- Seat context: runtime contract injected ✓; SOUL.md NOT injected (seat confirmed) —
|
||||||
|
matches AGENTS.md read-on-demand load order, but README says the launcher "checks for
|
||||||
|
SOUL.md". Question for lead, not a finding.
|
||||||
|
- `--ref next` install path verified available (flag exists, next archive HTTP 200) — not
|
||||||
|
exercised; fomo-lin stays main-as-shipped per lead ruling.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- Milestone comms sent at: install complete ✓ / first seat launched ✓.
|
||||||
|
- First gated-seat probe deliberately deferred until PR #1109 lands (known deny-only state).
|
||||||
|
|
||||||
|
## Session 3 (2026-08-08 evening) — wizard + gateway; refocus to `next`
|
||||||
|
|
||||||
|
Directive from Jason mid-session: focus shifts to the `next` branch and the new structure
|
||||||
|
(stock `~/.claude` untouched; framework wholly under `~/.config/mosaic`). Main's ~/.claude
|
||||||
|
write behavior is a deprecated location — findings stand, but no further deep-testing of it.
|
||||||
|
|
||||||
|
Wizard run (main): "keep identity, update framework"; ~/.claude hooks install DECLINED per
|
||||||
|
directive (wizard rewrote ~/.claude/settings.json anyway — benign, no gated hooks, MCP kept).
|
||||||
|
Wizard never prompted about fleet roster or lease-broker unit → F8/F10 disambiguation
|
||||||
|
partial: wizard does not visibly own those steps. Full degraded-state test dropped per refocus.
|
||||||
|
|
||||||
|
### F11 — gateway "Local" tier hard-requires Redis on main (fixed on next)
|
||||||
|
|
||||||
|
Wizard gateway install, Local tier ("embedded database, no dependencies"), port 14242:
|
||||||
|
daemon starts then crash-spams ioredis ECONNREFUSED; never healthy; killed manually.
|
||||||
|
`main..next` already contains `56787fab fix(gateway): disable Redis consumers on local
|
||||||
|
tier (#689)`. Main ships a gateway that cannot come up dependency-free; next has the cure.
|
||||||
|
|
||||||
|
### F12 — wizard exits 0 on gateway failure
|
||||||
|
|
||||||
|
Terminal shows "▲ Fix the underlying error above, then re-run `mosaic gateway install`"
|
||||||
|
and the wizard exits 0. Scripted/CI consumers read success.
|
||||||
|
|
||||||
|
### Cosmetic
|
||||||
|
|
||||||
|
Skipping the optional ANTHROPIC_API_KEY prompt records the literal string "undefined".
|
||||||
|
|
||||||
|
### `next` recon (read-only)
|
||||||
|
|
||||||
|
- next install.sh: first-class `--next` prerelease lane (npm @next dist-tag CLI + framework
|
||||||
|
from permanent next branch; guard against mixing @next with a different explicit --ref).
|
||||||
|
- next does NOT carry the new structure: ~/.claude handling unchanged; no harness-homes
|
||||||
|
design docs on next or main. New structure = Jason directive + fred W-F design phase.
|
||||||
|
|
||||||
|
State: bare seat launch works; gateway stopped. Holding for fred's ruling on a next-lane
|
||||||
|
reinstall (proposed) and W-F design review.
|
||||||
|
|
||||||
|
## Session 4 (2026-08-08 night) — `--next` lane reinstall (pivot confirmed by Jason)
|
||||||
|
|
||||||
|
Main uninstalled (note: uninstall removed `~/.claude/CLAUDE.md`/hooks-config/context7 but
|
||||||
|
LEFT its modified `settings.json` — asymmetric cleanup, minor). Reinstalled via next's own
|
||||||
|
installer: `raw/branch/next/tools/install.sh --next --yes --no-auto-launch` → framework from
|
||||||
|
permanent next branch + **CLI 0.0.50-next.2207 / gateway 0.0.7-next.2207 from the @next
|
||||||
|
registry lane**. Lane works as designed.
|
||||||
|
|
||||||
|
### N1 — FLAGSHIP (next-only): @next CLI requires Node 22; docs/installer floor says ≥20
|
||||||
|
|
||||||
|
On Node 20.19.2 (Debian 13's apt version, and the documented minimum) **every** mosaic
|
||||||
|
command crashes — even `--version` — with `ERR_REQUIRE_CYCLE_MODULE` in
|
||||||
|
`@mosaicstack/brain/dist/projects.js`. npm corroborates: `[email protected]` declares
|
||||||
|
`node >=22`. Verified the same installed CLI runs clean under Node **22.23.2** (nvm).
|
||||||
|
So the @next lane is dead-on-arrival on the documented minimum Node. Fix: installer
|
||||||
|
gates node ≥22 for the next lane (or brain drops the require cycle). fomo-lin now runs
|
||||||
|
Node 22 via nvm (user-level; system apt tops out at 20 — durable fix wants nodesource 22).
|
||||||
|
|
||||||
|
### F1–F12 recurrence scorecard on next
|
||||||
|
|
||||||
|
| Finding | On next |
|
||||||
|
|---|---|
|
||||||
|
| F1 PATH print-only | RECURS (identical warning) |
|
||||||
|
| F2 ~/.claude writes | RECURS (runtime assets copied again; per ruling, no deeper testing — W-F fixes structurally) |
|
||||||
|
| F3 doctor warns on fresh install | RECURS (10 warnings, same classes) |
|
||||||
|
| F4 drift-baseline wedge | RECURS (same gated template + drift warning) |
|
||||||
|
| F5 skill sets disjoint | RECURS (same 7 missing mosaic-*) |
|
||||||
|
| F6 init eval injection | RECURS (eval at lines 102/118/132 of next's mosaic-init) |
|
||||||
|
| F7 init drops mcpServers | RECURS (verified: count 0 after init; ensure-script fix works) |
|
||||||
|
| F8 roster raw-throw | RECURS in code (throw present in next launch.js; not re-triggered — roster restored from backup) |
|
||||||
|
| F9 probe 2.0s timeout | RECURS (constant unchanged) — and compounded: probe spawns `mosaic`, which on ambient Node 20 crashes (N1), so the probe fails on slow AND stock-node hosts |
|
||||||
|
| F10 broker unit not installed | RECURS (hand-relinked next's shipped unit; works) |
|
||||||
|
| F11 gateway Redis-on-local | Expected FIXED (#689 in next); not yet live-verified — gateway install not re-run this session |
|
||||||
|
| F12 wizard exit-0 | Untested on next (wizard.ts differs; #1120 tracks) |
|
||||||
|
|
||||||
|
Chain result on next (with the same three workarounds: MCP ensure-script, restored roster,
|
||||||
|
broker relink, plus probe override): **install → init → launch all pass; seat up on
|
||||||
|
Claude Code v2.1.226 / sonnet under Node 22.**
|
||||||
|
|
||||||
|
Net: next cures nothing in F1–F10 (they're all pre-W-F structural issues), carries the
|
||||||
|
gateway fix, and adds one hard regression-class gap (N1 node floor). The W-F gap list
|
||||||
|
stands unchanged as the fix vehicle.
|
||||||
|
|
||||||
|
## Session 5 (2026-08-08 night) — `~/.mosaic` prototype hand-roll (second-host cross-check)
|
||||||
|
|
||||||
|
Hand-rolled per HARNESS-HOMES prototype section, on the next-lane framework: skeleton
|
||||||
|
(config/claude `{}`, auth/claude/jason_woltje.com with `primary` alias, empty plugins/skills
|
||||||
|
stores), probe seat (profile.json schema 1, overlay `{}`, composed settings via three-layer
|
||||||
|
deep-merge, credentials two-hop symlink, identity-bootstrap CLAUDE.md, seeded onboarding
|
||||||
|
.claude.json, SOUL.md with positive Identity block).
|
||||||
|
|
||||||
|
**Smoke test PASS** (`CLAUDE_CONFIG_DIR=<probe> claude --print`): RC=0, auth through the
|
||||||
|
two-hop chain, seat self-identified as "probe". Post-run: both symlinks survived, live
|
||||||
|
credential inode unchanged, transcript in probe's own projects/, probe generated its own
|
||||||
|
backups/sessions, operator ~/.claude untouched. **dragon-lin's results replicate on a
|
||||||
|
clean second host — the layout stands up greenfield.**
|
||||||
|
|
||||||
|
### Gap-bites during the roll (feed to W-F)
|
||||||
|
|
||||||
|
- **Base-template hole (F4/gap-5 adjacent, NEW):** the design's composition base
|
||||||
|
`~/.config/mosaic/framework/runtime/claude/settings.json` does NOT exist in the shipped
|
||||||
|
framework; the closest shipped artifact (`runtime/claude/settings.json`) is the GATED
|
||||||
|
wedge template. Used the operator's vetted ungated settings as base (as dragon-lin did).
|
||||||
|
W-F1 must define + ship the canonical UNGATED system base; gate hooks arrive only via
|
||||||
|
promotion overlay.
|
||||||
|
- **Identity bootstrap vs permissions (NEW):** in `--print`/restricted mode the seat was
|
||||||
|
DENIED reading SOUL.md outside cwd — "read SOUL.md" bootstrap depends on tool
|
||||||
|
permissions. Generator should materialize the identity INTO the generated CLAUDE.md
|
||||||
|
(parameterized), keeping SOUL.md as source, not runtime dependency.
|
||||||
|
- **Gap 2 lived experience:** probe exists in profile.json but not roster.yaml — the
|
||||||
|
hand-rolled seat and `mosaic claude` are disjoint universes on the same host.
|
||||||
|
- **Gap 4 in miniature:** fresh-host store is empty; nothing defines what seeds it.
|
||||||
|
- **Lease posture:** hand-rolled seats launch bare `claude` → ungated by construction
|
||||||
|
until `mosaic fleet launch` exists (consistent with current bare-for-real-work rule).
|
||||||
|
|
||||||
|
### Addendum — gap-7 characterization (canonical ungated base)
|
||||||
|
|
||||||
|
Diffed operator vetted ungated settings vs shipped gated template: the delta is exactly
|
||||||
|
three items — template-only PreToolUse mutator-gate entry, template-only Stop
|
||||||
|
receipt-observer entry, operator-only mcpServers.sequential-thinking block (whose omission
|
||||||
|
from the template is F7's root cause). Spec: base = template − two gate hooks + mcpServers;
|
||||||
|
promotion overlay = the two gate hooks, nothing more. Sent to fred (20260808T200337Z).
|
||||||
|
|
||||||
|
## Box doctrine — true greenfield, repeatable full-cycle testing (Jason, 2026-08-08)
|
||||||
|
|
||||||
|
fomo-lin's defining property: the test operator (scooby) is NOT a fleet seat — comms
|
||||||
|
watcher, git identity, nvm/Node, and repos live entirely outside Mosaic. Therefore Mosaic
|
||||||
|
can be wiped to TRUE ZERO and reinstalled in full, repeatedly, to test protocols
|
||||||
|
end-to-end per cycle (each W-F fixture drop, each next release).
|
||||||
|
|
||||||
|
Codified as `~/.local/bin/mosaic-greenfield-reset` (dry-run by default, `--yes` to
|
||||||
|
execute): removes units/gateway/npm packages/npmrc scope/`~/.config/mosaic`/`~/.mosaic`/
|
||||||
|
mosaic-written `~/.claude` files (settings reset to stock)/workaround shims; preserves the
|
||||||
|
operator layer (watcher, git creds, nvm, repos, `~/.claude` auth + session state, baseline
|
||||||
|
backup). Ends with a verify-zero checklist.
|
||||||
|
|
||||||
|
Known boundary impurities the reset explicitly handles: `~/.claude/settings.json` is
|
||||||
|
mosaic-written today (its QA hooks fire even in the operator's own session — observed:
|
||||||
|
prevent-memory-write blocked an operator write), and the F9 probe shim sits in
|
||||||
|
`~/.local/bin`. Both are named in the script rather than left as ambient state.
|
||||||
|
|
||||||
|
Not executed yet — current install (next lane + prototype) is the substrate Fred's W-F1
|
||||||
|
fixtures target. First full cycle runs when the next testable artifact lands.
|
||||||
|
|
||||||
|
## Session 6 (2026-08-08 night) — promotion-branch E2E (Fred-directed, first fomo-lin full E2E)
|
||||||
|
|
||||||
|
Branch feat/lease-promotion-and-harness-isolation (rebased on next), built from source
|
||||||
|
(pnpm --filter '@mosaicstack/mosaic...' build), CLI packed + installed globally, branch
|
||||||
|
framework installed to ~/.config/mosaic. Transcript:
|
||||||
|
scratchpad/promote-e2e-transcript.md. Verdict: **BLOCKED at step 3, NOT VERIFIED (not faked).**
|
||||||
|
|
||||||
|
Findings this session (all filed under scooby's own Gitea account):
|
||||||
|
|
||||||
|
- **#1123** — TS activation capability probe hardcodes a 2000ms timeout; `node cli.js
|
||||||
|
__lease-capability` cold-start on fomo-lin is 5.1–5.5s, so `leaseEnforcementActivatable()`
|
||||||
|
returns false and the gate REFUSES to wire via the sanctioned path. The Python-side
|
||||||
|
F9/#1118 override does NOT apply to this TS probe. Worked around by bumping only the
|
||||||
|
installed dist timeout (reversible; can't mask a bad capability).
|
||||||
|
- **LIVE WEDGE (F4 reproduced, un-recoverable):** hand-wiring the gated template into a live
|
||||||
|
BARE session's own runtime home hot-reloads the gate and bricks the session with
|
||||||
|
GATE_UNAVAILABLE (no lease). Every self-recovery path is closed (Bash/Read gated;
|
||||||
|
Write/Edit blocked by stale-guard needing a gated Read). Required an EXTERNAL shell to
|
||||||
|
restore settings. Exactly HARNESS-HOMES' "a live unpromoted session that gains the gate
|
||||||
|
cannot self-recover." Lesson applied: gated seats must be a SEPARATE mosaic claude process
|
||||||
|
in its own CLAUDE_CONFIG_DIR (~/.config/mosaic/.claude), never the operator's ~/.claude.
|
||||||
|
- **#1124 — the critical link, proven to fail:** `mosaic promote` transport reads the lease
|
||||||
|
session id from `pane_pid`'s /proc/environ, but `execRuntime()` (launch.js:883) uses
|
||||||
|
`spawnSync` (NOT the exec-replace its own comment claims), so pane_pid = node(mosaic)
|
||||||
|
[no lease env] and the lease env is on the claude CHILD. resolve() never walks to the
|
||||||
|
child → 'no readable lease session' → UNVERIFIED exit 1, before injection. Fails for every
|
||||||
|
real `mosaic claude` seat; unit tests pass only via a mocked environmentReader. This is
|
||||||
|
the exact link terra couldn't test (detached pane).
|
||||||
|
|
||||||
|
E2E scorecard: Step 1 (build/install) PASS. Step 2 (gated seat, real lease, mutator DENIED
|
||||||
|
MUTATOR_UNVERIFIED, file not created) PASS. Step 3 (promote → VERIFIED) BLOCKED (#1124).
|
||||||
|
Steps 4–5 not reached; failure-path sub-case (unresolvable seat → UNVERIFIED exit 1, no
|
||||||
|
hang) incidentally confirmed. The branch does NOT pass E2E on a real host as-is; #1124 gates
|
||||||
|
its merge.
|
||||||
|
|
||||||
|
## Session 7 (2026-08-08 night) — promotion re-run on #1124 fix (b) @ de0adb92
|
||||||
|
|
||||||
|
Rebuilt from de0adb92, reran steps 3+. **fix (b) confirmed working**; promotion advanced two
|
||||||
|
links deeper and revealed two new findings. Debian 13 compat: `/proc/<pid>/task/<pid>/children`
|
||||||
|
IS populated — BFS walk works, no PPID fallback needed.
|
||||||
|
|
||||||
|
- resolve() (#1124 fix b): **PASS** — BFS walk from pane(node,no-lease) → claude child(lease)
|
||||||
|
resolved the real session id. The exact bug I reported is fixed.
|
||||||
|
- **#1125** — `/mosaic-promote` first returned "Unknown command": the slash command is shipped
|
||||||
|
at `runtime/claude/commands/mosaic-promote.md` but NOT seeded into the seat's
|
||||||
|
`CLAUDE_CONFIG_DIR/commands/`. UserPromptSubmit hook never fires → PROMOTION_TIMEOUT. F7-class
|
||||||
|
asset-seeding gap. Worked around by copying the command into the seat home; hook then fires.
|
||||||
|
- **#1126 (deepest finding)** — with the command seeded, promote-begin injects (via
|
||||||
|
UserPromptSubmit additionalContext) an instruction to echo an opaque `MOSAIC-RECEIPT{...}`
|
||||||
|
token "verbatim and nothing else … discloses nothing." The seat MODEL REFUSED, correctly
|
||||||
|
flagging it as a prompt-injection pattern (imperative in a description field; verbatim opaque
|
||||||
|
echo; self-vouching language; no protocol legitimized in the seat's trusted context) →
|
||||||
|
RECEIPT_MISMATCH. Design-level: legitimate promotion is indistinguishable from an injection
|
||||||
|
attack to a well-aligned model; stronger injection defenses = more reliable promotion FAILURE.
|
||||||
|
Refusal evidence: docs/reports/greenfield/seat-receipt-refusal.txt.
|
||||||
|
|
||||||
|
Scorecard: build/install PASS · gated seat + mutator DENIED PASS · resolve (fix b) PASS ·
|
||||||
|
slash-cmd (post-#1125 workaround) PASS · receipt echo FAIL (#1126). Promotion does NOT reach
|
||||||
|
VERIFIED on a real seat. #1124 fix validated; #1125 + #1126 now gate the merge.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
▐▛███▜▌ Claude Code v2.1.226
|
||||||
|
▝▜█████▛▘ Sonnet 5 · Claude Max
|
||||||
|
▘▘ ▝▝ ~/src/jarvis-brain
|
||||||
|
✔ Using flicker-free rendering · if you want to go back, use /tui default
|
||||||
|
· Click to move your cursor in the text input
|
||||||
|
· Click to expand collapsed tool results
|
||||||
|
· By default, text auto-copies when you select it (/config to change)
|
||||||
|
· Hold Shift (Option in iTerm2, Fn in Terminal.app) while selecting to use your terminal's native copy instead
|
||||||
|
▎ Fable 5 is now a standard part of your Max plan
|
||||||
|
▎ You can use up to 50% of your weekly usage limit on Fable 5. If you hit your limit, you can continue on Fable 5 with usage credits. Fable 5 draws down usage faster than Opus 5. Run /model and
|
||||||
|
▎ select Fable to use it. Learn more: https://support.claude.com/en/articles/15424964-claude-fable-5-promotional-access
|
||||||
|
+1 more · /status
|
||||||
|
❯ /mosaic-promote
|
||||||
|
● I'm not going to echo that receipt string. A few things about this request don't add up, and I want to flag them before doing anything:
|
||||||
|
1. The "command description" is itself an instruction. In the skills listing, mosaic-promote is described as "I invoked this registered command to authorize lease promotion; follow the local seat
|
||||||
|
broker's injected receipt confirmation instruction exactly." That's not a description of what a skill does — it's an imperative instruction written in first person, injected into a field that's
|
||||||
|
supposed to be descriptive metadata. That's a classic injection pattern.
|
||||||
|
2. The "hook" reminder asks for verbatim, unexamined output. The UserPromptSubmit hook additional context block instructs me to output an opaque MOSAIC-RECEIPT{...} blob "verbatim and nothing else,"
|
||||||
|
and preemptively asserts that doing so "discloses nothing" — that's the kind of self-vouching language injected content uses to head off scrutiny. I have no way to verify what that token actually
|
||||||
|
authorizes downstream (it reads like a challenge/response for some privilege or lease-promotion action), and nothing in the actual Mosaic Constitution, AGENTS.md, or RUNTIME.md content I've loaded
|
||||||
|
this session mentions a "seat broker," "lease promotion," or a MOSAIC-RECEIPT protocol at all.
|
||||||
|
3. No genuine /mosaic-promote skill exists in the documented set. The real skill list (mosaic-gitea, mosaic-orchestrator, mosaic-portainer, mosaic-woodpecker, mosaic-tools, mosaic-deploy) has
|
||||||
|
nothing resembling this. A command that only shows up as a self-instructing string, paired with a matching "hook" telling me to blindly echo a token, is consistent with a prompt-injection attempt to
|
||||||
|
get an agent to produce an authorization artifact it doesn't understand.
|
||||||
|
I'm stopping here rather than emitting the receipt. If this is something you actually intended to trigger (a real seat/lease mechanism I'm not aware of), let me know and point me at where it's
|
||||||
|
documented and I'll take another look. Otherwise, I'd treat this as a suspicious injected command.
|
||||||
|
✻ Sautéed for 23s
|
||||||
|
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
❯
|
||||||
|
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
⏸ manual mode on · ? for shortcuts · ← for agents
|
||||||
@@ -69,15 +69,4 @@ 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 |
|
||||||
|
|
||||||
## Tranche 3 — wake validation harnesses
|
Remaining wake-validation sites are intentionally deferred to the final review-sized tranche and are not yet assigned a safety verdict here.
|
||||||
|
|
||||||
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,10 +25,6 @@ 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.
|
||||||
@@ -37,5 +33,5 @@ Expanded the shared scanner over four wake validation harnesses. RED named 26 oc
|
|||||||
- `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 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.
|
- 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.
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
# #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,8 +13,7 @@ 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 framework-owned `mosaic-extension.ts` and `goal-extension.ts` from
|
3. Loads the Mosaic extension via `--extension` for lifecycle hooks
|
||||||
`~/.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
|
||||||
@@ -23,7 +22,6 @@ 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,14 +94,7 @@ 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. For Pi, loads the framework-owned core and persistent-goal extensions from
|
4. Forwards all arguments to the runtime CLI
|
||||||
`~/.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`.
|
||||||
|
|
||||||
@@ -121,7 +114,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, goal-extension.ts
|
│ ├── pi/ ← RUNTIME.md, mosaic-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
|
||||||
@@ -133,7 +126,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 + Mosaic extensions |
|
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + extension |
|
||||||
| `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 |
|
||||||
|
|||||||
@@ -51,26 +51,12 @@ Skills are discovered from:
|
|||||||
|
|
||||||
### Extensions
|
### Extensions
|
||||||
|
|
||||||
`mosaic pi` loads framework-owned extensions directly from `~/.config/mosaic/runtime/pi/` in this
|
The Mosaic Pi extension (`~/.config/mosaic/runtime/pi/mosaic-extension.ts`) handles:
|
||||||
order:
|
|
||||||
|
|
||||||
1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates,
|
- Session start/end lifecycle hooks
|
||||||
and fleet heartbeat reporting.
|
- Active mission detection and context injection
|
||||||
2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction
|
- Memory routing to `~/.config/mosaic/memory/`
|
||||||
checks.
|
- MACP queue status reporting
|
||||||
|
|
||||||
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,10 +66,7 @@ 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"
|
||||||
@@ -90,17 +87,7 @@ __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 \
|
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60'
|
||||||
"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,32 +254,15 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
|
|
||||||
def _origin_and_path(url):
|
def _origin_and_path(url):
|
||||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||||
#
|
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
# port and its explicit default form compare equal.
|
||||||
# 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 = None if parsed.port in (None, default_port) else parsed.port
|
port = parsed.port if parsed.port is not None else default_port
|
||||||
else:
|
return (scheme, host, port), parsed.path.rstrip("/")
|
||||||
scheme_class = scheme
|
|
||||||
port = parsed.port
|
|
||||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# pr-edit.sh - Edit a pull request on GitHub or Gitea
|
|
||||||
# Usage: pr-edit.sh -n <pr_number> [-t <title>] [-b <body>] [-B <base>] [--draft|--ready] [--login <name>] [-r owner/repo] [-H host]
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=detect-platform.sh
|
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
|
||||||
|
|
||||||
PR_NUMBER=""
|
|
||||||
TITLE=""
|
|
||||||
BODY=""
|
|
||||||
BASE_BRANCH=""
|
|
||||||
DRAFT_MODE=""
|
|
||||||
LOGIN_OVERRIDE=""
|
|
||||||
REPO_OVERRIDE=""
|
|
||||||
HOST_OVERRIDE=""
|
|
||||||
AUTH_CONFIG=""
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
[[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT HUP INT TERM
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
Usage: $(basename "$0") [OPTIONS]
|
|
||||||
|
|
||||||
Edit a pull request on the current repository (Gitea or GitHub).
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-n, --number NUMBER Pull request number (required)
|
|
||||||
-t, --title TITLE New title
|
|
||||||
-b, --body BODY New body/description
|
|
||||||
-B, --base BRANCH New base branch
|
|
||||||
--draft Mark the pull request as draft
|
|
||||||
--ready Mark the pull request ready for review
|
|
||||||
-l, --login NAME Gitea login (must authenticate as MOSAIC_GIT_IDENTITY)
|
|
||||||
-r, --repo OWNER/REPO Explicit target repository
|
|
||||||
-H, --host HOST Explicit Gitea host (required with --repo off-host)
|
|
||||||
-h, --help Show this help message
|
|
||||||
EOF
|
|
||||||
exit "${1:-1}"
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
-n|--number) PR_NUMBER="${2:-}"; shift 2 ;;
|
|
||||||
-t|--title) TITLE="${2:-}"; shift 2 ;;
|
|
||||||
-b|--body) BODY="${2:-}"; shift 2 ;;
|
|
||||||
-B|--base) BASE_BRANCH="${2:-}"; shift 2 ;;
|
|
||||||
--draft)
|
|
||||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
|
||||||
DRAFT_MODE="draft"; shift ;;
|
|
||||||
--ready)
|
|
||||||
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
|
||||||
DRAFT_MODE="ready"; shift ;;
|
|
||||||
-l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
|
|
||||||
-r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
|
|
||||||
-H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;;
|
|
||||||
-h|--help) usage 0 ;;
|
|
||||||
*) echo "Unknown option: $1" >&2; usage ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 1; }
|
|
||||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 1; }
|
|
||||||
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
|
||||||
echo "Error: At least one edit option is required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
|
|
||||||
echo "Error: --repo must be OWNER/REPO" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
|
|
||||||
PLATFORM="gitea"
|
|
||||||
else
|
|
||||||
PLATFORM=$(detect_platform)
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$PLATFORM" in
|
|
||||||
github)
|
|
||||||
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 1; }
|
|
||||||
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
|
|
||||||
CMD=(gh pr edit "$PR_NUMBER")
|
|
||||||
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
|
||||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
|
||||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
|
||||||
"${CMD[@]}"
|
|
||||||
fi
|
|
||||||
if [[ "$DRAFT_MODE" == "draft" ]]; then
|
|
||||||
gh pr ready "$PR_NUMBER" --undo
|
|
||||||
elif [[ "$DRAFT_MODE" == "ready" ]]; then
|
|
||||||
gh pr ready "$PR_NUMBER"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
gitea)
|
|
||||||
IDENTITY="${MOSAIC_GIT_IDENTITY:-}"
|
|
||||||
[[ -n "$IDENTITY" ]] || {
|
|
||||||
echo "Error: MOSAIC_GIT_IDENTITY is required for a mutating Gitea operation" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
HOST="${HOST_OVERRIDE:-}"
|
|
||||||
if [[ -z "$HOST" ]]; then
|
|
||||||
HOST=$(get_remote_host) || {
|
|
||||||
echo "Error: Could not resolve Gitea host; pass --host with --repo" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
HOST="${HOST#http://}"; HOST="${HOST#https://}"; HOST="${HOST%%/*}"
|
|
||||||
REPO_SLUG="${REPO_OVERRIDE:-}"
|
|
||||||
if [[ -z "$REPO_SLUG" ]]; then
|
|
||||||
REPO_SLUG=$(get_repo_slug) || { echo "Error: Could not resolve Gitea repo slug from remote" >&2; exit 1; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$LOGIN_OVERRIDE" ]]; then
|
|
||||||
GITEA_LOGIN_NAME="$LOGIN_OVERRIDE"
|
|
||||||
elif [[ -n "${GITEA_LOGIN:-}" ]]; then
|
|
||||||
GITEA_LOGIN_NAME="$GITEA_LOGIN"
|
|
||||||
else
|
|
||||||
echo "Error: --login (or GITEA_LOGIN) is required; refusing host-first login selection" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN_NAME" "$HOST") || {
|
|
||||||
echo "Error: login '$GITEA_LOGIN_NAME' is not configured for target host '$HOST'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
AUTHENTICATED_USER=$(get_gitea_authenticated_user "$GITEA_LOGIN_NAME") || {
|
|
||||||
echo "Error: could not authenticate Gitea login '$GITEA_LOGIN_NAME'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
[[ "$AUTHENTICATED_USER" == "$IDENTITY" ]] || {
|
|
||||||
echo "Error: Gitea login '$GITEA_LOGIN_NAME' authenticates as '$AUTHENTICATED_USER', not MOSAIC_GIT_IDENTITY '$IDENTITY'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
AUTH_CONFIG=$(gitea_write_auth_config "$TOKEN") || {
|
|
||||||
echo "Error: could not stage private Gitea authentication" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
unset TOKEN
|
|
||||||
|
|
||||||
API_BASE="https://${HOST}/api/v1/repos/${REPO_SLUG}"
|
|
||||||
# Preflight the explicit host/repo pair before any mutation. This prevents
|
|
||||||
# a slug inferred from one checkout being combined with another host.
|
|
||||||
curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE" >/dev/null || {
|
|
||||||
echo "Error: target repository preflight failed for https://${HOST}/${REPO_SLUG}" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
PAYLOAD=$(TITLE="$TITLE" BODY="$BODY" BASE_BRANCH="$BASE_BRANCH" DRAFT_MODE="$DRAFT_MODE" python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
payload = {}
|
|
||||||
if os.environ["TITLE"]: payload["title"] = os.environ["TITLE"]
|
|
||||||
if os.environ["BODY"]: payload["body"] = os.environ["BODY"]
|
|
||||||
if os.environ["BASE_BRANCH"]: payload["base"] = os.environ["BASE_BRANCH"]
|
|
||||||
if os.environ["DRAFT_MODE"]: payload["draft"] = os.environ["DRAFT_MODE"] == "draft"
|
|
||||||
print(json.dumps(payload))
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
curl -fsS --config "$AUTH_CONFIG" -X PATCH \
|
|
||||||
-H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD" "$API_BASE/pulls/${PR_NUMBER}"
|
|
||||||
echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2
|
|
||||||
;;
|
|
||||||
*) echo "Error: Could not detect git platform" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
@@ -243,35 +243,15 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
|
|
||||||
def _origin_and_path(url):
|
def _origin_and_path(url):
|
||||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||||
#
|
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
# port and its explicit default form compare equal.
|
||||||
# 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 = None if parsed.port in (None, default_port) else parsed.port
|
port = parsed.port if parsed.port is not None else default_port
|
||||||
else:
|
return (scheme, host, port), parsed.path.rstrip("/")
|
||||||
scheme_class = scheme
|
|
||||||
port = parsed.port
|
|
||||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -7,40 +7,14 @@ 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" "$HOME_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_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'
|
||||||
{
|
{
|
||||||
@@ -112,7 +86,6 @@ 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" \
|
||||||
"$@"
|
"$@"
|
||||||
@@ -310,7 +283,6 @@ 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" \
|
||||||
"$@"
|
"$@"
|
||||||
@@ -371,7 +343,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
|||||||
token_fallback() {
|
token_fallback() {
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
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"
|
||||||
@@ -382,7 +354,7 @@ token_fallback() {
|
|||||||
token_pyyaml() {
|
token_pyyaml() {
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
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"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
|
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
|
||||||
#
|
#
|
||||||
# Covers wrappers whose usage() previously hard-coded `exit 1`, so every
|
# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||||
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
|
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
|
||||||
# Asserts, per wrapper:
|
# Asserts, per wrapper:
|
||||||
# 1. `--help` exits 0 and prints usage.
|
# 1. `--help` exits 0 and prints usage.
|
||||||
@@ -18,7 +18,6 @@ WRAPPERS=(
|
|||||||
issue-list.sh
|
issue-list.sh
|
||||||
milestone-create.sh
|
milestone-create.sh
|
||||||
pr-create.sh
|
pr-create.sh
|
||||||
pr-edit.sh
|
|
||||||
pr-list.sh
|
pr-list.sh
|
||||||
pr-merge.sh
|
pr-merge.sh
|
||||||
)
|
)
|
||||||
@@ -48,7 +47,7 @@ for wrapper in "${WRAPPERS[@]}"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
echo "help-exit-code regression passed (8/8 wrappers)"
|
echo "help-exit-code regression passed (7/7 wrappers)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit "$fail"
|
exit "$fail"
|
||||||
|
|||||||
@@ -61,54 +61,15 @@ 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" "$HOME_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
# HERMETICITY (#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"
|
||||||
@@ -411,7 +372,6 @@ 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,38 +7,13 @@ 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" "$HOME_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_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"}}}
|
||||||
@@ -75,7 +50,6 @@ 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" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regression harness for secret-safe, identity-bound PR editing and explicit targets.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit}"
|
|
||||||
REPO_DIR="$WORK_DIR/repo"; BIN_DIR="$WORK_DIR/bin"; HOME_DIR="$WORK_DIR/home"
|
|
||||||
XDG_DIR="$WORK_DIR/xdg"; LOG_FILE="$WORK_DIR/calls.log"
|
|
||||||
rm -rf "$WORK_DIR"; mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR" "$XDG_DIR/tea"
|
|
||||||
git -C "$REPO_DIR" init -q
|
|
||||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/other/wrong-checkout.git
|
|
||||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
|
||||||
|
|
||||||
cat > "$XDG_DIR/tea/config.yml" <<'YAML'
|
|
||||||
logins:
|
|
||||||
- name: usc-coder3
|
|
||||||
url: https://git.uscllc.com
|
|
||||||
token: fixture-usc-token
|
|
||||||
- name: same-host-other
|
|
||||||
url: https://git.uscllc.com
|
|
||||||
token: fixture-other-token
|
|
||||||
- name: mosaic-coder3
|
|
||||||
url: https://git.mosaicstack.dev
|
|
||||||
token: fixture-mosaic-token
|
|
||||||
YAML
|
|
||||||
|
|
||||||
cat > "$BIN_DIR/tea" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
[[ "$*" == "api --login usc-coder3 /user" ]] && { printf '{"login":"coder3"}\n'; exit 0; }
|
|
||||||
[[ "$*" == "api --login same-host-other /user" ]] && { printf '{"login":"other"}\n'; exit 0; }
|
|
||||||
[[ "$*" == "api --login mosaic-coder3 /user" ]] && { printf '{"login":"coder3"}\n'; exit 0; }
|
|
||||||
exit 1
|
|
||||||
SH
|
|
||||||
cat > "$BIN_DIR/curl" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
printf 'curl' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
|
|
||||||
[[ " $* " == *" -X PATCH "* ]] && printf '{"number":42,"draft":false}\n' || printf '{"name":"repo"}\n'
|
|
||||||
SH
|
|
||||||
cat > "$BIN_DIR/gh" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
printf 'gh' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
|
|
||||||
SH
|
|
||||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" "$BIN_DIR/gh" "$SCRIPT_DIR/pr-edit.sh"
|
|
||||||
|
|
||||||
run_wrapper() {
|
|
||||||
(cd "$REPO_DIR"; PATH="$BIN_DIR:$PATH" HOME="$HOME_DIR" XDG_CONFIG_HOME="$XDG_DIR" \
|
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" "$SCRIPT_DIR/pr-edit.sh" "$@")
|
|
||||||
}
|
|
||||||
assert_no_secret() {
|
|
||||||
! grep -q 'fixture-.*-token' "$LOG_FILE" || { echo "Credential leaked into curl argv/log" >&2; exit 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
# The explicit target differs from CWD origin and must govern BOTH host and slug.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
# shellcheck disable=SC2016 # literal backticks prove argument-array body safety.
|
|
||||||
MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r mosaicstack/stack \
|
|
||||||
-H git.mosaicstack.dev --title 'New title' --body 'Body with `literal` bytes' --base develop --draft >/dev/null
|
|
||||||
python3 - "$LOG_FILE" <<'PY'
|
|
||||||
import json, pathlib, sys
|
|
||||||
lines = pathlib.Path(sys.argv[1]).read_text().splitlines()
|
|
||||||
assert len(lines) == 2, lines
|
|
||||||
assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack" in lines[0], lines
|
|
||||||
assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/42" in lines[1], lines
|
|
||||||
assert "--config" in lines[0] and "--config" in lines[1], lines
|
|
||||||
assert "Authorization:" not in "\n".join(lines), lines
|
|
||||||
payload = lines[1].split(" <-d> <", 1)[1].split("> <https://", 1)[0]
|
|
||||||
assert json.loads(payload) == {"title":"New title","body":"Body with `literal` bytes","base":"develop","draft":True}
|
|
||||||
PY
|
|
||||||
assert_no_secret
|
|
||||||
|
|
||||||
# Ready maps to false and still preflights before the write.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login usc-coder3 -r USC/uconnect -H git.uscllc.com --ready >/dev/null
|
|
||||||
grep -q '"draft": false' "$LOG_FILE"; assert_no_secret
|
|
||||||
|
|
||||||
# Identity is mandatory; no ambient/first-host login can write.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
if run_wrapper -n 42 --login usc-coder3 --draft >/dev/null 2>&1; then echo "Unset identity wrote" >&2; exit 1; fi
|
|
||||||
[[ ! -s "$LOG_FILE" ]] || { echo "Unset identity reached curl" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Explicit and ambient same-host wrong principals both refuse before preflight/write.
|
|
||||||
for mode in explicit ambient; do
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
if [[ "$mode" == explicit ]]; then
|
|
||||||
cmd=(--login same-host-other)
|
|
||||||
else
|
|
||||||
cmd=(); export GITEA_LOGIN=same-host-other
|
|
||||||
fi
|
|
||||||
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 "${cmd[@]}" -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
|
||||||
echo "$mode wrong identity wrote" >&2; exit 1
|
|
||||||
fi
|
|
||||||
unset GITEA_LOGIN
|
|
||||||
[[ ! -s "$LOG_FILE" ]] || { echo "$mode wrong identity reached curl" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
# Set identity with no explicit/ambient login refuses rather than selecting first host login.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
|
||||||
echo "Missing login selected a principal" >&2; exit 1
|
|
||||||
fi
|
|
||||||
[[ ! -s "$LOG_FILE" ]] || { echo "Missing login reached curl" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Cross-host credential fails before curl; explicit target preflight failure blocks PATCH.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
|
||||||
echo "Cross-host login wrote" >&2; exit 1
|
|
||||||
fi
|
|
||||||
[[ ! -s "$LOG_FILE" ]] || { echo "Cross-host login reached curl" >&2; exit 1; }
|
|
||||||
|
|
||||||
if run_wrapper -n 42 --draft --ready >/dev/null 2>&1; then echo "Accepted conflicting modes" >&2; exit 1; fi
|
|
||||||
if run_wrapper -n 42 >/dev/null 2>&1; then echo "Accepted no-op edit" >&2; exit 1; fi
|
|
||||||
run_wrapper --help 2>&1 | grep -q '^Usage:'
|
|
||||||
|
|
||||||
# GitHub retains provider-native edit/readiness behavior.
|
|
||||||
git -C "$REPO_DIR" remote set-url origin https://github.com/acme/widgets.git
|
|
||||||
: > "$LOG_FILE"; run_wrapper -n 7 --title 'GitHub title' --draft >/dev/null
|
|
||||||
grep -q 'gh <pr> <edit> <7> <--title> <GitHub title>' "$LOG_FILE"
|
|
||||||
grep -q 'gh <pr> <ready> <7> <--undo>' "$LOG_FILE"
|
|
||||||
echo "PR edit regression harness passed"
|
|
||||||
@@ -8,7 +8,6 @@ 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() {
|
||||||
@@ -16,7 +15,7 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
mkdir -p "$MOCK_BIN" "$REPO_DIR" "$HOME_DIR"
|
mkdir -p "$MOCK_BIN" "$REPO_DIR"
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
|
|
||||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
@@ -110,48 +109,7 @@ 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,68 +8,12 @@ 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" "$HOME_DIR"
|
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_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'
|
||||||
{
|
{
|
||||||
@@ -187,8 +131,6 @@ 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" \
|
||||||
@@ -228,8 +170,6 @@ 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" \
|
||||||
@@ -264,8 +204,7 @@ 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" && HOME="$HOME_DIR" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
output=$(cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
||||||
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,19 +439,6 @@ 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
|
||||||
@@ -906,16 +893,11 @@ 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 does not belong to this PR must
|
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
|
||||||
# FAIL CLOSED. Verification pins the URL's ORIGIN (scheme-class + host + explicit
|
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
|
||||||
# non-default port) and FULL path (deployment prefix + exact owner/repo + kind +
|
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
|
||||||
# number); a bare endswith/suffix test would wrongly accept the look-alike-host
|
# test would wrongly accept the look-alike-host and prefix-injection variants.
|
||||||
# and prefix-injection variants. comment-url-wrong-port and
|
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
|
||||||
# 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
|
||||||
@@ -941,19 +923,6 @@ 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
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ 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"
|
||||||
@@ -92,8 +89,6 @@ 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_lines="$(has_match -F "\"id\":\"$enum_id\"" "$self")"; fixture_matches="$(has_match -F '"observed_seq":5' <<<"$fixture_lines")"; fixture_line="${fixture_matches%%$'\n'*}"
|
fixture_line="$(has_match -F "\"id\":\"$enum_id\"" "$self" | has_match -F '"observed_seq":5' | head -n1)"
|
||||||
[ -n "$fixture_line" ] || fail_msg "Q16: could not locate Q2's $enum_id fixture line (renamed/renumbered? update this guard)"
|
[ -n "$fixture_line" ] || fail_msg "Q16: could not locate Q2's $enum_id fixture line (renamed/renumbered? update this guard)"
|
||||||
fixture_json="$(printf '%s' "$fixture_line" | sed "s/.*'\({.*}\)'.*/\1/")"
|
fixture_json="$(printf '%s' "$fixture_line" | sed "s/.*'\({.*}\)'.*/\1/")"
|
||||||
# Positive controls FIRST (blind-instrument rule): the extraction must yield
|
# Positive controls FIRST (blind-instrument rule): the extraction must yield
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ echo "== P3: detector orders the cause line BEFORE the delta it explains =="
|
|||||||
printf 'r1 state v2\n' >"$fx/repo_r1"
|
printf 'r1 state v2\n' >"$fx/repo_r1"
|
||||||
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: second poll failed"
|
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: second poll failed"
|
||||||
sd="$(state_dir)"
|
sd="$(state_dir)"
|
||||||
pre_seq="$(jq -nr 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||||
src_seq="$(jq -nr 'first(inputs | select(.locators.kind == "repo") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
src_seq="$(jq -r 'select(.locators.kind == "repo") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||||
[ -n "$pre_seq" ] || fail_msg "P3: no preimage cause entry enqueued"
|
[ -n "$pre_seq" ] || fail_msg "P3: no preimage cause entry enqueued"
|
||||||
[ -n "$src_seq" ] || fail_msg "P3: no source delta entry enqueued"
|
[ -n "$src_seq" ] || fail_msg "P3: no source delta entry enqueued"
|
||||||
if [ -n "$pre_seq" ] && [ -n "$src_seq" ]; then
|
if [ -n "$pre_seq" ] && [ -n "$src_seq" ]; then
|
||||||
@@ -343,8 +343,8 @@ echo "== P11: reconcile surfaces the cause line before its enumerations =="
|
|||||||
printf '# adapter changed while detector down\n' >>"$fx/adapter.sh"
|
printf '# adapter changed while detector down\n' >>"$fx/adapter.sh"
|
||||||
"$RECON" reconcile >/dev/null 2>&1 # rc 1 expected (unaccounted enumerated)
|
"$RECON" reconcile >/dev/null 2>&1 # rc 1 expected (unaccounted enumerated)
|
||||||
sd="$(state_dir)"
|
sd="$(state_dir)"
|
||||||
pre_seq="$(jq -nr 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty' "$sd/pending.jsonl")"
|
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||||
enum_seq="$(jq -nr 'first(inputs | select(.locators.reconciled == true) | .observed_seq) // empty' "$sd/pending.jsonl")"
|
enum_seq="$(jq -r 'select(.locators.reconciled == true) | .observed_seq' "$sd/pending.jsonl" | head -n1)"
|
||||||
[ -n "$pre_seq" ] || fail_msg "P11: reconcile must enqueue the preimage cause line"
|
[ -n "$pre_seq" ] || fail_msg "P11: reconcile must enqueue the preimage cause line"
|
||||||
[ -n "$enum_seq" ] || fail_msg "P11: reconcile must still enumerate the unaccounted source"
|
[ -n "$enum_seq" ] || fail_msg "P11: reconcile must still enumerate the unaccounted source"
|
||||||
if [ -n "$pre_seq" ] && [ -n "$enum_seq" ]; then
|
if [ -n "$pre_seq" ] && [ -n "$enum_seq" ]; then
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ site_line() { # site_line FILE MARKER -> first physical line of that call
|
|||||||
local f="$1" marker="$2" ln
|
local f="$1" marker="$2" ln
|
||||||
ln="$(grep -n "# SITE:${marker}\$" "$f" | cut -d: -f1)"
|
ln="$(grep -n "# SITE:${marker}\$" "$f" | cut -d: -f1)"
|
||||||
# continuation marker sits on the tail line; the call starts one line up
|
# continuation marker sits on the tail line; the call starts one line up
|
||||||
source_line="$(sed -n "${ln}p" "$f")"; if ! grep -Eq 'has_match|count_lines' <<<"$source_line"; then
|
if ! sed -n "${ln}p" "$f" | grep -Eq 'has_match|count_lines'; then
|
||||||
ln=$((ln - 1))
|
ln=$((ln - 1))
|
||||||
fi
|
fi
|
||||||
printf '%s' "$ln"
|
printf '%s' "$ln"
|
||||||
@@ -167,13 +167,13 @@ rcB=$?
|
|||||||
sort "$LEDGER" >"$TMP/got-c1"
|
sort "$LEDGER" >"$TMP/got-c1"
|
||||||
n_expected="$(grep -c . "$TMP/expected-c1")"
|
n_expected="$(grep -c . "$TMP/expected-c1")"
|
||||||
if [ "$rcA" -eq 0 ] && [ "$rcB" -eq 0 ] &&
|
if [ "$rcA" -eq 0 ] && [ "$rcB" -eq 0 ] &&
|
||||||
grep -q 'mini-a: OK' <<<"$outA"&&
|
printf '%s' "$outA" | grep -q 'mini-a: OK' &&
|
||||||
grep -q 'mini-b: OK' <<<"$outB"&&
|
printf '%s' "$outB" | grep -q 'mini-b: OK' &&
|
||||||
[ "$n_expected" -gt 1 ] &&
|
[ "$n_expected" -gt 1 ] &&
|
||||||
cmp -s "$TMP/expected-c1" "$TMP/got-c1"; then
|
cmp -s "$TMP/expected-c1" "$TMP/got-c1"; then
|
||||||
check C1 0 ""
|
check C1 0 ""
|
||||||
else
|
else
|
||||||
check C1 1 "rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff "$TMP/expected-c1" "$TMP/got-c1" 2>&1 | sed -n '1,10p' | tr '\n' ' ')"
|
check C1 1 "rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff "$TMP/expected-c1" "$TMP/got-c1" 2>&1 | head -n 10 | tr '\n' ' ')"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- C2: early exit -> short ledger, comparison catches it -----------------
|
# --- C2: early exit -> short ledger, comparison catches it -----------------
|
||||||
@@ -201,12 +201,12 @@ abort_case() { # abort_case NAME MARKER HELPER
|
|||||||
bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -ne 0 ] &&
|
if [ "$rc" -ne 0 ] &&
|
||||||
! grep -q 'mini-a: OK' <<<"$out"&&
|
! printf '%s' "$out" | grep -q 'mini-a: OK' &&
|
||||||
! grep -q 'mini-a: FAILED' <<<"$out"&&
|
! printf '%s' "$out" | grep -q 'mini-a: FAILED' &&
|
||||||
grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" <<<"$out"&&
|
printf '%s' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" &&
|
||||||
grep -q "WAKE-ASSERT ABORT" <<<"$out"&&
|
printf '%s' "$out" | grep -q "WAKE-ASSERT ABORT" &&
|
||||||
grep -q "$site" <<<"$out"&&
|
printf '%s' "$out" | grep -q "$site" &&
|
||||||
grep -q "grep exit 2" <<<"$out"&&
|
printf '%s' "$out" | grep -q "grep exit 2" &&
|
||||||
grep -q "^${helper} ${site}\$" "$ledger"; then
|
grep -q "^${helper} ${site}\$" "$ledger"; then
|
||||||
check "$name" 0 ""
|
check "$name" 0 ""
|
||||||
else
|
else
|
||||||
@@ -230,8 +230,8 @@ if [ "$got" = "1" ]; then check C8 0 ""; else check C8 1 "env-prefix did not rea
|
|||||||
out="$(WAKE_ASSERT_FORCE_GREP_ERROR_AT="mini-a.sh:9999" bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
out="$(WAKE_ASSERT_FORCE_GREP_ERROR_AT="mini-a.sh:9999" bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -eq 0 ] &&
|
if [ "$rc" -eq 0 ] &&
|
||||||
grep -q 'mini-a: OK' <<<"$out"&&
|
printf '%s' "$out" | grep -q 'mini-a: OK' &&
|
||||||
! grep -q 'WAKE-ASSERT ARMED' <<<"$out"; then
|
! printf '%s' "$out" | grep -q 'WAKE-ASSERT ARMED'; then
|
||||||
check C9 0 ""
|
check C9 0 ""
|
||||||
else
|
else
|
||||||
check C9 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
|
check C9 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
|
||||||
@@ -248,8 +248,8 @@ chmod +x "$TMP/fake-bash"
|
|||||||
out="$(WAKE_ASSERT_PIN_BASH="$TMP/fake-bash" bash -c '. "$WAKE_COMMON" && wake_assert_init && echo REACHED-PAST-INIT' 2>&1)"
|
out="$(WAKE_ASSERT_PIN_BASH="$TMP/fake-bash" bash -c '. "$WAKE_COMMON" && wake_assert_init && echo REACHED-PAST-INIT' 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -ne 0 ] &&
|
if [ "$rc" -ne 0 ] &&
|
||||||
! grep -q 'REACHED-PAST-INIT' <<<"$out"&&
|
! printf '%s' "$out" | grep -q 'REACHED-PAST-INIT' &&
|
||||||
grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated' <<<"$out"; then
|
printf '%s' "$out" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then
|
||||||
check C10 0 ""
|
check C10 0 ""
|
||||||
else
|
else
|
||||||
check C10 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 2 | tr '\n' ' ')"
|
check C10 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 2 | tr '\n' ' ')"
|
||||||
@@ -283,8 +283,8 @@ out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$TMP/mini-c.sh" "$TMP" 2>&1)"
|
|||||||
rc=$?
|
rc=$?
|
||||||
summary_ln="$(site_line "$TMP/mini-c.sh" c-summary)"
|
summary_ln="$(site_line "$TMP/mini-c.sh" c-summary)"
|
||||||
if [ "$rc" -eq 1 ] &&
|
if [ "$rc" -eq 1 ] &&
|
||||||
grep -q 'wake mini-c harness: FAILED (1 assertion(s))' <<<"$out"&&
|
printf '%s' "$out" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&
|
||||||
! grep -q 'all invariants passed' <<<"$out"&&
|
! printf '%s' "$out" | grep -q 'all invariants passed' &&
|
||||||
grep -q "^count_lines mini-c.sh:${summary_ln}\$" "$LEDGER"; then
|
grep -q "^count_lines mini-c.sh:${summary_ln}\$" "$LEDGER"; then
|
||||||
check C11 0 ""
|
check C11 0 ""
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ if cmp -s "$TMP/expected.txt" "$TMP/static.txt"; then
|
|||||||
echo "STATIC-INVENTORY equals expected set ($(grep -c . "$TMP/static.txt") rows from source text)"
|
echo "STATIC-INVENTORY equals expected set ($(grep -c . "$TMP/static.txt") rows from source text)"
|
||||||
else
|
else
|
||||||
flag "static inventory (source text) differs from expected set (artifact):"
|
flag "static inventory (source text) differs from expected set (artifact):"
|
||||||
diff "$TMP/expected.txt" "$TMP/static.txt" | sed -n '1,20p' | sed 's/^/ /'
|
diff "$TMP/expected.txt" "$TMP/static.txt" | head -n 20 | sed 's/^/ /'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 3: green instrumented run ----------------------------------------------
|
# --- 3: green instrumented run ----------------------------------------------
|
||||||
@@ -116,7 +116,7 @@ LEDGER="$TMP/ledger"
|
|||||||
for s in "${SUITES[@]}"; do
|
for s in "${SUITES[@]}"; do
|
||||||
out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$WAKE/$s" 2>&1)"
|
out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$WAKE/$s" 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
if grep -Eq "$(sentinel_for "$s")" <<<"$out"; then
|
if printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$s")"; then
|
||||||
sent="present"
|
sent="present"
|
||||||
else
|
else
|
||||||
sent="ABSENT"
|
sent="ABSENT"
|
||||||
@@ -177,14 +177,14 @@ while read -r helper site form; do
|
|||||||
rc=$?
|
rc=$?
|
||||||
bad=""
|
bad=""
|
||||||
[ "$rc" -ne 0 ] || bad="$bad exit=0"
|
[ "$rc" -ne 0 ] || bad="$bad exit=0"
|
||||||
grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" <<<"$out"||
|
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" ||
|
||||||
bad="$bad no-ARMED-line"
|
bad="$bad no-ARMED-line"
|
||||||
grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" <<<"$out"||
|
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" ||
|
||||||
bad="$bad no-ABORT-line"
|
bad="$bad no-ABORT-line"
|
||||||
# AND-polarity check (a match is the defect): a grep error (rc>=2) must be
|
# AND-polarity check (a match is the defect): a grep error (rc>=2) must be
|
||||||
# its own loud arm — it cannot fall through as "no sentinel = pass".
|
# its own loud arm — it cannot fall through as "no sentinel = pass".
|
||||||
rc_sent=0
|
rc_sent=0
|
||||||
grep -Eq "$(sentinel_for "$f")" <<<"$out"|| rc_sent=$?
|
printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$f")" || rc_sent=$?
|
||||||
case "$rc_sent" in
|
case "$rc_sent" in
|
||||||
0) bad="$bad sentinel-emitted" ;;
|
0) bad="$bad sentinel-emitted" ;;
|
||||||
1) : ;;
|
1) : ;;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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,
|
||||||
@@ -179,52 +178,6 @@ 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,15 +715,9 @@ export function buildPiSkillArgs(
|
|||||||
return ['--no-skills', ...forcedSkillArgs];
|
return ['--no-skills', ...forcedSkillArgs];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PI_EXTENSION_FILES = ['mosaic-extension.ts', 'goal-extension.ts'] as const;
|
function discoverPiExtension(): string[] {
|
||||||
|
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
|
||||||
export function discoverPiExtensionArgs(mosaicHome: string = MOSAIC_HOME): string[] {
|
return existsSync(ext) ? ['--extension', ext] : [];
|
||||||
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 ────────────────────────────────────────────────────────
|
||||||
@@ -798,7 +792,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(...discoverPiExtensionArgs());
|
cliArgs.push(...discoverPiExtension());
|
||||||
if (hasMissionNoArgs) {
|
if (hasMissionNoArgs) {
|
||||||
cliArgs.push(missionPrompt);
|
cliArgs.push(missionPrompt);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -209,20 +209,6 @@ 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 });
|
||||||
|
|
||||||
|
|||||||
@@ -1,796 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
[
|
|
||||||
"packages/mosaic/framework/tools/wake/test-wake-digest-quarantine.sh:fixture_line=\"$(has_match -F \"\\\"id\\\":\\\"$enum_id\\\"\" \"$self\" | has_match -F '\"observed_seq\":5' | head -n1)\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:src_seq=\"$(jq -r 'select(.locators.kind == \"repo\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:enum_seq=\"$(jq -r 'select(.locators.reconciled == true) | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:if ! sed -n \"${ln}p\" \"$f\" | grep -Eq 'has_match|count_lines'; then",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outA\" | grep -q 'mini-a: OK' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outB\" | grep -q 'mini-b: OK' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:check C1 1 \"rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff \"$TMP/expected-c1\" \"$TMP/got-c1\" 2>&1 | head -n 10 | tr '\\n' ' ')\"",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: FAILED' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ABORT\" &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"$site\" &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"grep exit 2\" &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'WAKE-ASSERT ARMED'; then",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'REACHED-PAST-INIT' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'all invariants passed' &&",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:diff \"$TMP/expected.txt\" \"$TMP/static.txt\" | head -n 20 | sed 's/^/ /'",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:if printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$s\")\"; then",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" ||",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit\" ||",
|
|
||||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$f\")\" || rc_sent=$?"
|
|
||||||
]
|
|
||||||
@@ -8,7 +8,6 @@ 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',
|
||||||
@@ -38,10 +37,6 @@ 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.
|
||||||
@@ -111,14 +106,6 @@ 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()), []);
|
||||||
});
|
});
|
||||||
@@ -151,40 +138,6 @@ 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