Compare commits

..
9 Commits
Author SHA1 Message Date
mos-dt-0 722163671f feat(pi): add persistent Mosaic /goal controller (#1152)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-10 22:54:43 +00:00
f10-coderandMos b0f7d26dd9 fix(shell): remove test harness pipe hazards (#1106)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/manual/ci-image Pipeline failed
ci/woodpecker/manual/publish Pipeline failed
ci/woodpecker/manual/ci Pipeline was successful
Co-authored-by: f10-coder <[email protected]>
2026-08-07 11:12:17 +00:00
f10-coderandMos 3a1203b2f8 fix(shell): remove runtime early-exit pipe hazards (#1105)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: f10-coder <[email protected]>
2026-08-07 09:38:37 +00:00
be-coder-08 df4c591ab4 fix(fleet): make framework shell assertions SIGPIPE-safe (#1100)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-07 08:21:20 +00:00
be-coder-06andMos 4fa2768962 fix(fleet): propagate roster git identity (#1073)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-06 <[email protected]>
2026-08-07 07:07:45 +00:00
Mos aa0a7b5fa2 fix(tools/git): issue-close.sh silently dropped the closing comment (#1085)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-07 05:59:12 +00:00
Mos 42ac19af48 test(gateway): size the enrollment clamp tolerance to CI jitter, not to a fast machine (closes #1090) (#1094)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
2026-08-07 05:33:38 +00:00
Mos f744f32214 feat(tools/git): explain tea's misleading user does not exist error (stale token, not a missing account) (#1086)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-07 05:07:40 +00:00
Mos 8ff7aac0ca fix(tools/git): detect-platform died silently outside a repo, taking every wrapper with it (#1089)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-07 04:26:36 +00:00
72 changed files with 3787 additions and 144 deletions
+4
View File
@@ -46,6 +46,10 @@ steps:
# [0] of the pnpm chain, so severing that chain would silence it together # [0] of the pnpm chain, so severing that chain would silence it together
# with everything it guards; this direct line keeps one instrument running. # with everything it guards; this direct line keeps one instrument running.
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh - bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
# and sandboxes a throwaway git repo, so it resolves no real credentials and
# joins CI directly rather than the exclusions file.
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
# Blocking gate (#791): a framework upgrade must never write or delete an # Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel # operator-owned path. The HARD GATE proves an unanticipated operator sentinel
+8
View File
@@ -60,6 +60,14 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery. Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi,
`/goal set <statement>` starts a bounded persistent loop that checks every turn and successful
compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with
`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state
entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and
evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension
into `~/.pi/agent/extensions/`.
### TUI & Gateway ### TUI & Gateway
```bash ```bash
@@ -245,9 +245,21 @@ describe('EnrollmentService.createToken', () => {
const after = Date.now(); const after = Date.now();
const expiresMs = new Date(result.expiresAt).getTime(); const expiresMs = new Date(result.expiresAt).getTime();
// Should be at most 900s from now
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100); // The property under test is CLAMPING: a 9999s request must come back as 900s.
// The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below
// only has to exceed CI scheduling jitter — it does not need to be tight to keep
// the assertion discriminating. A 5s allowance consumes 0.05% of that margin and
// an unclamped result still misses by three orders of magnitude.
//
// It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A
// wall-clock budget sized to a fast machine is a flake, not a tighter test.
const CI_JITTER_MS = 5_000;
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS);
expect(expiresMs - after).toBeGreaterThanOrEqual(0); expect(expiresMs - after).toBeGreaterThanOrEqual(0);
// Explicitly pin the clamp itself, independent of any timing allowance:
// unclamped (9999s) would exceed this by ~9_099_000 ms.
expect(expiresMs - before).toBeLessThan(1_000_000);
}); });
}); });
+184
View File
@@ -102,6 +102,128 @@ Context compaction, session replacement, and same-PID runtime reloads can leave
--- ---
## Pi Persistent Goal Loop (#1150)
### Problem and objective
A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is
not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an
optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native
lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is
reached.
The objective is a Mosaic-owned Pi extension deployed from the framework into
`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`.
### Scope
#### In scope
1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under
`packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make
`mosaic pi` load it alongside the core Mosaic extension when present.
2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help
operations without silently replacing an active goal.
3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries,
restored on session start and tree navigation, and never rely on a compaction summary as its
source of truth.
4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every
model request so it remains effective across tool turns, retries, and post-compaction requests.
5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event.
A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`,
or `blocked` status without requiring a redundant model turn.
6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive
evidence-bearing verification report. Any continuation report or successful compaction during
verification SHALL reset the verification sequence.
7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily
`agent_settled`; manual compaction and restored active sessions may schedule a deferred idle
continuation without re-entering compaction handlers.
8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress
limits. Exhausted or blocked goals pause rather than continuing indefinitely.
9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the
goal extension is framework-owned under `runtime/**`, while no goal extension or configuration
asset is created or modified under the operator's main Pi configuration. Pi remains the owner of
its native session files used by `appendEntry()`.
#### Out of scope
1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete.
2. Automatically executing user-supplied shell predicates or accepting executable validation code in
`/goal` arguments.
3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime
supervisor owns process durability.
4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice.
### User and stakeholder requirements
- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report.
- The agent remains oriented after each turn and compaction until verified, paused, blocked,
exhausted, or cancelled.
- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an
extension asset to `~/.pi/agent/extensions/`.
- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest
sync path.
### Non-functional requirements
1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no
completion without non-empty reported evidence.
2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe
context injection, and stale-timer cancellation on session shutdown.
3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the
active agent's final terminating tool call.
4. **Observability:** Pi status/notifications expose phase and bounded counters without recording
credentials or hidden model reasoning.
5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi
provider/network access.
### Acceptance criteria
1. `AC-PGL-01`: A framework-sync fixture installs the extension at
`$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are
emitted in deterministic order while absent optional files remain backward-compatible.
2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement
refusal, and bounded input handling.
3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every
request, two evidence-bearing achievement reports are required, and `agent_settled` continues an
unmet goal without duplicate scheduling.
4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and
rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session
branch state is reconstructed correctly.
5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous
continuation, while pause/cancel/blocked states do not restart.
6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load
smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before
issue #1150 closes.
### Constraints, risks, and assumptions
- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`,
`context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating
tool results.
- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory
second verification pass, explicit semantic limitations, and operator-visible reports.
- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds
and paused terminal states.
- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from
`agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown.
- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy;
rationale: it provides a real recheck without doubling every turn's model cost. Future policy may
add independent or deterministic validators.
- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by
bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget.
- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication
is requested.
### Testing and delivery intent
Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle
objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests,
launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one
reviewed squash PR to `main` with terminal-green CI.
---
## Fleet Declarative Configuration Management Workstream (FCM, #758) ## Fleet Declarative Configuration Management Workstream (FCM, #758)
### Problem and objective ### Problem and objective
@@ -146,6 +268,68 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR. are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
### Fleet git identity launch propagation (#1043)
#### Problem and objective
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
estates where that refusal is not active. The objective is to make Git identity a deterministic,
roster-derived part of the generated launch projection and prove it reaches the launched process.
#### Normative requirements
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
tmux launch.
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
against the launched process environment. Source-text/string-presence assertions are insufficient.
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
removes Git-identity pane propagation and makes the behavioral test fail.
#### Acceptance criteria
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
environment projection, including the roster-derived Git identity.
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
independent review, and the required RED/green/R7 evidence are recorded before push.
### Framework shell assertion portability (#1098)
#### Problem and objective
The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when
`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after
the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a
nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and
make failures carry the observed records needed for diagnosis.
#### Normative requirements
1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from
the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status
can override a successful match.
2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries
SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI
utility implementation.
3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records
before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only.
4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order
controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review.
#### Acceptance criteria
1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former
`grep -q` pipeline returns nonzero from an upstream SIGPIPE.
2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and
emit the indexed observed argv.
3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main
Woodpecker pipeline is terminal green before #1098 closes.
--- ---
## Exact Cross-Harness Fleet Communications Contract (#766) ## Exact Cross-Harness Fleet Communications Contract (#766)
+7
View File
@@ -14,6 +14,13 @@
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior. - [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes. - [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
## Pi persistent goals
- [Persistent goal user guide](guides/user-guide.md#pi-persistent-goals) — `/goal` commands, verification behavior, limits, compaction/resume semantics, and limitations.
- [Goal extension developer guide](guides/dev-guide.md#pi-persistent-goal-extension) — framework ownership, launcher ordering, lifecycle design, tests, and local Mosaic-path smoke workflow.
- [Goal loop operations](guides/admin-guide.md#pi-goal-loop-operations) — deployment ownership, bounded settings, pause/resume procedures, and supervisor boundary.
- [Pi runtime reference](../packages/mosaic/framework/runtime/pi/RUNTIME.md#extensions) — deployed paths, command summary, and bounded environment settings.
## Fleet configuration management ## Fleet configuration management
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map. - [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
+7 -4
View File
@@ -6,8 +6,8 @@ Generated environment files are rebuildable projections, not an operator-editabl
## Launch chain ## Launch chain
| Layer | Responsibility | | Layer | Responsibility |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. | | Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. |
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. | | Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. | | Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. | | systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
@@ -24,6 +24,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
```dotenv ```dotenv
MOSAIC_AGENT_NAME=<roster name> MOSAIC_AGENT_NAME=<roster name>
MOSAIC_GIT_IDENTITY=<roster name>
MOSAIC_AGENT_CLASS=<roster class> MOSAIC_AGENT_CLASS=<roster class>
MOSAIC_AGENT_RUNTIME=<roster runtime> MOSAIC_AGENT_RUNTIME=<roster runtime>
MOSAIC_AGENT_MODEL=<roster model hint> MOSAIC_AGENT_MODEL=<roster model hint>
@@ -33,8 +34,10 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
MOSAIC_TMUX_SOCKET=<roster socket or empty> MOSAIC_TMUX_SOCKET=<roster socket or empty>
``` ```
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add `MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state. split runtime and repository identity authority. The generated launch contract supports `claude`,
`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or
modifies generated, local, or quarantine state.
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket; The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
it has no generated-launch adapter and cannot be added through this path. it has no generated-launch adapter and cannot be added through this path.
@@ -3,11 +3,12 @@
The launcher consumes validated data, not shell configuration. The launcher consumes validated data, not shell configuration.
1. Read and validate the canonical roster. 1. Read and validate the canonical roster.
2. Render deterministic <name>.env.generated data from that roster. 2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
3. Parse optional <name>.env.local through a strict allowlist. 3. Parse optional <name>.env.local through a strict allowlist.
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides. 4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
5. Derive the runtime command from validated runtime/model/reasoning data. 5. Reject a Git identity that is unsafe or differs from the generated agent name.
6. Target only the exact configured tmux socket and roster session after ownership checks. 6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
7. Target only the exact configured tmux socket and roster session after ownership checks.
## File precedence and ownership ## File precedence and ownership
@@ -35,6 +35,7 @@ values, credential material, or command text.
```dotenv ```dotenv
MOSAIC_AGENT_NAME=<roster name> MOSAIC_AGENT_NAME=<roster name>
MOSAIC_GIT_IDENTITY=<roster name>
MOSAIC_AGENT_CLASS=<roster class> MOSAIC_AGENT_CLASS=<roster class>
MOSAIC_AGENT_RUNTIME=<roster runtime> MOSAIC_AGENT_RUNTIME=<roster runtime>
MOSAIC_AGENT_MODEL=<roster model hint> MOSAIC_AGENT_MODEL=<roster model hint>
@@ -44,8 +45,9 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
MOSAIC_TMUX_SOCKET=<roster socket or empty> MOSAIC_TMUX_SOCKET=<roster socket or empty>
``` ```
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add `MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
uses that same runtime authority and rejects any other runtime before it writes the roster or changes operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory` projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
socket remains an observability canary; it has no generated-launch adapter and cannot be added through socket remains an observability canary; it has no generated-launch adapter and cannot be added through
this projection path. this projection path.
+38 -1
View File
@@ -7,7 +7,8 @@
3. [Provider Configuration](#provider-configuration) 3. [Provider Configuration](#provider-configuration)
4. [MCP Server Configuration](#mcp-server-configuration) 4. [MCP Server Configuration](#mcp-server-configuration)
5. [Environment Variables Reference](#environment-variables-reference) 5. [Environment Variables Reference](#environment-variables-reference)
6. [Local Fleet Canary](./fleet-local-canary.md) 6. [Pi Goal Loop Operations](#pi-goal-loop-operations)
7. [Local Fleet Canary](./fleet-local-canary.md)
--- ---
@@ -264,6 +265,16 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions | | `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users | | `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
### Mosaic Pi goal loop
| Variable | Default | Description |
| ----------------------------- | ------- | -------------------------------------------------------------------- |
| `MOSAIC_GOAL_MAX_TURNS` | `40` | Per-goal autonomous turn limit; accepted range `1..500` |
| `MOSAIC_GOAL_MAX_NO_PROGRESS` | `6` | Consecutive identical progress-report limit; accepted range `1..100` |
These variables are consumed by the framework-owned Pi goal extension at goal creation. Invalid or
out-of-range values fall back to the defaults; they do not disable the bounds.
### Providers ### Providers
| Variable | Default | Description | | Variable | Default | Description |
@@ -374,3 +385,29 @@ Session cleanup is scoped to one session identifier and only removes that sessio
| Variable | Default | Description | | Variable | Default | Description |
| ----------------------- | ----------------------------- | ------------------------------------------ | | ----------------------- | ----------------------------- | ------------------------------------------ |
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations | | `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
---
## Pi Goal Loop Operations
The reviewed runtime asset is deployed at
`~/.config/mosaic/runtime/pi/goal-extension.ts` by framework install/update. Do not install another
copy under `~/.pi/agent/extensions/`; duplicate registration can create suffixed commands and two
competing lifecycle controllers.
Operational checks:
1. Run `mosaic pi` and verify `/goal help` is available.
2. Use `/goal status` to inspect phase, turn/no-progress limits, compaction checks, and evidence.
Reports persist in Pi session data; controller-owned state redacts common credential shapes, but
Pi's model/tool-call history is separate. Operators must not place secrets or raw sensitive output
in goals, pause reasons, or evidence.
3. Use `/goal pause <reason>` before planned maintenance or manual investigation. Pause and cancel
abort the current goal-driven run when Pi is busy.
4. Use `/goal resume` only after addressing a blocker; counters restart with the configured bounds.
5. Use `/goal cancel` before replacing an unfinished goal.
A blocked or exhausted goal remains stopped and visible; Mosaic does not automatically raise its
limits or restart the process. Framework sync owns file deployment, while Pi's native session file
owns branch replay. Process/host restart remains the responsibility of the existing runtime or fleet
supervisor.
+82 -2
View File
@@ -9,8 +9,9 @@
5. [Adding New MCP Tools](#adding-new-mcp-tools) 5. [Adding New MCP Tools](#adding-new-mcp-tools)
6. [Database Schema and Migrations](#database-schema-and-migrations) 6. [Database Schema and Migrations](#database-schema-and-migrations)
7. [Claude Code Skill Bridge](#claude-code-skill-bridge) 7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
8. [API Endpoint Reference](#api-endpoint-reference) 8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension)
9. [Local Fleet Canary](./fleet-local-canary.md) 9. [API Endpoint Reference](#api-endpoint-reference)
10. [Local Fleet Canary](./fleet-local-canary.md)
--- ---
@@ -385,6 +386,85 @@ M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the
canonical root directly. Codex still relies on the existing full skill-sync canonical root directly. Codex still relies on the existing full skill-sync
linker and needs separate parity analysis before this lifecycle API is extended. linker and needs separate parity analysis before this lifecycle API is extended.
## Pi Persistent Goal Extension
The source of the Mosaic-owned Pi goal controller is:
```text
packages/mosaic/framework/runtime/pi/goal-extension.ts
```
The framework manifest classifies `runtime/**` as framework-owned. Both the bash installer and the
TypeScript file adapter therefore deploy the same reviewed source to:
```text
$MOSAIC_HOME/runtime/pi/goal-extension.ts
# default: ~/.config/mosaic/runtime/pi/goal-extension.ts
```
Do not copy or link this extension into `~/.pi/agent/extensions/`. The launcher function
`discoverPiExtensionArgs()` emits the core `mosaic-extension.ts` first and the optional
`goal-extension.ts` second, preserving compatibility with an older installed framework that does
not have the goal file yet.
### Lifecycle design
| Pi API | Goal-controller responsibility |
| ------------------------------ | --------------------------------------------------------------------------------- |
| `registerCommand('goal')` | Set, inspect, pause, resume, or cancel one branch-specific goal |
| `registerTool(...)` | Record a terminating structured progress report with evidence |
| `context` | Inject the active goal contract before every provider request |
| `turn_end` | Record every turn, reject mixed final reports, and enforce the turn bound |
| `agent_settled` | Start one deduplicated continuation only after Pi has no retry/compact/queue work |
| `session_compact` | Record the compact check, reset provisional verification, and defer idle work |
| `session_start`/`session_tree` | Rebuild state from custom entries on the active branch |
| `session_shutdown` | Invalidate deferred callbacks and clear UI state |
State is appended as `mosaic-goal-state` custom entries, which do not enter model context. The
`context` hook creates a fresh hidden `mosaic-goal-context` message for each request instead of
trusting compaction summaries. The `mosaic_goal_report` result uses `terminate: true`; when it is the
sole final tool call, Pi avoids an unnecessary model response before the controller decides whether
to verify, continue, or stop.
Before state is appended or displayed, the controller applies bounded credential-pattern redaction
to the goal statement, report summary/evidence/next step, and stop reason. Fingerprints are computed
over redacted report content. Pi session entries are append-only, so a credential-bearing legacy
entry cannot honestly be erased by the extension: restoration fails closed, emits a warning, and
requires removal of the affected session before setting a new goal. This is defense-in-depth rather
than a secret-storage contract, and it does not rewrite Pi's separate model-message/tool-call
history. Goal prompts tell the agent not to submit credentials or raw sensitive output, and tests use
canaries to prove known forms do not reach new custom entries, status text, context, or tool details
while ordinary typed fields such as `token: string` remain intact.
Completion remains evidence-gated but semantic: two consecutive `achieved` reports are required,
and the second run is explicitly a verification pass. This avoids an extra judge-model request after
every turn. Deterministic validator commands are intentionally not accepted as `/goal` input in this
slice, so never describe this mechanism as proof of arbitrary natural-language completion.
### Tests and local smoke workflow
```bash
pnpm --filter @mosaicstack/mosaic exec vitest run \
src/runtime/pi-goal-extension.spec.ts \
src/commands/launch.spec.ts \
src/config/file-adapter.test.ts
bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
```
For an additive local smoke test without reseeding unrelated live framework files:
```bash
install -D -m 0644 \
packages/mosaic/framework/runtime/pi/goal-extension.ts \
~/.config/mosaic/runtime/pi/goal-extension.ts
pi --extension ~/.config/mosaic/runtime/pi/goal-extension.ts
```
Use `/goal help`, `/goal set ...`, and `/goal status` in that test session. A released framework
sync installs the file, and a released Mosaic CLI loads it automatically through `mosaic pi`.
## API Endpoint Reference ## API Endpoint Reference
All endpoints are served by the gateway at `http://localhost:14242` by default. All endpoints are served by the gateway at `http://localhost:14242` by default.
+55 -3
View File
@@ -8,9 +8,10 @@
4. [Tasks](#tasks) 4. [Tasks](#tasks)
5. [Settings](#settings) 5. [Settings](#settings)
6. [CLI Usage](#cli-usage) 6. [CLI Usage](#cli-usage)
7. [Sub-package Commands](#sub-package-commands) 7. [Pi Persistent Goals](#pi-persistent-goals)
8. [Telemetry](#telemetry) 8. [Sub-package Commands](#sub-package-commands)
9. [Local Fleet Canary](./fleet-local-canary.md) 9. [Telemetry](#telemetry)
10. [Local Fleet Canary](./fleet-local-canary.md)
--- ---
@@ -307,6 +308,57 @@ mosaic prdy
mosaic quality-rails mosaic quality-rails
``` ```
## Pi Persistent Goals
`mosaic pi` loads a Mosaic-owned goal extension from
`~/.config/mosaic/runtime/pi/goal-extension.ts`. It is deliberately not installed in
`~/.pi/agent/extensions/`; framework installation and updates manage it with the rest of the Mosaic
runtime assets.
Start Pi, then set a goal:
```text
/goal set Deliver the feature, tests, documentation, and verification evidence
# Shorthand:
/goal Deliver the feature, tests, documentation, and verification evidence
```
Control and inspect the loop with:
| Command | Behavior |
| ---------------------- | ------------------------------------------------------------------ |
| `/goal status` | Show phase, limits, compaction checks, latest report, and evidence |
| `/goal pause [reason]` | Stop autonomous continuation while preserving the goal |
| `/goal resume` | Resume with fresh turn and no-progress counters |
| `/goal cancel` | Cancel the goal and remove its active status |
| `/goal help` | Show command help |
While a goal is active, Mosaic injects its contract before every Pi model request and checks every
completed model/tool turn. The agent ends each work cycle with the structured
`mosaic_goal_report` tool. `achieved` is provisional until a second consecutive report rechecks the
whole goal with evidence. A continuation report or a successful compaction resets provisional
verification.
Goal statements and reports are stored in Pi session data. Mosaic redacts common credential shapes
before appending its goal-state entries and before goal tool output or `/goal status`, but
pattern-based redaction is not a secret store. Pi's own model-message and tool-call records are
outside that redactor. Never put tokens, passwords, private keys, connection strings, or raw
sensitive output in a goal or report; cite the command, artifact, and pass/fail result instead.
The loop stops instead of running forever when it is paused, blocked, cancelled, verified, reaches
its turn limit, or repeats the same no-progress report too many times. Defaults are 40 turns and 6
repeated no-progress reports. Operators may lower or raise them within enforced bounds before
launching Pi:
```bash
MOSAIC_GOAL_MAX_TURNS=60 MOSAIC_GOAL_MAX_NO_PROGRESS=8 mosaic pi
```
Goal state is branch-specific Pi session data. It survives compaction and session resume, but Pi's
process still must be relaunched or supervised after a process/host failure. This initial verifier
checks structured evidence twice; it cannot mathematically prove every arbitrary natural-language
goal. Use explicit acceptance criteria and inspect `/goal status` for consequential work.
--- ---
### Claude Code Skill Registration ### Claude Code Skill Registration
@@ -0,0 +1,72 @@
# #1099 pipefail + early-exit sweep
Baseline: `df4c591ab42aa1ae62c12935fdc0e772684864a0`
This is a site inventory, not a risk count. `FIXED` means the early-exiting consumer no longer has a piped upstream process whose SIGPIPE can become the result under `pipefail`. `NOT-LOAD-BEARING` means the pipeline status is explicitly discarded. `UNREACHABLE-AND-WHY` describes designed input, not a payload-size safety claim.
## Tranche 1 — runtime and general scripts
| Baseline site | Verdict | Construction / reason |
| --- | --- | --- |
| `tools/matrix-presence-harness/run.sh:38` | FIXED | nullglob array selects the first path; no pipeline |
| `tools/e2e-install-test.sh:139` | FIXED | capture help completely, then grep via redirection |
| `tools/install.sh:312` | FIXED | NUL `mapfile` reads all roots; count != 1 reaches the named malformed-archive diagnostic |
| `scripts/analysis/reflect-board-history.sh:76` | FIXED | capture Git history completely, then grep via redirection |
| `scripts/analysis/reflect-git-history.sh:67` | FIXED | grep reads from a here-string |
| `scripts/analysis/reflect-git-history.sh:69` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/authentik/user-create.sh:72` | FIXED | jq `first(...)` reads the response directly |
| `packages/mosaic/framework/tools/git/mutate-push-guard.sh:87` | FIXED | grep `-m1` reads the file directly; downstream `cut` consumes its complete scalar output |
| `packages/mosaic/framework/tools/orchestrator/session-resume.sh:94` | FIXED | `mapfile` plus bounded indexed loop replaces `head` pipeline |
| `packages/mosaic/framework/tools/prdy/prdy-status.sh:69` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:172` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:173` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:174` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:175` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:176` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:177` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:178` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:16` | FIXED | Bash regex extracts the first field without a pipeline |
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:56` | FIXED | grep and bounded sed each read from a here-string |
| `packages/mosaic/framework/tools/tmux/send-message.sh:113` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/tmux/send-message.sh:124` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/wake/detector.sh:126` | FIXED | one awk reads the manifest directly and exits after the first exact key |
| `packages/mosaic/framework/tools/wake/detector.sh:270` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/wake/detector.sh:278` | FIXED | grep reads from a here-string |
| `packages/mosaic/framework/tools/wake/digest.sh:647` | FIXED | capture complete locator output, then select first line by parameter expansion |
| `packages/mosaic/framework/tools/wake/reconcile.sh:149` | FIXED | one awk reads the manifest directly and exits after the first exact key |
## Explicit withdrawn / non-load-bearing sites
| Baseline site | Verdict | Reason |
| --- | --- | --- |
| `tools/install.sh:182` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
| `tools/install.sh:356` | UNREACHABLE-AND-WHY | `pnpm pack` writes one matching CLI tarball into a fresh directory immediately before lookup; citation withdrawn in #1099 |
| `tools/install.sh:357` | UNREACHABLE-AND-WHY | same fresh-directory invariant for gateway tarball; citation withdrawn in #1099 |
| `tools/install.sh:627` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
| `scripts/agent/session-start.sh:70` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
| `packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh:58` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:25` | UNREACHABLE-AND-WHY | withdrawn in #1099 after designed-input reachability measurement; preserved without re-litigation |
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:27` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:30` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:32` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:34` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
## Tranche 2 — non-wake test harnesses
All 22 baseline sites below are `FIXED`; the checked-in tranche fixture is passed through the same scanner and asserts all 22 occurrences and 21 normalized identities (the same response-split line occurs twice).
| Baseline site(s) | Verdict | Construction |
| --- | --- | --- |
| `systemd/user/test-fleet-units.sh:148` | FIXED | capture tmux output, then grep via redirection |
| `git/test-issue-comment-readback.sh:283,302` | FIXED | parameter expansion splits status/body without `head` |
| `git/test-pr-review-gitea-comment.sh:228` | FIXED | parameter expansion splits status/body |
| `git/test-lane-brief-pr-linkage.sh:72` | FIXED | grep reads from a here-string |
| `git/test-pr-review-repo-host-override.sh:225-226` | FIXED | grep reads from a here-string |
| `orchestrator/smoke-test.sh:67,72` | FIXED | parameter expansion selects first line |
| `orchestrator/test-board-roll.sh:99-100` | FIXED | grep reads from a here-string |
| `quality/scripts/test-upgrade-durable-snapshot.sh:180` | FIXED | complete sorted output is read with `mapfile`, then indexed |
| `quality/scripts/test-upgrade-rollback.sh:339,356` | FIXED | direct `grep -m1` file reads; cleanup captures before testing |
| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions |
| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string |
Remaining wake-validation sites are intentionally deferred to the final review-sized tranche and are not yet assigned a safety verdict here.
+229
View File
@@ -0,0 +1,229 @@
# #1043 — Fleet pane git-identity propagation
## Objective
Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison.
## Tracking
- External issue: `mosaicstack/stack#1043`
- Branch: `fix/1043-pane-git-identity`
- Coordinator: `tl-mosaic`
- `docs/TASKS.md`: read-only by project worker contract; not modified.
## Constraints
- RED-first bug reproducer is mandatory.
- R7 delete-the-subject mutation must turn the behavioral test red.
- Assert launched-process environment, not source text.
- One push only; do not poll CI after push.
- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence.
- Do not modify a live host launcher or obtain/copy another credential.
- Self-post the PR, verify provider attribution, then stop.
- Final status wording: `believed-fixed, pending jarvis validation`.
## Scope inventory
Re-derived against `origin/main` at `85d2108e`:
- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts`
- Roster projection producers:
- `packages/mosaic/src/commands/fleet.ts`
- `packages/mosaic/src/fleet/fleet-reconciler.ts`
- `packages/mosaic/src/fleet/fleet-agent-crud.ts`
- `packages/mosaic/src/fleet/v1-v2-migration.ts`
- Contract and producer tests discovered by repository search.
- Generated-environment operator/developer docs and their executable documentation contract test.
Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior.
## Plan
1. Add the process-environment set-comparison regression first and record RED.
2. Add roster-derived `MOSAIC_GIT_IDENTITY=<agent name>` to the complete generated projection contract.
3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment.
4. Update affected projection tests and generated-environment docs.
5. Run focused and baseline gates.
6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN.
7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling.
## Budget
No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor.
## Evidence log
### TDD and mutation evidence
- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity.
- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`.
- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
- R7 restoration: restored that launch entry; the same test returned green.
- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy.
### Situational and focused tests
- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity.
- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green.
- Focused Mosaic Vitest set: 6 files, 311 tests — green.
- `bash -n` on changed shell files — green.
- `git diff --check` — green.
### Baseline gates
- `pnpm typecheck` — 45/45 tasks green.
- `pnpm lint` — 25/25 tasks green.
- `pnpm format:check` — green.
- `pnpm test:checkout` — green.
- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase.
- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates:
1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr;
2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`).
No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter.
### Independent review
- Codex code review first pass: request changes for missing shell rejection-path coverage.
- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call.
- Codex code re-review: **approve**, no findings, confidence 0.88.
- Codex security review: risk `none`, no findings, confidence 0.97.
### Acceptance criteria mapping
| Acceptance criterion | Evidence |
| --- | --- |
| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above |
| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests |
| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly |
### Documentation checklist
- PRD updated with #1043 requirements and acceptance criteria.
- Fleet launch runbook, generated-env concept, and generated-env reference updated.
- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies.
- `docs/TASKS.md` remains unmodified per its single-writer project contract.
## Round 2 — PR #1073 review 97 remediation
### Review blocker
The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression.
### RED-first and canonical wiring
1. Removed the suite's signed exclusion before adding a CI execution path.
2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18.
3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain.
4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition.
### Workflow-level R7
- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`.
- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain.
- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort.
- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical.
### Workflow population
- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`).
- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses.
- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change.
### Independent remediation review
- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install.
- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged.
- Codex re-review: **APPROVE**, confidence 0.93, no findings.
### Vitest denominator reconciliation
The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees:
- generated environment boundary: 24
- fleet documentation: 23
- Tess service profile: 6
- fleet regen command: 27
- fleet agent CRUD command: 22
- fleet command: 209
- total: **311**
Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic.
## Round 3 — Alpine stale-marker portability
### Objective and plan
- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion.
- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review.
- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling.
- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes.
### RED-first evidence
Before the fix, the canonical CI image command
`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
exited 1 at the stale-marker setup with exact BusyBox output
`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location.
### Root cause and fix
The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata.
### Structural pattern
This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening.
### GREEN and quality evidence
- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`.
- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces.
- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed.
- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN.
- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings.
- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings.
### Re-derived inventory and denominators
- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization.
- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path.
- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`).
- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets.
## Round 4 — bound stale-marker observation
### Objective and plan
- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window.
- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale.
- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image.
- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling.
- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged.
### Root cause and bounded observation
The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion.
The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed.
### Two-mutant RED / restored GREEN
All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`:
1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`.
2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls.
3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`.
### Re-derived inventory
- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty.
- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path.
- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds.
- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation.
## Residual risk
- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events.
- Canonical CI result is pending and will not be polled by this seat.
@@ -0,0 +1,97 @@
# #1098 — Framework shell portability / red main
## Objective
Restore terminal-green `main` by making the `test-start-agent-session.sh` clean-environment assertion semantic and portable without removing either newly enumerated framework-shell suite.
## Scope
- Tracking issue: `mosaicstack/stack#1098`
- Branch: `fix/framework-shell-portability`
- Base: `origin/main` at `4fa2768962702d53e16e8b67ee6ad52ebcb0910e`
- Primary file: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
- Requirements source: `docs/PRD.md` § Framework shell assertion portability (#1098)
- Out of scope: deployed files under `~/.config/mosaic`, pnpm-store cleanup, checkout deletion, and changes to the launchers `/usr/bin/env -i` behavior.
## Acceptance criteria
1. The test inspects the captured NUL-delimited tmux argv semantically and accepts an adjacent `/usr/bin/env`, `-i` pair regardless of trailing payload size or pipe scheduling.
2. Missing `/usr/bin/env`, missing `-i`, and non-adjacent `-i` remain failures.
3. Failure output includes the observed argv records with stable indexes and shell escaping; it exposes no credentials because this fixture supplies only generated non-secret launch data.
4. The focused suite passes on the dev host and in the repository CI image; the blocking PR/main pipeline returns terminal green.
5. Independent review passes; PR is squash-merged and #1098 is closed only after merged-main CI is terminal green.
## Budget
- ASSUMPTION: 30K-token working budget; rationale: one shell-test defect plus full PR/CI lifecycle.
- Auto-reduction: focused shell and package gates first; rely on canonical Woodpecker for the full monorepo suite rather than duplicating a dependency install under constrained `/home`.
- Disk baseline before clone/build: `/home` 7.1G free (99% used), `/tmp` 2.4G free (92% used).
## Investigation
### First-hand CI evidence
- Public log: `GET https://ci.mosaicstack.dev/api/repos/47/logs/2269/53041`
- Decoded 1,436 entries (11 null `data` entries treated as empty log rows), 190,756 bytes.
- Failure: `FAIL: pane command did not clear its environment` immediately after the expected pane-PID warning.
- BusyBox primitives, complete assertion pipeline, real CI image, stale/current image digests, Turbo cache masking, gateway failure, and heartbeat-sidecar concurrent writing were independently excluded.
### Root cause
The assertion ends in:
```bash
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i'
```
The script has `set -o pipefail`. `grep -q` exits as soon as it finds the valid `-i` record. Upstream `tail`/`printf` can then receive SIGPIPE, making the aggregate pipeline nonzero even though grep returned 0 and the semantic property is true. This depends on payload size, pipe capacity, and scheduling, explaining a local/image pass with a CI failure.
Discriminating stress control with `/usr/bin/env` followed immediately by `-i`:
- 8,192-byte trailing payload: `printf=0 tail=0 grep=0`, aggregate 0.
- 16,384-byte trailing payload: `printf=0 tail=141 grep=0`, aggregate 141.
- 32,768+ bytes: `printf=141 tail=141 grep=0`, aggregate 141.
- A full-reading `grep -xF` control remained 0 for every payload.
This is a third branch omitted by the earlier present-vs-corrupted split: the pair can be present and intact while `pipefail` reports an upstream SIGPIPE.
## TDD plan
1. RED: preserve the one-off stress reproducer above and add an automated large-argv semantic regression that fails under the current pipeline implementation.
2. GREEN: parse the authoritative NUL-delimited capture into a Bash array and search for an adjacent `/usr/bin/env`, `-i` pair without a short-circuit pipeline.
3. Add negative controls for missing, detached, and reversed tokens.
4. On failure, print indexed `%q` argv records before returning nonzero.
5. Run focused suite, mutation controls, shell syntax/format checks, then repository baseline gates feasible without dependency installation.
6. Independent review, queue guard, push, PR, CI, coordinator merge authorization, squash merge, merged-main CI, issue close.
## Progress
- [x] Checkout created and based on `origin/main` `4fa27689`.
- [x] CI log decoded directly.
- [x] Root-cause stress control reproduced semantic match + aggregate pipeline failure.
- [x] RED evidence: intact `/usr/bin/env`, `-i` fixture produced component statuses `0/141/0` and aggregate 141 under the former `grep -q` pipeline; full-reading semantic control stayed 0.
- [x] GREEN implementation: direct NUL-argv adjacency parser, indexed diagnostics, and full-reading scalar predicates replace all load-bearing early-exit pipelines in this test.
- [x] Baseline/situational tests:
- focused launcher suite: PASS on GNU host and cached Alpine CI image;
- paired `test-fleet-units.sh`: PASS;
- enumeration guard: PASS (`population=53`, `enumerated=36`, `excluded=18`), 14/14 mutation needles;
- `bash -n`, ShellCheck, `git diff --check`: PASS;
- static denominator after change: zero load-bearing `grep -q`/`head`/`-m1` pipeline candidates in `test-start-agent-session.sh`;
- delete-the-subject mutation removing production `-i`: RED with 78 indexed argv records, byte count, and explicit boundary failure.
- [x] Independent review:
- first Codex review: request changes — negative fixtures did not each assert diagnostics;
- remediation: centralized predicate + diagnostic wrapper and exercised all four negative fixtures;
- second Codex review: APPROVE, 0 blockers/should-fix/suggestions;
- Codex security review: risk none, 0 findings.
- [ ] PR CI, formal fleet review, merge, merged-main CI, issue closure.
## Documentation disposition
- Updated canonical `docs/PRD.md` with FSP requirements and acceptance criteria.
- This is an internal test/reliability change with no API, user workflow, deployment, navigation, or publishing-surface change; no user/admin/API/sitemap update is required.
- `docs/TASKS.md` remains unchanged because the project contract makes it orchestrator-only.
## Risks
- The CI failure did not print its captured argv, so the exact CI payload is unavailable. The stress control proves the assertion is non-portable and can emit the exact false verdict; branch CI is the canonical confirmation that replacing it resolves pipeline 2269s failure class.
- Printing fixture argv is safe only while this tests projection remains non-secret. The diagnostic must stay scoped to the test capture and shell-escaped.
+37
View File
@@ -0,0 +1,37 @@
# #1099 — pipefail + early-exit sweep
## Scope and decisions
- Baseline `df4c591ab42aa1ae62c12935fdc0e772684864a0`, after #1100 removed its 35 sites.
- Split into review-sized non-closing tranches: runtime/general; tmux/git/quality tests; wake validation/tests.
- Do not equate class membership with demonstrated risk. Do not use payload size or pipeline stage count as a safety proxy.
- Preserve the issue's withdrawn findings for `qa-hook-stdin.sh` and the two fresh-directory `pnpm pack` lookups. Fix `install.sh:312` because malformed multi-root input must reach its named handler.
## Tranche 1 TDD
RED-first control: `node --test scripts/pipefail-early-exit.test.mjs` reported exactly 26 non-accepted runtime/general sites, including `install.sh:312`, and exited 1. A checked-in fixture generated from immutable baseline `df4c591a` records all 26 normalized sites; the control passes every fixture entry through the same scanner, asserts exact identity/count/uniqueness, and separately requires zero findings in the current tree. It also inventories accepted sites rather than silently excluding whole files.
Construction choices:
- here-string/file redirection for scalar grep assertions;
- full capture then parameter expansion for first-line selection;
- arrays/`mapfile` for complete populations;
- direct jq/awk/grep selection where one tool can express the property;
- no `|| true` added to a load-bearing assertion.
Site-by-site verdicts: `docs/reports/quality/1099-pipefail-sweep.md`.
## Tranche 2 TDD
Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2.
## Verification so far
- `bash -n` on every changed shell script: pass.
- structural Node control: pass.
- `test-mutate-push-guard.sh`: 8/8 pass.
- `test-send-message-verdict.sh`: 3/3 pass.
- `test-send-message-socket.sh`: pass.
- Independent review 143 found two semantic regressions: a help-probe `|| true` changed the failure truth table, and an unguarded Git capture changed non-Git data-dir behavior from rc 0 + JSON to silent rc 128. Both received RED-first regressions before correction; help status is now separate and required, and Git status remains condition-guarded.
- Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced.
+156
View File
@@ -0,0 +1,156 @@
# #1150 — Pi persistent goal extension
- **Task ID:** ISSUE-1150 (no `docs/TASKS.md` row; that file is orchestrator-only)
- **Issue:** #1150`pi: add persistent /goal controller extension to Mosaic framework`
- **Branch:** `feat/1150-pi-goal-extension`
- **Mode:** Delivery
- **Status:** in progress
## Objective
Build and locally validate a Mosaic-owned Pi `/goal` extension. Source must ship from
`packages/mosaic/framework/runtime/pi/`, framework sync must deploy it under
`~/.config/mosaic/runtime/pi/`, and no extension/configuration asset may be written into `~/.pi`.
Pi's native session manager remains the owner of session entries.
## Scope and acceptance source
- Canonical requirements: `docs/PRD.md`, section **Pi Persistent Goal Loop (#1150)**.
- User intent: continuous goal orientation and status checking after each Pi turn and compaction,
tested locally before framework delivery.
- Documentation target: canonical in-repo user/developer/runtime docs; no external publication.
## Assumptions
- `ASSUMPTION:` Initial semantic verification uses two consecutive structured, evidence-bearing
reports from the working agent rather than a second model request after every turn. This keeps the
loop testable and avoids doubling model cost while making the limitation explicit.
- `ASSUMPTION:` Default autonomous bounds are 40 turns and 6 repeated no-progress reports, with only
bounded numeric environment overrides.
- `ASSUMPTION:` A local smoke copy to `~/.config/mosaic/runtime/pi/goal-extension.ts` is authorized by
the user's explicit request. Full framework reseed into the live home is not required for the smoke
test and would touch unrelated framework-owned files.
## Budget
- Working estimate: 30K implementation/review tokens.
- Hard user cap: none stated.
- Cost control: deterministic fake-Pi tests; no nested evaluator calls; only bounded arithmetic/load
smoke workflows against the installed runtime.
## Plan
1. Update PRD and create tracking/scratchpad artifacts.
2. Read launcher, installer ownership, Pi extension, and documentation surfaces.
3. TDD: add fake-Pi behavior tests for commands, state restoration, turn checks, compaction, limits,
verification, and continuation deduplication.
4. Implement `runtime/pi/goal-extension.ts` and deterministic launcher discovery.
5. Add framework-sync/deployment acceptance coverage.
6. Update user, developer, runtime, framework README, and sitemap documentation.
7. Run focused tests, local Mosaic-path smoke test, then baseline repository gates.
8. Run independent review, remediate, commit, push/PR/CI/merge/issue closure per delivery gates.
## TDD decision
Applied. The continuation state machine and lifecycle scheduling are control-path logic where a race
or false terminal state can cause unbounded work or premature completion.
## Progress checkpoints
- [x] Issue #1150 created through Mosaic wrapper.
- [x] Isolated worktree created from `origin/main`.
- [x] PRD requirements and acceptance criteria added.
- [x] Task scratchpad created.
- [x] RED controller and security-regression tests written and observed failing before implementation.
- [x] Goal controller, launcher discovery, framework deployment coverage, and bounded state machine
implemented.
- [x] User, admin, developer, runtime, adapter, README, and sitemap documentation updated.
- [x] Final source copied additively to `~/.config/mosaic/runtime/pi/goal-extension.ts`; source and
deployed SHA-256 are identical.
- [x] Live Pi RPC smoke from the exact Mosaic path reached `achieved` with two verification passes and
no extension errors.
- [x] Baseline and situational checks completed, except the explicitly documented unavailable
PostgreSQL-only root integration case.
- [x] Independent code and OWASP/security reviews completed; all findings remediated and re-reviewed.
- [ ] Commit, push, PR, terminal-green CI, squash merge, and issue closure complete.
## Tests and evidence
### Situational
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/runtime/pi-goal-extension.spec.ts`
- final: 25 passed.
- Covers commands, per-turn checks, context injection, two-pass verification, mixed-report
rejection, bounded limits, compaction, branch restore, stale timers, credential redaction,
typed-field false-positive protection, and append-only legacy-state fail-closed behavior.
- Final focused launcher/controller/file-adapter run: 3 files / 67 tests passed.
- Final V8 coverage for `framework/runtime/pi/goal-extension.ts`:
- 99.17% statements/lines, 93.78% branches, 100% functions.
- Installer migration fixture: 24 passed and byte-compared the deployed framework asset.
- Standalone extension TypeScript check against installed Pi 0.84.1 types passed:
`pnpm --filter @mosaicstack/mosaic exec tsc --noEmit --pretty false --module NodeNext
--moduleResolution NodeNext --target ES2022 --skipLibCheck framework/runtime/pi/goal-extension.ts`.
- Live deployment/load evidence:
- source/deployed SHA-256:
`1f0a3806e0948ad5f49684273a7e535e9880c148f7fd16d13ee487fcd601f637`.
- `get_commands` identified `/goal` as an extension command sourced from
`~/.config/mosaic/runtime/pi/goal-extension.ts`; `/goal help` succeeded; zero extension errors.
- live arithmetic goal ended `achieved`, verification `2/2`, with 3 goal reports / 3 agent starts
and zero extension errors.
- no goal extension exists under `~/.pi` extension paths.
### Baseline
- `pnpm build`: passed before the final framework-only redaction remediation; the extension is not a
package build input and its final source passed the standalone Pi type check.
- `pnpm typecheck`: 45/45 tasks passed.
- `pnpm lint`: 25/25 tasks passed.
- `pnpm format:check`: passed.
- Final Mosaic package components:
- Vitest: 82 files / 1,539 tests passed.
- full `test:framework-shell` harness passed.
- the discovered pre-existing tmux loader-marker race was reproduced with constructor PID
evidence, fixed with a pane readiness/FIFO barrier, passed 3 consecutive focused runs, and passed
in the full shell harness.
- one combined rerun encountered the separate existing real-lease probe TOCTOU in
`install-ordering-guard.spec.ts`; an earlier final Vitest run was fully green and the changed
focused suites remained green.
- Gateway safe baseline excluding the prohibited PostgreSQL-only fixture: 55 files / 600 tests passed
(6 files / 12 tests skipped by their existing environment gates).
- Root `pnpm test` reached 43 successful workspace tasks and all changed-package Vitest tests, but
the unchanged `apps/gateway/src/__tests__/cross-user-isolation.test.ts` afterAll hook retried a
PostgreSQL connection and failed authentication (`28P01`). This checkout explicitly forbids local
PostgreSQL startup/access; the failure is unrelated to #1150 and cannot be remediated by starting
the database. The gateway suite excluding that PostgreSQL-only file and required CI are used as
the safe verification paths.
### Independent review
- Codex code review: approved, 0 findings across 15 files.
- Initial Codex security review: one medium CWE-532/A09 finding for raw report persistence.
- Remediation added central credential-pattern redaction, prompt/docs guidance, canary tests, typed
field false-positive guards, and sticky fail-closed restore for credential-bearing append-only
history.
- Codex security re-review: risk `none`, 0 findings, confidence 0.87.
- Focused remediation review findings were fixed; final focused re-review verdict: `APPROVE`.
- Focused independent review of the tmux readiness barrier: `APPROVE`, no actionable findings.
## Risks and blockers
- Live `~/.config/mosaic` is shared by active Pi/fleet processes. Local deployment remained a single
additive framework file and did not reload or restart unrelated sessions.
- Completion verification is semantic, not mathematical: the active agent supplies structured
evidence twice. Operators must still inspect consequential outcomes.
- Credential redaction is pattern-based defense-in-depth, not a secret store. It covers
controller-owned state/status/tool details, not Pi's separate model-message/tool-call history.
Goals and reports must never contain real secrets or raw sensitive output. Because Pi session
entries are append-only, a detected credential-bearing legacy branch fails closed and the affected
session must be removed.
- Current installed Pi is newer than the repository's historical gateway Pi dependency. The
extension was checked and smoke-tested against installed Pi 0.84.1 using stable documented APIs.
- Local root testing cannot safely execute the unchanged PostgreSQL-only integration fixture under
the checkout's explicit database safety constraints. Terminal-green PR CI remains mandatory before
merge.
- The unchanged real-lease default-probe test can observe different broker availability across its two
sequential probes; one combined package rerun hit that existing TOCTOU. The same final Vitest suite
passed in a separate run, and CI remains the merge authority.
+3 -1
View File
@@ -13,7 +13,8 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
1. Injects the full runtime contract via `--append-system-prompt` 1. Injects the full runtime contract via `--append-system-prompt`
2. Loads Mosaic skills via `--skill` flags 2. Loads Mosaic skills via `--skill` flags
3. Loads the Mosaic extension via `--extension` for lifecycle hooks 3. Loads framework-owned `mosaic-extension.ts` and `goal-extension.ts` from
`~/.config/mosaic/runtime/pi/` via ordered `--extension` flags
4. Detects active missions and injects initial prompts 4. Detects active missions and injects initial prompts
## Capabilities vs Other Runtimes ## Capabilities vs Other Runtimes
@@ -22,6 +23,7 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
- Native thinking levels replace sequential-thinking MCP - Native thinking levels replace sequential-thinking MCP
- Native skill discovery compatible with Mosaic SKILL.md format - Native skill discovery compatible with Mosaic SKILL.md format
- Native extension system for lifecycle hooks (TypeScript, not bash shims) - Native extension system for lifecycle hooks (TypeScript, not bash shims)
- Bounded persistent `/goal` loop with per-turn, post-compaction, and two-pass evidence checks
- Native session persistence and resume - Native session persistence and resume
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers) - Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
+10 -3
View File
@@ -94,7 +94,14 @@ The launcher:
1. Verifies `~/.config/mosaic` exists 1. Verifies `~/.config/mosaic` exists
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing) 2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
3. Injects `AGENTS.md` into the runtime 3. Injects `AGENTS.md` into the runtime
4. Forwards all arguments to the runtime CLI 4. For Pi, loads the framework-owned core and persistent-goal extensions from
`~/.config/mosaic/runtime/pi/`
5. Forwards all arguments to the runtime CLI
Inside `mosaic pi`, `/goal set <statement>` starts a bounded persistent goal loop. Use `/goal status`,
`/goal pause`, `/goal resume`, or `/goal cancel` to control it. The extension remains part of Mosaic
under `~/.config/mosaic/runtime/pi/goal-extension.ts`; it is not installed in Pi's main extension
directory.
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`. You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
@@ -114,7 +121,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks │ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
│ ├── codex/ ← instructions.md, RUNTIME.md │ ├── codex/ ← instructions.md, RUNTIME.md
│ ├── opencode/ ← AGENTS.md, RUNTIME.md │ ├── opencode/ ← AGENTS.md, RUNTIME.md
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts │ ├── pi/ ← RUNTIME.md, mosaic-extension.ts, goal-extension.ts
│ └── mcp/ ← MCP server configs │ └── mcp/ ← MCP server configs
├── skills/ ← Universal skills (synced from mosaic/agent-skills) ├── skills/ ← Universal skills (synced from mosaic/agent-skills)
├── skills-local/ ← Local cross-runtime skills ├── skills-local/ ← Local cross-runtime skills
@@ -126,7 +133,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
| Launch method | Injection mechanism | | Launch method | Injection mechanism |
| ------------------- | ----------------------------------------------------------------------------------------- | | ------------------- | ----------------------------------------------------------------------------------------- |
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + extension | | `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + Mosaic extensions |
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) | | `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch | | `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch | | `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
@@ -51,12 +51,26 @@ Skills are discovered from:
### Extensions ### Extensions
The Mosaic Pi extension (`~/.config/mosaic/runtime/pi/mosaic-extension.ts`) handles: `mosaic pi` loads framework-owned extensions directly from `~/.config/mosaic/runtime/pi/` in this
order:
- Session start/end lifecycle hooks 1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates,
- Active mission detection and context injection and fleet heartbeat reporting.
- Memory routing to `~/.config/mosaic/memory/` 2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction
- MACP queue status reporting checks.
The goal extension is deployed by Mosaic and MUST NOT be copied into `~/.pi/agent/extensions/`.
Use `/goal set <statement>` (or `/goal <statement>`) to start, then `/goal status`, `/goal pause`,
`/goal resume`, or `/goal cancel` to control it. An active goal is injected before every model
request, restored from branch-specific session entries, and considered achieved only after two
consecutive evidence-bearing reports. Common credential shapes are redacted before controller-owned
goal-state entries are persisted or
displayed; Pi's own model/tool-call history is separate. Goals and reports must contain references
and pass/fail summaries rather than secrets or raw sensitive output.
- `MOSAIC_GOAL_MAX_TURNS` — autonomous turn limit, default `40`, accepted range `1..500`.
- `MOSAIC_GOAL_MAX_NO_PROGRESS` — identical no-progress report limit, default `6`, accepted range
`1..100`.
### Sessions ### Sessions
File diff suppressed because it is too large Load Diff
@@ -66,7 +66,10 @@ if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
MARKER="$TEST_ROOT/loader-marker" MARKER="$TEST_ROOT/loader-marker"
LIBRARY="$TEST_ROOT/marker.so" LIBRARY="$TEST_ROOT/marker.so"
FIXTURE_READY="$TEST_ROOT/loader-ready"
FIXTURE_FIFO="$TEST_ROOT/loader-block"
HOLDER_HOME="$TEST_ROOT/holder-home" HOLDER_HOME="$TEST_ROOT/holder-home"
mkfifo "$FIXTURE_FIFO"
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run" mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \ chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run" "$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
@@ -87,7 +90,17 @@ __attribute__((constructor)) static void mark_loader(void) {
EOF EOF
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c" cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \ MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60' tmux -L "$TEST_SOCKET" new-session -d -s _holder \
"touch '$FIXTURE_READY'; read _ < '$FIXTURE_FIFO'"
# tmux starts the pane asynchronously. Wait until its contaminated shell has
# loaded the constructor and reached a builtin-only FIFO barrier before
# clearing the marker; otherwise that expected constructor can race with the
# clean holder assertion below and create a false failure.
for _attempt in {1..100}; do
[ -e "$FIXTURE_READY" ] && break
sleep 0.01
done
[ -e "$FIXTURE_READY" ] || fail "contaminated fixture pane did not become ready"
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor" [ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}') server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
: > "$MARKER" : > "$MARKER"
@@ -112,6 +125,7 @@ EOF
chmod 700 "$AGENT_HOME/fleet/agents" chmod 700 "$AGENT_HOME/fleet/agents"
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
MOSAIC_AGENT_NAME=$AGENT_NAME MOSAIC_AGENT_NAME=$AGENT_NAME
MOSAIC_GIT_IDENTITY=$AGENT_NAME
MOSAIC_AGENT_CLASS=code MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL= MOSAIC_AGENT_MODEL=
@@ -144,7 +158,8 @@ EOF
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \ /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START" MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created" tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then ld_preload_env="$(tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null)" || true
if grep -q '^LD_PRELOAD=' <<<"$ld_preload_env"; then
fail "fresh holder retained LD_PRELOAD" fail "fresh holder retained LD_PRELOAD"
fi fi
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \ /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
@@ -69,7 +69,7 @@ if [[ -n "$GROUP" ]]; then
group_response=$(curl -sk \ group_response=$(curl -sk \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
"${AUTHENTIK_URL}/api/v3/core/groups/?search=${GROUP}") "${AUTHENTIK_URL}/api/v3/core/groups/?search=${GROUP}")
group_pk=$(echo "$group_response" | jq -r ".results[] | select(.name == \"$GROUP\") | .pk" | head -1) group_pk=$(jq -r "first(.results[] | select(.name == \"$GROUP\") | .pk) // empty" <<<"$group_response")
if [[ -n "$group_pk" ]]; then if [[ -n "$group_pk" ]]; then
payload=$(echo "$payload" | jq --arg gk "$group_pk" '. + {groups: [$gk]}') payload=$(echo "$payload" | jq --arg gk "$group_pk" '. + {groups: [$gk]}')
else else
@@ -97,7 +97,7 @@ is_sensitive_key() {
is_generated_key() { is_generated_key() {
case "$1" in case "$1" in
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;; MOSAIC_AGENT_NAME|MOSAIC_GIT_IDENTITY|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
*) return 1 ;; *) return 1 ;;
esac esac
} }
@@ -114,6 +114,7 @@ validate_generated_value() {
local value="$2" local value="$2"
case "$key" in case "$key" in
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;; MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
MOSAIC_GIT_IDENTITY) safe_agent_name "$value" || fail_env unsafe-git-identity "$key" "$value" ;;
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;; MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
MOSAIC_AGENT_RUNTIME) MOSAIC_AGENT_RUNTIME)
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
@@ -175,7 +176,7 @@ load_environment_file() {
load_environment_file "$GENERATED_ENV" generated load_environment_file "$GENERATED_ENV" generated
for required_key in \ for required_key in \
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \ MOSAIC_AGENT_NAME MOSAIC_GIT_IDENTITY MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" '' [ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
done done
@@ -183,12 +184,15 @@ load_environment_file "$LOCAL_ENV" local
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \ [ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
[ "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}" = "$AGENT_NAME" ] || \
fail_env git-identity-mismatch MOSAIC_GIT_IDENTITY "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}"
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]} MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]} MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]} MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]} MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]} MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
MOSAIC_GIT_IDENTITY=${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]} MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]} MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-} MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
@@ -343,6 +347,7 @@ LAUNCH_ENV=(
"PATH=$PANE_PATH" "PATH=$PANE_PATH"
"MOSAIC_HOME=$MOSAIC_HOME" "MOSAIC_HOME=$MOSAIC_HOME"
"MOSAIC_AGENT_NAME=$AGENT_NAME" "MOSAIC_AGENT_NAME=$AGENT_NAME"
"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS" "MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME" "MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL" "MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
@@ -14,6 +14,82 @@ fail() {
exit 1 exit 1
} }
pane_command_clears_environment() {
local calls_file="$1"
local -a argv=()
local index
mapfile -d '' -t argv < "$calls_file"
for ((index = 0; index + 1 < ${#argv[@]}; index++)); do
if [ "${argv[$index]}" = /usr/bin/env ] && [ "${argv[$((index + 1))]}" = -i ]; then
return 0
fi
done
return 1
}
print_pane_argv() {
local calls_file="$1"
local -a argv=()
local bytes index
mapfile -d '' -t argv < "$calls_file"
bytes=$(wc -c < "$calls_file")
printf 'observed pane argv: records=%s bytes=%s\n' "${#argv[@]}" "$bytes" >&2
for ((index = 0; index < ${#argv[@]}; index++)); do
printf ' [%03d] %q\n' "$index" "${argv[$index]}" >&2
done
}
check_pane_environment_boundary() {
local calls_file="$1"
if pane_command_clears_environment "$calls_file"; then
return 0
fi
print_pane_argv "$calls_file"
return 1
}
contains_literal() {
grep -F -- "$2" <<< "$1" >/dev/null
}
contains_line() {
grep -xF -- "$2" <<< "$1" >/dev/null
}
# Portability regression: inspect the authoritative NUL-delimited argv instead
# of piping a newline reconstruction through `grep -q` under pipefail. The old
# pipeline could report failure after a successful match when an upstream
# producer received SIGPIPE. A large trailing argument keeps that failure class
# covered without making stream size part of the semantic contract.
PORTABILITY_CALLS="$ROOT/portability-calls"
printf -v PORTABILITY_PADDING '%*s' 32768 ''
PORTABILITY_PADDING=${PORTABILITY_PADDING// /x}
printf '%s\0' /usr/bin/env -i "$PORTABILITY_PADDING" > "$PORTABILITY_CALLS"
pane_command_clears_environment "$PORTABILITY_CALLS" || \
fail "valid large pane argv was rejected by the environment-boundary assertion"
assert_pane_boundary_rejected() {
local case_name="$1"
local expected_records="$2"
local diagnostic
if diagnostic=$(check_pane_environment_boundary "$PORTABILITY_CALLS" 2>&1); then
fail "pane boundary accepted invalid $case_name fixture"
fi
contains_literal "$diagnostic" "records=$expected_records bytes=" || \
fail "pane argv diagnostic omitted counts for $case_name fixture"
contains_literal "$diagnostic" '[000]' || \
fail "pane argv diagnostic omitted indexed arguments for $case_name fixture"
}
printf '%s\0' tmux -i > "$PORTABILITY_CALLS"
assert_pane_boundary_rejected missing-env 2
printf '%s\0' /usr/bin/env HOME=/untrusted > "$PORTABILITY_CALLS"
assert_pane_boundary_rejected missing-i 2
printf '%s\0' /usr/bin/env HOME=/untrusted -i > "$PORTABILITY_CALLS"
assert_pane_boundary_rejected non-adjacent-i 3
printf '%s\0' -i /usr/bin/env > "$PORTABILITY_CALLS"
assert_pane_boundary_rejected reversed-boundary 2
cat > "$FAKE_BIN/tmux" <<'SHIM' cat > "$FAKE_BIN/tmux" <<'SHIM'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -62,6 +138,19 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
SHIM SHIM
chmod +x "$FAKE_BIN/mosaic" chmod +x "$FAKE_BIN/mosaic"
# Freeze numeric epoch reads only when a test arm supplies an observation bound.
# Formatting reads still use the real BusyBox/POSIX date implementation.
cat > "$FAKE_BIN/date" <<'SHIM'
#!/usr/bin/env bash
set -euo pipefail
if [ -n "${MOSAIC_TEST_FIXED_EPOCH:-}" ] && [ "${1:-}" = '+%s' ]; then
printf '%s\n' "$MOSAIC_TEST_FIXED_EPOCH"
exit 0
fi
exec /bin/date "$@"
SHIM
chmod +x "$FAKE_BIN/date"
write_generated() { write_generated() {
local home="$1" local home="$1"
local agent="$2" local agent="$2"
@@ -71,6 +160,7 @@ write_generated() {
chmod 600 "$home/fleet/run/holder-owner" chmod 600 "$home/fleet/run/holder-owner"
cat > "$home/fleet/agents/$agent.env.generated" <<EOF cat > "$home/fleet/agents/$agent.env.generated" <<EOF
MOSAIC_AGENT_NAME=$agent MOSAIC_AGENT_NAME=$agent
MOSAIC_GIT_IDENTITY=$agent
MOSAIC_AGENT_CLASS=code MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
@@ -88,6 +178,7 @@ run_start() {
local agent="$2" local agent="$2"
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \ HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \ MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
MOSAIC_TEST_FIXED_EPOCH="${MOSAIC_TEST_FIXED_EPOCH:-}" \
MOSAIC_TEST_HOME="$home" \ MOSAIC_TEST_HOME="$home" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$home" "$START" "$agent" MOSAIC_HOME="$home" "$START" "$agent"
@@ -100,19 +191,55 @@ AGENT_VALID="coder0"
write_generated "$HOME_VALID" "$AGENT_VALID" write_generated "$HOME_VALID" "$AGENT_VALID"
run_start "$HOME_VALID" "$AGENT_VALID" run_start "$HOME_VALID" "$AGENT_VALID"
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS") valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux" contains_literal "$valid_args" new-session || fail "valid generated projection did not reach tmux"
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing" contains_literal "$valid_args" mosaic || fail "fixed mosaic launcher command missing"
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing" contains_literal "$valid_args" yolo || fail "fixed yolo launcher command missing"
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing" contains_literal "$valid_args" pi || fail "roster runtime missing"
if echo "$valid_args" | grep -qF 'bash -c'; then if contains_literal "$valid_args" 'bash -c'; then
fail "launcher constructed a shell command payload" fail "launcher constructed a shell command payload"
fi fi
# The pane must start through an absolute clean-environment boundary. Its # The pane must start through an absolute clean-environment boundary. Its
# runtime command remains an argv vector, but no holder/session environment # runtime command remains an argv vector, but no holder/session environment
# control variable can pass through the pane command. # control variable can pass through the pane command.
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env" check_pane_environment_boundary "$TMUX_CALLS" || \
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared" fail "pane command did not use an adjacent /usr/bin/env -i boundary"
# Git identity is generated authority, not an optional or independently mutable
# local value. Each invalid form must fail before fake tmux receives a call.
assert_git_identity_rejected() {
local case_name="$1"
local expected_code="$2"
local home="$ROOT/git-identity-$case_name"
local agent="coder-git-identity-$case_name"
local generated="$home/fleet/agents/$agent.env.generated"
write_generated "$home" "$agent"
case "$case_name" in
missing) grep -v '^MOSAIC_GIT_IDENTITY=' "$generated" > "$generated.next" && mv "$generated.next" "$generated" ;;
unsafe) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=bad/identity|' "$generated" ;;
mismatch) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=other-agent|' "$generated" ;;
local-shadow)
printf 'MOSAIC_GIT_IDENTITY=%s\n' "$agent" > "$home/fleet/agents/$agent.env.local"
chmod 600 "$home/fleet/agents/$agent.env.local"
;;
*) fail "unknown Git identity rejection case: $case_name" ;;
esac
chmod 600 "$generated"
: > "$TMUX_CALLS"
if output=$(run_start "$home" "$agent" 2>&1); then
fail "Git identity case $case_name was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before Git identity $case_name rejection"
contains_literal "$output" "code=$expected_code" || \
fail "Git identity $case_name diagnostic omitted code $expected_code"
}
assert_git_identity_rejected missing missing-key
assert_git_identity_rejected unsafe unsafe-git-identity
assert_git_identity_rejected mismatch git-identity-mismatch
assert_git_identity_rejected local-shadow generated-key-shadow
# The generated-file parent is a security boundary too: even a private regular # The generated-file parent is a security boundary too: even a private regular
# file is untrusted if its parent can be replaced or written by another user. # file is untrusted if its parent can be replaced or written by another user.
@@ -125,7 +252,7 @@ if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
fail "generated file under a world-writable parent was accepted" fail "generated file under a world-writable parent was accepted"
fi fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection" [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing" contains_literal "$output" 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
: > "$TMUX_CALLS" : > "$TMUX_CALLS"
HOME_SYMLINK_PARENT="$ROOT/symlink-parent" HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
@@ -136,7 +263,7 @@ if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
fail "generated file under a symlinked parent was accepted" fail "generated file under a symlinked parent was accepted"
fi fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection" [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing" contains_literal "$output" 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A # Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
# symlink or group/world-writable ancestor must fail before environment parsing, # symlink or group/world-writable ancestor must fail before environment parsing,
@@ -174,8 +301,8 @@ assert_managed_ancestor_rejected() {
fi fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection" [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection" [ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing" contains_literal "$output" 'code=unsafe-' || fail "managed ancestor diagnostic missing"
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then if contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND'; then
fail "environment parsing ran before $hazard $ancestor rejection" fail "environment parsing ran before $hazard $ancestor rejection"
fi fi
} }
@@ -196,9 +323,9 @@ if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
fail "generated-key shadow was accepted" fail "generated-key shadow was accepted"
fi fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection" [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key" contains_literal "$output" 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash" contains_literal "$output" 'sha256=' || fail "shadow diagnostic omitted hash"
if echo "$output" | grep -qF 'codex'; then if contains_literal "$output" codex; then
fail "shadow diagnostic leaked value" fail "shadow diagnostic leaked value"
fi fi
@@ -214,9 +341,9 @@ if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
fail "arbitrary command override was accepted" fail "arbitrary command override was accepted"
fi fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection" [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key" contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash" contains_literal "$output" 'sha256=' || fail "command diagnostic omitted hash"
if echo "$output" | grep -qF "$COMMAND_VALUE"; then if contains_literal "$output" "$COMMAND_VALUE"; then
fail "command diagnostic leaked command value" fail "command diagnostic leaked command value"
fi fi
@@ -230,7 +357,7 @@ if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
fail "world-readable local input was accepted" fail "world-readable local input was accepted"
fi fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection" [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing" contains_literal "$output" 'code=unsafe-permissions' || fail "permission diagnostic missing"
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and # A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
# computed PATH only. The pane command itself must not carry loader, shell # computed PATH only. The pane command itself must not carry loader, shell
@@ -260,25 +387,35 @@ PATH="$PANE_STALE_PATH" \
MOSAIC_TEST_EXECUTE_PANE=1 \ MOSAIC_TEST_EXECUTE_PANE=1 \
"$START" coder-pane-boundary "$START" coder-pane-boundary
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS") pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ contains_line "$pane_args" "HOME=$PANE_TRUSTED_HOME" || \
fail "pane did not restore trusted HOME" fail "pane did not restore trusted HOME"
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \ contains_literal "$pane_args" "HOME=$PANE_STALE_HOME" && \
fail "pane inherited stale HOME" fail "pane inherited stale HOME"
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH" contains_literal "$pane_args" "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked" contains_literal "$pane_args" "$blocked" && fail "pane inherited $blocked"
done done
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1) check_pane_environment_boundary "$TMUX_CALLS" || \
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env" fail "pane command did not use an adjacent /usr/bin/env -i boundary"
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
fail "pane command did not clear its environment"
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment") pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ # Exercise the repository launcher at $START, not the independently installed
# host copy. Set-compare every declared generated projection entry with the
# launched process environment so a newly declared identity cannot be omitted
# by a hand-maintained per-variable assertion.
declared_generated_environment=$(sort "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.generated")
missing_or_changed_generated_environment=$(comm -23 \
<(printf '%s\n' "$declared_generated_environment") \
<(printf '%s\n' "$pane_environment" | sort))
if [ -n "$missing_or_changed_generated_environment" ]; then
missing_or_changed_keys=$(printf '%s\n' "$missing_or_changed_generated_environment" | cut -d= -f1 | paste -sd, -)
fail "runtime pane omitted or changed generated environment keys: $missing_or_changed_keys"
fi
contains_line "$pane_environment" "HOME=$PANE_TRUSTED_HOME" || \
fail "runtime pane did not receive trusted HOME" fail "runtime pane did not receive trusted HOME"
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH" contains_literal "$pane_environment" "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked" contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
done done
write_interaction_generated() { write_interaction_generated() {
@@ -290,6 +427,7 @@ write_interaction_generated() {
chmod 600 "$home/fleet/run/holder-owner" chmod 600 "$home/fleet/run/holder-owner"
cat > "$home/fleet/agents/$agent.env.generated" <<EOF cat > "$home/fleet/agents/$agent.env.generated" <<EOF
MOSAIC_AGENT_NAME=$agent MOSAIC_AGENT_NAME=$agent
MOSAIC_GIT_IDENTITY=$agent
MOSAIC_AGENT_CLASS=operator-interaction MOSAIC_AGENT_CLASS=operator-interaction
MOSAIC_AGENT_RUNTIME=pi MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
@@ -352,8 +490,12 @@ write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale" write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb" STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB" printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
touch -d '10 seconds ago' "$STALE_HB.native" touch -t 200001010000.00 "$STALE_HB.native"
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale # Hold the sidecar's observation epoch constant: assertion runtime must not age
# a fresh-marker mutant into the stale state that this fixture must distinguish.
STALE_OBSERVATION_EPOCH=$(date +%s)
MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
wait_for_sidecar_status "$STALE_HB" wait_for_sidecar_status "$STALE_HB"
HOME_NATIVE_ABSENT="$ROOT/native-absent" HOME_NATIVE_ABSENT="$ROOT/native-absent"
@@ -374,22 +516,22 @@ if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed
fail "interaction wrapper accepted malformed generated data" fail "interaction wrapper accepted malformed generated data"
fi fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection" [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first" contains_literal "$output" 'code=unknown-key' || fail "interaction did not use shared strict parser first"
# A syntactically valid but policy-incompatible projection reaches the pinned # A syntactically valid but policy-incompatible projection reaches the pinned
# interaction policy check only after strict parsing and never starts tmux. # interaction policy check only after strict parsing and never starts tmux.
: > "$TMUX_CALLS" : > "$TMUX_CALLS"
HOME_INTERACTION_POLICY="$ROOT/interaction-policy" HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy" write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \ sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated" "$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
fail "interaction wrapper accepted a policy-incompatible projection" fail "interaction wrapper accepted a policy-incompatible projection"
fi fi
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS") interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$interaction_policy_args" | grep -qF 'new-session' && \ contains_literal "$interaction_policy_args" new-session && \
fail "interaction pinned-policy rejection created a tmux session" fail "interaction pinned-policy rejection created a tmux session"
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \ contains_literal "$output" 'operator interaction service requires runtime pi' || \
fail "interaction pinned-policy check did not follow strict parsing" fail "interaction pinned-policy check did not follow strict parsing"
# Exact stop derives the socket exclusively from the validated generated # Exact stop derives the socket exclusively from the validated generated
@@ -402,10 +544,10 @@ HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS") stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket" contains_line "$stop_args" mosaic-test || fail "exact stop did not use the validated generated socket"
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination" contains_line "$stop_args" kill-session || fail "exact stop did not request session termination"
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name" contains_line "$stop_args" '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
if echo "$stop_args" | grep -qF 'ambient-socket'; then if contains_literal "$stop_args" ambient-socket; then
fail "exact stop trusted an ambient socket" fail "exact stop trusted an ambient socket"
fi fi
@@ -5,7 +5,10 @@
detect_platform() { detect_platform() {
local remote_url local remote_url
remote_url=$(git remote get-url origin 2>/dev/null) # `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
# kills the CALLER before the -z check below can run, so the error message that is
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
remote_url=$(git remote get-url origin 2>/dev/null) || true
if [[ -z "$remote_url" ]]; then if [[ -z "$remote_url" ]]; then
echo "error: not a git repository or no origin remote" >&2 echo "error: not a git repository or no origin remote" >&2
@@ -39,7 +42,10 @@ detect_platform() {
get_repo_info() { get_repo_info() {
local remote_url local remote_url
remote_url=$(git remote get-url origin 2>/dev/null) # `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
# kills the CALLER before the -z check below can run, so the error message that is
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
remote_url=$(git remote get-url origin 2>/dev/null) || true
if [[ -z "$remote_url" ]]; then if [[ -z "$remote_url" ]]; then
echo "error: not a git repository or no origin remote" >&2 echo "error: not a git repository or no origin remote" >&2
@@ -91,13 +91,32 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login || true) GITEA_LOGIN_NAME=$(get_gitea_login || true)
if [[ -n "$GITEA_LOGIN_NAME" ]]; then if [[ -n "$GITEA_LOGIN_NAME" ]]; then
if [[ -n "$COMMENT" ]]; then if [[ -n "$COMMENT" ]]; then
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" # `tea issue comment` is NOT a subcommand -- tea 0.11.x lists only
# list/create/edit/reopen/close under `tea issue`. Comments are the
# TOP-LEVEL `tea comment`, which takes the same --repo/--login flags.
# The old call therefore always failed, was unchecked, and the script
# closed the issue anyway, losing the record of WHY.
#
# Use `tea comment` rather than the API helper so the comment and the
# close are made by the SAME principal ($GITEA_LOGIN_NAME). Routing the
# comment through the token-authenticated helper here would attribute the
# comment to the token holder and the close to the tea login -- two
# principals for one operation.
tea comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || {
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
exit 1
}
fi fi
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
else else
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2 echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
if [[ -n "$COMMENT" ]]; then if [[ -n "$COMMENT" ]]; then
gitea_issue_comment_api # Fail closed here too: an unchecked comment lets the issue close without its
# audit trail, which is the same defect as the tea path above.
gitea_issue_comment_api || {
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
exit 1
}
fi fi
gitea_issue_close_api gitea_issue_close_api
fi fi
@@ -84,7 +84,7 @@ cp "$TARGET" "$BAK"
export MOSAIC_TEST_WORK_DIR="$WORK/.work" export MOSAIC_TEST_WORK_DIR="$WORK/.work"
# --- where the prose lives: usage() { ... EOF --------------------------------- # --- where the prose lives: usage() { ... EOF ---------------------------------
PROSE_LO="$(grep -n '^usage() {' "$BAK" | head -1 | cut -d: -f1)" PROSE_LO="$(grep -n -m1 '^usage() {' "$BAK" | cut -d: -f1)"
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")" PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2 echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
@@ -0,0 +1,58 @@
#!/bin/bash
# Regression: detect_platform / get_repo_info must FAIL LOUDLY outside a git repo,
# not kill the caller silently.
#
# Both functions already contained the right error path:
# if [[ -z "$remote_url" ]]; then echo "error: not a git repository..." >&2; return 1; fi
# but under `set -e` -- which every wrapper in this directory uses -- the preceding
# assignment `remote_url=$(git remote get-url origin 2>/dev/null)` returns git's 128
# outside a repo and terminates the CALLER first. The message was unreachable.
#
# Observed cost: pr-review.sh invoked from a non-repo cwd exits 128 with NO stdout and
# NO stderr, even when -r/--repo and -H/--host are supplied -- the flags documented as
# "skips git-remote inference". Two reviewer seats hit this and correctly reported
# `blocked` with no diagnostic to report.
#
# The control that matters is the LOUD one: asserting "rc != 0" passes on the broken
# build too, because 128 is also non-zero. The test must assert the MESSAGE.
set -uo pipefail
fail=0
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
run_outside() { # $1=function name -> "rc:sawmessage"
local fn="$1" out rc
out=$( cd "$TMP" && bash -c "set -e; source '$HERE/detect-platform.sh'; $fn" 2>&1 ); rc=$?
printf '%s:%s' "$rc" "$(grep -qi 'not a git repository' <<<"$out" && echo yes || echo no)"
}
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
# $TMP must not be inside a git repo. Do not SKIP on failure: be-coder-07 showed the
# original SKIP exited 0, so pointing TMPDIR beneath a git worktree made this test PASS
# against unchanged main. A skip that exits 0 is indistinguishable from a pass.
# GIT_CEILING_DIRECTORIES stops git walking above $TMP, making the condition hold
# regardless of where TMPDIR lives, rather than merely detecting when it does not.
# GIT_CEILING_DIRECTORIES is matched against the PHYSICAL path -- a symlinked TMPDIR
# (/tmp is commonly one) makes the logical path never match, and the ceiling silently
# does nothing. Resolve it before exporting.
TMP="$(cd "$TMP" && pwd -P)"
export GIT_CEILING_DIRECTORIES="$TMP"
if ( cd "$TMP" && git rev-parse --git-dir >/dev/null 2>&1 ); then
echo " FAIL scratch dir is inside a git repo even with GIT_CEILING_DIRECTORIES set;"
echo " the outside-a-repo precondition cannot be established -- refusing to report a result"
exit 1
fi
echo "== outside a git repo: rc=1 AND the diagnostic is emitted =="
check "detect_platform" "$(run_outside detect_platform)" "1:yes"
check "get_repo_info" "$(run_outside get_repo_info)" "1:yes"
echo "== inside a git repo the functions still work =="
git init -q "$TMP/repo" 2>/dev/null
git -C "$TMP/repo" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git 2>/dev/null
out=$( cd "$TMP/repo" && bash -c "set -e; source '$HERE/detect-platform.sh'; detect_platform" 2>&1 ); rc=$?
if [ "$rc" -eq 0 ] && grep -qi 'gitea' <<<"$out"; then echo " PASS detect_platform in-repo (rc=0, $out)"
else echo " FAIL detect_platform in-repo: rc=$rc out=$out"; fail=1; fi
[ "$fail" -eq 0 ] && echo "OK detect-platform fails loudly outside a repo" || echo "FAILED"
exit "$fail"
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Regression: issue-close.sh must NOT close an issue when the closing comment could not
# be posted, and comment+close must be made by ONE principal.
#
# Guards two defects fixed together (see #1081):
# 1. `tea issue comment` is not a subcommand -- tea exposes comments as the TOP-LEVEL
# `tea comment`. The old call always failed, was unchecked, and the issue closed
# anyway, losing the record of WHY it was closed.
# 2. Routing the comment through the token-authenticated API helper while the close
# used --login would attribute one operation to two principals.
#
# SAFETY (rev-974, #1085 review 130): this test previously ran under `set -uo pipefail`
# with unchecked mkdir/redirect/cd, then prepended a possibly-nonexistent $MOCK_BIN to
# PATH -- while `git remote add origin` names the REAL repository. Forcing setup failure
# with an unwritable AGENT_WORK_ROOT made it `git init` in its CALLER's directory and
# invoke the real, provider-mutating issue-close.sh. Setup now fails closed, and both
# `tea` and `curl` are asserted to resolve INSIDE $MOCK_BIN before any target run.
set -euo pipefail
# NOTE: with `set -e`, `grep -q X && fail "..."` is a trap -- the ABSENT case (grep rc=1,
# which is the PASSING case for a must-not-appear assertion) is the last command of an &&
# list and silently terminates the script with no message. Every must-not-appear check
# below is therefore an if-block. This is the same set -e + &&-list defect be-coder-08
# found in #1086, reintroduced here by adding `set -e` for the sandbox-safety fix.
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
SANDBOX="$WORK_ROOT/issue-close-fail-closed-test-$$"
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
cleanup() { rm -rf "$SANDBOX"; }
trap cleanup EXIT
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="$SCRIPT_DIR/issue-close.sh"
[ -f "$TARGET" ] || { echo "FAIL: issue-close.sh not found beside this test"; exit 1; }
fail() { echo "FAIL: $*"; exit 1; }
# Every setup step is checked. Under `set -e` these abort; the explicit || fail keeps the
# reason legible instead of a bare non-zero exit.
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
git init -q || fail "setup: git init failed"
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
export PATH="$MOCK_BIN:$PATH" CALLS
export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
method=GET; url=""
while [ $# -gt 0 ]; do
case "$1" in
-X) method="$2"; shift 2 ;;
http*|https*) url="$1"; shift ;;
*) shift ;;
esac
done
printf 'curl %s %s\n' "$method" "$url" >> "$CALLS"
[ "${MOCK_CURL_FAIL:-}" = "1" ] && [ "$method" = "POST" ] && exit 22
exit 0
EOF
chmod +x "$MOCK_BIN/curl"
mk_tea() { # $1 = exit code for a comment attempt; $2 = login list (empty => no login)
local rc="$1" login="${2-}"
cat > "$MOCK_BIN/tea" <<EOF
#!/bin/bash
printf 'tea %s\n' "\$*" >> "$CALLS"
if [[ "\$*" == *"login list"* ]]; then
printf '%s\n' '${login}'; exit 0
fi
# Fail ANY comment attempt -- both the correct top-level \`tea comment\` and the broken
# \`tea issue comment\` -- so an unfixed script exercises the DEFECT rather than tripping
# a setup assertion.
if [[ "\$1" == "comment" || ( "\$1" == "issue" && "\$2" == "comment" ) ]]; then exit $rc; fi
exit 0
EOF
chmod +x "$MOCK_BIN/tea"
}
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
# The mocks must be the ones that run. Without this, a failed setup silently falls through
# to the real tea/curl and the "test" mutates the real provider.
assert_mocked() {
local w
for w in tea curl; do
p=$(command -v "$w" || true)
[ -n "$p" ] || fail "SAFETY: $w does not resolve at all"
case "$p" in
"$MOCK_BIN"/*) : ;;
*) fail "SAFETY: $w resolves to $p, OUTSIDE the sandbox -- refusing to invoke the target" ;;
esac
done
}
run_target() { # never let a target failure abort the test; we assert on rc
# Call sites MUST use `rc=0; run_target ... || rc=$?` -- a bare `run_target ...; rc=$?`
# lets the non-zero RETURN trip set -e in the CALLER before rc is ever read.
set +e; bash "$TARGET" "$@" >/dev/null 2>&1; local rc=$?; set -e; return $rc
}
# ── tea path ────────────────────────────────────────────────────────────────────────
# 1. NEGATIVE (the regression): comment fails => must NOT close, must exit non-zero
mk_tea 1 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
rc=0; run_target -i 42 -c "closing note" || rc=$?
grep -qE 'tea (issue )?comment' "$CALLS" || fail "no comment attempt -- setup did not reach the tea branch"
if grep -q 'tea issue close' "$CALLS"; then fail "ISSUE CLOSED AFTER THE COMMENT FAILED -- the regression"; fi
[ "$rc" -ne 0 ] || fail "comment failed but issue-close exited 0 -- FAIL-OPEN"
# 2. POSITIVE: comment succeeds => close proceeds, exit 0
mk_tea 0 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
rc=0; run_target -i 42 -c "closing note" || rc=$?
[ "$rc" -eq 0 ] || fail "comment succeeded but issue-close exited $rc"
grep -q 'tea issue close' "$CALLS" || fail "issue not closed even though the comment succeeded"
# 3. must use top-level `tea comment`, never `tea issue comment`
if grep -q 'tea issue comment' "$CALLS"; then fail "used 'tea issue comment' -- not a valid subcommand"; fi
# 4. ONE PRINCIPAL: comment and close must carry the SAME --login
c=$(grep -m1 '^tea comment' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
k=$(grep -m1 '^tea issue close' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
[ -n "$c" ] || fail "comment carried no --login"
[ "$c" = "$k" ] || fail "MIXED PRINCIPALS: comment=$c close=$k"
# ── no-login / API fallback path ────────────────────────────────────────────────────
# rev-974: the delta also adds fail-closed behaviour to this branch, and the suite never
# reached it -- replacing the whole fallback contract with an unconditional close still
# passed. These assert the POSTCONDITION (which HTTP calls happened, in what order),
# not merely that a command ran.
# 5. no login + comment FAILS => POST attempted, NO PATCH, non-zero
mk_tea 0 ""; : > "$CALLS"; assert_mocked
rc=0; MOCK_CURL_FAIL=1 run_target -i 42 -c "closing note" || rc=$?
grep -q 'curl POST' "$CALLS" || fail "API path: no comment POST attempted"
if grep -q 'curl PATCH' "$CALLS"; then fail "API path: ISSUE CLOSED (PATCH) AFTER THE COMMENT POST FAILED"; fi
[ "$rc" -ne 0 ] || fail "API path: comment failed but exited 0 -- FAIL-OPEN"
# 6. no login + comment SUCCEEDS => POST strictly BEFORE PATCH, exit 0
mk_tea 0 ""; : > "$CALLS"; assert_mocked
rc=0; run_target -i 42 -c "closing note" || rc=$?
[ "$rc" -eq 0 ] || fail "API path: comment succeeded but exited $rc"
order=$(grep -oE 'curl (POST|PATCH)' "$CALLS" | awk '{print $2}' | paste -sd, -)
[ "$order" = "POST,PATCH" ] || fail "API path: expected POST,PATCH -- got '${order:-<none>}'"
# 7. no login + NO comment => PATCH only, never a POST
mk_tea 0 ""; : > "$CALLS"; assert_mocked
rc=0; run_target -i 42 || rc=$?
[ "$rc" -eq 0 ] || fail "API path: no-comment close exited $rc"
if grep -q 'curl POST' "$CALLS"; then fail "API path: posted a comment when none was requested"; fi
grep -q 'curl PATCH' "$CALLS" || fail "API path: issue not closed when no comment was requested"
echo "issue-close.sh fail-closed + single-principal regression passed"
@@ -280,7 +280,10 @@ print("201")
print(json.dumps(record)) print(json.dumps(record))
PY PY
) )
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)" response_status="${result%%$'\n'*}"
response_body=""
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
write_response "$response_status" "$response_body"
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY' result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
import json import json
@@ -299,7 +302,10 @@ else:
print(json.dumps(match)) print(json.dumps(match))
PY PY
) )
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)" response_status="${result%%$'\n'*}"
response_body=""
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
write_response "$response_status" "$response_body"
else else
echo "Unexpected curl request: $method $url" >&2 echo "Unexpected curl request: $method $url" >&2
exit 97 exit 97
@@ -69,7 +69,7 @@ section_nums() { # $1 = output $2 = header-prefix
} }
fail() { echo "FAIL: $1" >&2; exit 1; } fail() { echo "FAIL: $1" >&2; exit 1; }
contains() { printf '%s\n' "$1" | grep -qx "$2"; } contains() { grep -qx "$2" <<<"$1"; }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fixed (current) script behavior # Fixed (current) script behavior
@@ -225,7 +225,10 @@ write_response() {
emit() { emit() {
# Split a two-line "status\n<json body>" python result into the response. # Split a two-line "status\n<json body>" python result into the response.
local result="$1" local result="$1"
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)" response_status="${result%%$'\n'*}"
response_body=""
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
write_response "$response_status" "$response_body"
} }
mode="${PR_REVIEW_TEST_MODE:-}" mode="${PR_REVIEW_TEST_MODE:-}"
@@ -222,8 +222,8 @@ grep -q 'Unknown action: bogus-action' "$OUTPUT_FILE"
# --- Case 2: -h/--help documents both overrides. # --- Case 2: -h/--help documents both overrides.
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)" HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
echo "$HELP_TEXT" | grep -q -- '-r, --repo' grep -q -- '-r, --repo' <<<"$HELP_TEXT"
echo "$HELP_TEXT" | grep -q -- '-H, --host' grep -q -- '-H, --host' <<<"$HELP_TEXT"
# --- Case 3 (comment): a TRUE no-git-origin dir + -r/-H must not silently die # --- Case 3 (comment): a TRUE no-git-origin dir + -r/-H must not silently die
# and must not fail with "not a git repository or no origin remote" either. # and must not fail with "not a git repository or no origin remote" either.
@@ -91,10 +91,12 @@ fi
if [[ -n "$dirty_files" ]]; then if [[ -n "$dirty_files" ]]; then
echo " Modified files:" echo " Modified files:"
echo "$dirty_files" | head -20 | while IFS= read -r line; do mapfile -t dirty_lines <<<"$dirty_files"
echo " $line" file_count="${#dirty_lines[@]}"
display_count=$((file_count < 20 ? file_count : 20))
for ((i = 0; i < display_count; i++)); do
echo " ${dirty_lines[$i]}"
done done
file_count="$(echo "$dirty_files" | wc -l)"
if (( file_count > 20 )); then if (( file_count > 20 )); then
echo " ... and $(( file_count - 20 )) more" echo " ... and $(( file_count - 20 )) more"
fi fi
@@ -64,12 +64,12 @@ if jq -e '.next_task == "T-001"' "$capsule_file" >/dev/null 2>&1; then pass_case
if grep -Fq 'Target runtime:** codex' <<< "$codex_continue_output"; then pass_case "continue prompt contains target runtime codex"; else fail_case "continue prompt contains target runtime codex"; fi if grep -Fq 'Target runtime:** codex' <<< "$codex_continue_output"; then pass_case "continue prompt contains target runtime codex"; else fail_case "continue prompt contains target runtime codex"; fi
codex_run_prompt="$(MOSAIC_COORD_RUNTIME=codex bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)" codex_run_prompt="$(MOSAIC_COORD_RUNTIME=codex bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
if [[ "$(printf '%s\n' "$codex_run_prompt" | head -n1)" == "Now initiating Orchestrator mode..." ]]; then pass_case "codex run prompt first line is mode declaration"; else fail_case "codex run prompt first line is mode declaration"; fi if [[ "${codex_run_prompt%%$'\n'*}" == "Now initiating Orchestrator mode..." ]]; then pass_case "codex run prompt first line is mode declaration"; else fail_case "codex run prompt first line is mode declaration"; fi
if grep -Fq 'Do NOT ask clarifying questions before your first tool actions' <<< "$codex_run_prompt"; then pass_case "codex run prompt includes no-questions hard gate"; else fail_case "codex run prompt includes no-questions hard gate"; fi if grep -Fq 'Do NOT ask clarifying questions before your first tool actions' <<< "$codex_run_prompt"; then pass_case "codex run prompt includes no-questions hard gate"; else fail_case "codex run prompt includes no-questions hard gate"; fi
if grep -Fq '"next_task": "T-001"' <<< "$codex_run_prompt"; then pass_case "codex run prompt embeds capsule json"; else fail_case "codex run prompt embeds capsule json"; fi if grep -Fq '"next_task": "T-001"' <<< "$codex_run_prompt"; then pass_case "codex run prompt embeds capsule json"; else fail_case "codex run prompt embeds capsule json"; fi
claude_run_prompt="$(MOSAIC_COORD_RUNTIME=claude bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)" claude_run_prompt="$(MOSAIC_COORD_RUNTIME=claude bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
if [[ "$(printf '%s\n' "$claude_run_prompt" | head -n1)" == "## Continuation Mission" ]]; then pass_case "claude run prompt remains continuation prompt format"; else fail_case "claude run prompt remains continuation prompt format"; fi if [[ "${claude_run_prompt%%$'\n'*}" == "## Continuation Mission" ]]; then pass_case "claude run prompt remains continuation prompt format"; else fail_case "claude run prompt remains continuation prompt format"; fi
echo "" echo ""
echo "Smoke test summary: pass=$PASS fail=$FAIL" echo "Smoke test summary: pass=$PASS fail=$FAIL"
@@ -96,8 +96,8 @@ L="$WORK/live5.md"; G="$WORK/ledger5.md"; echo "# LEDGER" > "$G"
make_board "$L" 6 1 400 make_board "$L" 6 1 400
before_l=$(cat "$L"); before_g=$(cat "$G") before_l=$(cat "$L"); before_g=$(cat "$G")
out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 2000 --dry-run 2>&1) || note "dry-run exited nonzero: $out" out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 2000 --dry-run 2>&1) || note "dry-run exited nonzero: $out"
echo "$out" | grep -qi "dry run" || note "dry-run did not announce itself" grep -qi "dry run" <<<"$out" || note "dry-run did not announce itself"
echo "$out" | grep -q "would roll" || note "dry-run did not report a plan" grep -q "would roll" <<<"$out" || note "dry-run did not report a plan"
[[ "$(cat "$L")" == "$before_l" ]] || note "dry-run modified LIVE" [[ "$(cat "$L")" == "$before_l" ]] || note "dry-run modified LIVE"
[[ "$(cat "$G")" == "$before_g" ]] || note "dry-run modified LEDGER" [[ "$(cat "$G")" == "$before_g" ]] || note "dry-run modified LEDGER"
@@ -66,7 +66,7 @@ present=0
for entry in "${PRDY_REQUIRED_SECTIONS[@]}"; do for entry in "${PRDY_REQUIRED_SECTIONS[@]}"; do
pattern="${entry#*|}" pattern="${entry#*|}"
if echo "$PRD_CONTENT" | grep -qiE "$pattern"; then if grep -qiE "$pattern" <<<"$PRD_CONTENT"; then
present=$((present + 1)) present=$((present + 1))
fi fi
done done
@@ -169,13 +169,13 @@ main() {
# classify_surface PATH → surface name (highest-risk match wins, mirrors TS) # classify_surface PATH → surface name (highest-risk match wins, mirrors TS)
classify_surface() { classify_surface() {
local p="$1" local p="$1"
if printf '%s' "$p" | grep -qiE 'auth|login|session|token|permission|rbac|credential|secret'; then echo auth; return; fi if grep -qiE 'auth|login|session|token|permission|rbac|credential|secret' <<<"$p"; then echo auth; return; fi
if printf '%s' "$p" | grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed'; then echo data; return; fi if grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed' <<<"$p"; then echo data; return; fi
if printf '%s' "$p" | grep -qiE 'docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform'; then echo infra; return; fi if grep -qiE 'docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform' <<<"$p"; then echo infra; return; fi
if printf '%s' "$p" | grep -qiE 'package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite'; then echo build; return; fi if grep -qiE 'package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite' <<<"$p"; then echo build; return; fi
if printf '%s' "$p" | grep -qE '\.tsx|\.css|components/|apps/web/'; then echo ui; return; fi if grep -qE '\.tsx|\.css|components/|apps/web/' <<<"$p"; then echo ui; return; fi
if printf '%s' "$p" | grep -qE '\.spec\.|\.test\.|__tests__/'; then echo test; return; fi if grep -qE '\.spec\.|\.test\.|__tests__/' <<<"$p"; then echo test; return; fi
if printf '%s' "$p" | grep -qE '\.md$|docs/'; then echo docs; return; fi if grep -qE '\.md$|docs/' <<<"$p"; then echo docs; return; fi
echo none echo none
} }
@@ -13,7 +13,12 @@ JSON_INPUT=$(cat)
if command -v jq &>/dev/null; then if command -v jq &>/dev/null; then
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // .tool_response.filePath // .file_path // empty' 2>/dev/null || echo "") FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // .tool_response.filePath // .file_path // empty' 2>/dev/null || echo "")
else else
FILE_PATH=$(echo "$JSON_INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -1) file_path_pattern='"file_path"[[:space:]]*:[[:space:]]*"([^"]*)"'
if [[ "$JSON_INPUT" =~ $file_path_pattern ]]; then
FILE_PATH="${BASH_REMATCH[1]}"
else
FILE_PATH=""
fi
fi fi
# Only check TypeScript files # Only check TypeScript files
@@ -53,7 +58,7 @@ OUTPUT=$(npx tsc --noEmit --pretty --maxNodeModuleJsDepth 0 2>&1) || STATUS=$?
if [ "${STATUS:-0}" -ne 0 ]; then if [ "${STATUS:-0}" -ne 0 ]; then
# Filter output to only show errors related to the edited file (if possible) # Filter output to only show errors related to the edited file (if possible)
BASENAME=$(basename "$FILE_PATH") BASENAME=$(basename "$FILE_PATH")
RELEVANT=$(echo "$OUTPUT" | grep -A2 "$BASENAME" 2>/dev/null || echo "$OUTPUT" | head -20) RELEVANT=$(grep -A2 "$BASENAME" <<<"$OUTPUT" 2>/dev/null || sed -n '1,20p' <<<"$OUTPUT")
echo "TypeScript type errors detected after editing $FILE_PATH:" echo "TypeScript type errors detected after editing $FILE_PATH:"
echo "$RELEVANT" echo "$RELEVANT"
@@ -26,6 +26,9 @@ chk "F1 fresh: CONSTITUTION/AGENTS/STANDARDS/TOOLS seeded" \
"[ -f '$T1/CONSTITUTION.md' ] && [ -f '$T1/AGENTS.md' ] && [ -f '$T1/STANDARDS.md' ] && [ -f '$T1/TOOLS.md' ]" "[ -f '$T1/CONSTITUTION.md' ] && [ -f '$T1/AGENTS.md' ] && [ -f '$T1/STANDARDS.md' ] && [ -f '$T1/TOOLS.md' ]"
chk "F1 fresh: AGENTS == shipped default" "cmp -s '$T1/AGENTS.md' '$DEFA/AGENTS.md'" chk "F1 fresh: AGENTS == shipped default" "cmp -s '$T1/AGENTS.md' '$DEFA/AGENTS.md'"
chk "F1 fresh: framework-version stamped 3" "[ \"\$(cat '$T1/.framework-version' 2>/dev/null)\" = 3 ]" chk "F1 fresh: framework-version stamped 3" "[ \"\$(cat '$T1/.framework-version' 2>/dev/null)\" = 3 ]"
chk "F1 fresh: Pi goal extension deploys under Mosaic runtime" \
"cmp -s '$T1/runtime/pi/goal-extension.ts' '$FW/runtime/pi/goal-extension.ts'"
chk "F1 fresh: installer creates no nested main Pi config" "[ ! -e '$T1/.pi' ]"
# F2 — legacy install with a user-edited AGENTS.md (the sanctioned pre-constitution customization) # F2 — legacy install with a user-edited AGENTS.md (the sanctioned pre-constitution customization)
T2=$(mktemp -d); mkdir -p "$T2/credentials" T2=$(mktemp -d); mkdir -p "$T2/credentials"
@@ -89,6 +92,8 @@ chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.en
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'" chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'" chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'" chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
chk "F6 reseed: Pi goal extension is refreshed from framework source" \
"cmp -s '$T6/runtime/pi/goal-extension.ts' '$FW/runtime/pi/goal-extension.ts'"
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6" rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
echo echo
@@ -176,8 +176,12 @@ run_snap() {
# Resolve the single pre-update-* snapshot dir under a state dir (newest if many). # Resolve the single pre-update-* snapshot dir under a state dir (newest if many).
snap_dir() { snap_dir() {
local -a snapshots=()
mapfile -t snapshots < <(
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \ find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
| LC_ALL=C sort -r | head -1 | LC_ALL=C sort -r
)
printf '%s\n' "${snapshots[0]:-}"
} }
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────" echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
@@ -336,7 +336,7 @@ chk "[reset-fail] the manual-recovery pointer is emitted (not a silent set -e ex
"grep -q 'Snapshot restore could not reset' '$OUTG'" "grep -q 'Snapshot restore could not reset' '$OUTG'"
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \ chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'" "grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
SNAP_E="$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG" | head -1)" SNAP_E="$(grep -m1 -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG")"
chk "[reset-fail] the named snapshot directory actually survives for recovery" \ chk "[reset-fail] the named snapshot directory actually survives for recovery" \
"[ -n '$SNAP_E' ] && [ -d '$SNAP_E' ]" "[ -n '$SNAP_E' ] && [ -d '$SNAP_E' ]"
chk "[reset-fail] operator secret value never appears in installer output" \ chk "[reset-fail] operator secret value never appears in installer output" \
@@ -353,7 +353,8 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
"! grep -q 'Snapshot restore could not reset' '$OUTH'" "! grep -q 'Snapshot restore could not reset' '$OUTH'"
[ -n "${SNAP_E:-}" ] && rm -rf "$SNAP_E" [ -n "${SNAP_E:-}" ] && rm -rf "$SNAP_E"
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned). # Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done orphan_snapshot="$(grep -m1 -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null || true)"
[ -n "$orphan_snapshot" ] && rm -rf "$orphan_snapshot"
# Cleanup (generated installer controls are also removed by the EXIT trap). # Cleanup (generated installer controls are also removed by the EXIT trap).
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
@@ -32,7 +32,6 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling) packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
# --- single-suite directories: unmeasured in CI --- # --- single-suite directories: unmeasured in CI ---
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
@@ -110,7 +110,7 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
sleep 1.2 sleep 1.2
pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null) pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then if grep -qF "$QUEUED_RE" <<<"$pane"; then
status="queued"; break status="queued"; break
fi fi
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO # Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
@@ -121,7 +121,7 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
fi fi
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry. # Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
# (Submitted messages scroll up into history; a draft stays on the line.) # (Submitted messages scroll up into history; a draft stays on the line.)
if [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
status="draft"; continue status="draft"; continue
fi fi
# Input box located AND clear of our tail => positively submitted. This is the # Input box located AND clear of our tail => positively submitted. This is the
@@ -34,16 +34,20 @@ tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1=" " exec bash --no
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out "$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
sleep 0.2 sleep 0.2
capture_named | grep -qF "named socket hello" || fail "send-message.sh did not deliver to named socket" named_pane="$(capture_named)" || fail "could not capture named socket pane"
if capture_default | grep -qF "named socket hello"; then grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket"
default_pane="$(capture_default)" || fail "could not capture default socket pane"
if grep -qF "named socket hello" <<<"$default_pane"; then
fail "send-message.sh leaked named-socket message to default tmux server" fail "send-message.sh leaked named-socket message to default tmux server"
fi fi
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out "$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out
sleep 0.2 sleep 0.2
capture_named | grep -qF "[tester:source ->" || fail "agent-send.sh did not include preamble" named_pane="$(capture_named)" || fail "could not capture named socket pane"
capture_named | grep -qF "agent socket hello" || fail "agent-send.sh did not deliver to named socket" grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble"
if capture_default | grep -qF "agent socket hello"; then grep -qF "agent socket hello" <<<"$named_pane" || fail "agent-send.sh did not deliver to named socket"
default_pane="$(capture_default)" || fail "could not capture default socket pane"
if grep -qF "agent socket hello" <<<"$default_pane"; then
fail "agent-send.sh leaked named-socket message to default tmux server" fail "agent-send.sh leaked named-socket message to default tmux server"
fi fi
@@ -65,11 +69,11 @@ done
sleep 0.2 sleep 0.2
for i in $(seq 1 "$CONC_N"); do for i in $(seq 1 "$CONC_N"); do
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p) pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${i}-END" \ grep -qF "CONCPAYLOAD-${i}-END" <<<"$pane" \
|| fail "concurrent send dropped payload for pane conc-$i" || fail "concurrent send dropped payload for pane conc-$i"
for j in $(seq 1 "$CONC_N"); do for j in $(seq 1 "$CONC_N"); do
[ "$j" = "$i" ] && continue [ "$j" = "$i" ] && continue
if printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${j}-END"; then if grep -qF "CONCPAYLOAD-${j}-END" <<<"$pane"; then
fail "concurrent send cross-delivered payload $j to pane conc-$i" fail "concurrent send cross-delivered payload $j to pane conc-$i"
fi fi
done done
@@ -31,7 +31,7 @@ tmux -L "$SOCKET" new-session -d -s repl -c "$TMP" \
'PS1=" " exec bash --noprofile --norc -i' 'PS1=" " exec bash --noprofile --norc -i'
sleep 0.3 sleep 0.3
out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$? out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$?
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qF "✓ delivered"; then if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
ok "delivered: -prompt REPL that submits => exit 0 ✓ delivered" ok "delivered: -prompt REPL that submits => exit 0 ✓ delivered"
else else
no "delivered: -prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]" no "delivered: -prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
@@ -123,7 +123,7 @@ _manifest_val() {
# _manifest_val KEY — echo VALUE for KEY=VALUE in the manifest (blank if none). # _manifest_val KEY — echo VALUE for KEY=VALUE in the manifest (blank if none).
local key="$1" local key="$1"
[ -f "$MANIFEST" ] || return 0 [ -f "$MANIFEST" ] || return 0
sed -n "s/^${key}=//p" "$MANIFEST" | head -n1 | tr -d '[:space:]' awk -v key="$key" 'index($0, key "=") == 1 { sub(/^[^=]*=/, ""); gsub(/[[:space:]]/, ""); print; exit }' "$MANIFEST"
} }
# _load_watchlist — validate the watch-list path + JSON + schema_version range. # _load_watchlist — validate the watch-list path + JSON + schema_version range.
@@ -267,7 +267,7 @@ _poll_source() {
if snap_json="$(jq -ce '.' <<<"$rawmeta" 2>/dev/null)"; then if snap_json="$(jq -ce '.' <<<"$rawmeta" 2>/dev/null)"; then
snap_sha="$(jq -r 'if (.snapshot_sha|type) == "string" then .snapshot_sha else "" end' <<<"$snap_json")" snap_sha="$(jq -r 'if (.snapshot_sha|type) == "string" then .snapshot_sha else "" end' <<<"$snap_json")"
snap_ts="$(jq -r 'if (.snapshot_ts|type) == "number" then (.snapshot_ts|floor|tostring) else "" end' <<<"$snap_json")" snap_ts="$(jq -r 'if (.snapshot_ts|type) == "number" then (.snapshot_ts|floor|tostring) else "" end' <<<"$snap_json")"
if [ -n "$snap_sha" ] && ! printf '%s' "$snap_sha" | grep -Eq '^[0-9a-f]{7,64}$'; then if [ -n "$snap_sha" ] && ! grep -Eq '^[0-9a-f]{7,64}$' <<<"$snap_sha"; then
echo "detector.sh: source '$kind/$id' snapshot_sha rejected (not a 7-64 char lowercase-hex git sha) — snapshot metadata DROPPED, poll continues (#940)." >&2 echo "detector.sh: source '$kind/$id' snapshot_sha rejected (not a 7-64 char lowercase-hex git sha) — snapshot metadata DROPPED, poll continues (#940)." >&2
snap_sha="" snap_sha=""
snap_ts="" snap_ts=""
@@ -275,7 +275,7 @@ _poll_source() {
# A ts must be a sane positive epoch BEFORE any arithmetic touches it: a # A ts must be a sane positive epoch BEFORE any arithmetic touches it: a
# negative or absurdly large value would make the shell integer comparison # negative or absurdly large value would make the shell integer comparison
# below error out and silently KEEP the bad ts — validate first, compare after. # below error out and silently KEEP the bad ts — validate first, compare after.
if [ -n "$snap_ts" ] && ! printf '%s' "$snap_ts" | grep -Eq '^[0-9]{1,12}$'; then if [ -n "$snap_ts" ] && ! grep -Eq '^[0-9]{1,12}$' <<<"$snap_ts"; then
echo "detector.sh: source '$kind/$id' snapshot_ts rejected (not a sane positive epoch) — snapshot_ts DROPPED, poll continues (#940)." >&2 echo "detector.sh: source '$kind/$id' snapshot_ts rejected (not a sane positive epoch) — snapshot_ts DROPPED, poll continues (#940)." >&2
snap_ts="" snap_ts=""
fi fi
@@ -644,7 +644,8 @@ cmd_render() {
oseq="$(jq -r '.observed_seq // "?"' <<<"$line")" oseq="$(jq -r '.observed_seq // "?"' <<<"$line")"
oclass="$(jq -r '.class // "actionable"' <<<"$line")" oclass="$(jq -r '.class // "actionable"' <<<"$line")"
oloc="$(jq -c '.locators // {}' <<<"$line")" oloc="$(jq -c '.locators // {}' <<<"$line")"
olabel="$(_locator_line "$oloc" | head -n1)" olabel="$(_locator_line "$oloc")"
olabel="${olabel%%$'\n'*}"
printf ' * seq %s [%s] %s\n' "$oseq" "$(_scrub_inline "$oclass")" "$olabel" printf ' * seq %s [%s] %s\n' "$oseq" "$(_scrub_inline "$oclass")" "$olabel"
done <<<"$pending" done <<<"$pending"
fi fi
@@ -146,7 +146,7 @@ EOF
_manifest_val() { _manifest_val() {
local key="$1" local key="$1"
[ -f "$MANIFEST" ] || return 0 [ -f "$MANIFEST" ] || return 0
sed -n "s/^${key}=//p" "$MANIFEST" | head -n1 | tr -d '[:space:]' awk -v key="$key" 'index($0, key "=") == 1 { sub(/^[^=]*=/, ""); gsub(/[[:space:]]/, ""); print; exit }' "$MANIFEST"
} }
# _load_watchlist — validate path + JSON + shape + Gate B schema range (mirrors # _load_watchlist — validate path + JSON + shape + Gate B schema range (mirrors
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/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/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh" "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",
@@ -131,13 +131,14 @@ async function exists(path: string): Promise<boolean> {
} }
describe('projectRosterV2AgentGeneratedEnv', (): void => { describe('projectRosterV2AgentGeneratedEnv', (): void => {
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => { it('maps a roster-v2 agent to exactly the nine generated projection keys', (): void => {
const roster = parseRosterV2(rosterYaml, 'yaml'); const roster = parseRosterV2(rosterYaml, 'yaml');
const agent = roster.agents.find((candidate) => candidate.name === 'coder0'); const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
expect(agent).toBeDefined(); expect(agent).toBeDefined();
const values = projectRosterV2AgentGeneratedEnv(roster, agent!); const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
expect(values).toEqual({ expect(values).toEqual({
MOSAIC_AGENT_NAME: 'coder0', MOSAIC_AGENT_NAME: 'coder0',
MOSAIC_GIT_IDENTITY: 'coder0',
MOSAIC_AGENT_CLASS: 'code', MOSAIC_AGENT_CLASS: 'code',
MOSAIC_AGENT_RUNTIME: 'pi', MOSAIC_AGENT_RUNTIME: 'pi',
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol', MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
@@ -422,6 +422,7 @@ describe('fleet roster parsing', () => {
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe( expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[ [
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_GIT_IDENTITY=coder0',
// Reflects the roster's canonicalized compatibility class (A3a). // Reflects the roster's canonicalized compatibility class (A3a).
'MOSAIC_AGENT_CLASS=code', 'MOSAIC_AGENT_CLASS=code',
'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_RUNTIME=codex',
@@ -3799,6 +3800,7 @@ describe('fleet add command', () => {
'utf8', 'utf8',
); );
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0'); expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
expect(envContent).toContain('MOSAIC_GIT_IDENTITY=coder0');
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex'); expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
}); });
+1
View File
@@ -484,6 +484,7 @@ function generateAgentEnvValues(
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory; const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
return { return {
MOSAIC_AGENT_NAME: agent.name, MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.modelHint ?? '', MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { import {
buildPiSkillArgs, buildPiSkillArgs,
discoverPiExtensionArgs,
enumerateSkillDirs, enumerateSkillDirs,
piForceSkillNames, piForceSkillNames,
registerRuntimeLaunchers, registerRuntimeLaunchers,
@@ -178,6 +179,52 @@ describe('buildPiSkillArgs', () => {
}); });
}); });
describe('discoverPiExtensionArgs', () => {
it('loads the core and goal extensions in deterministic order from Mosaic home', () => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-'));
const runtimeDir = join(root, 'runtime', 'pi');
mkdirSync(runtimeDir, { recursive: true });
writeFileSync(join(runtimeDir, 'goal-extension.ts'), '// goal\n');
writeFileSync(join(runtimeDir, 'mosaic-extension.ts'), '// core\n');
try {
expect(discoverPiExtensionArgs(root)).toEqual([
'--extension',
join(runtimeDir, 'mosaic-extension.ts'),
'--extension',
join(runtimeDir, 'goal-extension.ts'),
]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('remains backward-compatible when the optional goal extension is absent', () => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-'));
const runtimeDir = join(root, 'runtime', 'pi');
mkdirSync(runtimeDir, { recursive: true });
writeFileSync(join(runtimeDir, 'mosaic-extension.ts'), '// core\n');
try {
expect(discoverPiExtensionArgs(root)).toEqual([
'--extension',
join(runtimeDir, 'mosaic-extension.ts'),
]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('emits no extension arguments when Mosaic runtime assets are absent', () => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-'));
try {
expect(discoverPiExtensionArgs(root)).toEqual([]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
describe('enumerateSkillDirs (real FS)', () => { describe('enumerateSkillDirs (real FS)', () => {
let root: string; let root: string;
+10 -4
View File
@@ -715,9 +715,15 @@ export function buildPiSkillArgs(
return ['--no-skills', ...forcedSkillArgs]; return ['--no-skills', ...forcedSkillArgs];
} }
function discoverPiExtension(): string[] { const PI_EXTENSION_FILES = ['mosaic-extension.ts', 'goal-extension.ts'] as const;
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
return existsSync(ext) ? ['--extension', ext] : []; export function discoverPiExtensionArgs(mosaicHome: string = MOSAIC_HOME): string[] {
const args: string[] = [];
for (const fileName of PI_EXTENSION_FILES) {
const extensionPath = join(mosaicHome, 'runtime', 'pi', fileName);
if (existsSync(extensionPath)) args.push('--extension', extensionPath);
}
return args;
} }
// ─── Launch functions ──────────────────────────────────────────────────────── // ─── Launch functions ────────────────────────────────────────────────────────
@@ -792,7 +798,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
const prompt = buildRuntimePrompt('pi'); const prompt = buildRuntimePrompt('pi');
const cliArgs = ['--append-system-prompt', prompt]; const cliArgs = ['--append-system-prompt', prompt];
cliArgs.push(...buildPiSkillArgs(args)); cliArgs.push(...buildPiSkillArgs(args));
cliArgs.push(...discoverPiExtension()); cliArgs.push(...discoverPiExtensionArgs());
if (hasMissionNoArgs) { if (hasMissionNoArgs) {
cliArgs.push(missionPrompt); cliArgs.push(missionPrompt);
} else { } else {
@@ -209,6 +209,20 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
); );
}); });
it('deploys the Mosaic-owned Pi goal extension only inside the Mosaic runtime tree', async () => {
const sourceRuntime = join(fixture.sourceDir, 'runtime', 'pi');
mkdirSync(sourceRuntime, { recursive: true });
writeFileSync(join(sourceRuntime, 'goal-extension.ts'), '// persistent goal extension\n');
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
await adapter.syncFramework('fresh');
expect(
readFileSync(join(fixture.mosaicHome, 'runtime', 'pi', 'goal-extension.ts'), 'utf-8'),
).toBe('// persistent goal extension\n');
expect(existsSync(join(fixture.mosaicHome, '.pi'))).toBe(false);
});
it('is a no-op for seeding when defaults/ dir does not exist', async () => { it('is a no-op for seeding when defaults/ dir does not exist', async () => {
rmSync(fixture.defaultsDir, { recursive: true }); rmSync(fixture.defaultsDir, { recursive: true });
@@ -358,6 +358,7 @@ function generatedValues(
): Readonly<Record<string, string>> { ): Readonly<Record<string, string>> {
return { return {
MOSAIC_AGENT_NAME: agent.name, MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model, MOSAIC_AGENT_MODEL: agent.model,
@@ -380,7 +380,7 @@ const COMMAND_RECORDS: Readonly<Record<string, RegExp>> = {
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = { const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
'DATA.DOTENV.FLEET_LAUNCH': 'DATA.DOTENV.FLEET_LAUNCH':
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>', 'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
'DATA.TEXT_TABLE.FLEET_TASKS': 'DATA.TEXT_TABLE.FLEET_TASKS':
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |', '| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW': 'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
@@ -406,7 +406,7 @@ const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
'DATA.JSON.MUTATION_RESULT': 'DATA.JSON.MUTATION_RESULT':
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}', '{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
'DATA.DOTENV.GENERATED_ENV': 'DATA.DOTENV.GENERATED_ENV':
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>', 'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
'DATA.YAML.ROSTER_FIELDS': 'DATA.YAML.ROSTER_FIELDS':
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true', 'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
}; };
@@ -922,8 +922,8 @@ describe('fleet operator documentation', (): void => {
); );
expect( expect(
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'), surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
).toHaveLength(858); ).toHaveLength(863);
expect(surfaces).toHaveLength(882); expect(surfaces).toHaveLength(887);
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8'); const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
const auxiliary: CodeSurface = { const auxiliary: CodeSurface = {
@@ -597,6 +597,7 @@ export function projectRosterV2AgentGeneratedEnv(
): Readonly<Record<string, string>> { ): Readonly<Record<string, string>> {
return { return {
MOSAIC_AGENT_NAME: agent.name, MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model, MOSAIC_AGENT_MODEL: agent.model,
@@ -22,6 +22,7 @@ import {
const generatedValues = { const generatedValues = {
MOSAIC_AGENT_NAME: 'coder0', MOSAIC_AGENT_NAME: 'coder0',
MOSAIC_GIT_IDENTITY: 'coder0',
MOSAIC_AGENT_CLASS: 'code', MOSAIC_AGENT_CLASS: 'code',
MOSAIC_AGENT_RUNTIME: 'pi', MOSAIC_AGENT_RUNTIME: 'pi',
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol', MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
@@ -45,6 +46,7 @@ describe('generated fleet agent environment boundary', (): void => {
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe( expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
[ [
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_GIT_IDENTITY=coder0',
'MOSAIC_AGENT_CLASS=code', 'MOSAIC_AGENT_CLASS=code',
'MOSAIC_AGENT_RUNTIME=pi', 'MOSAIC_AGENT_RUNTIME=pi',
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol', 'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
@@ -78,6 +80,22 @@ describe('generated fleet agent environment boundary', (): void => {
expect(String(error)).toMatch(/key=.*sha256=/); expect(String(error)).toMatch(/key=.*sha256=/);
}); });
it.each([
['unsafe-git-identity', 'other/identity'],
['git-identity-mismatch', 'reviewer0'],
])('rejects %s before any launch consumer can use it', (code: string, identity: string): void => {
expect((): void => {
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_GIT_IDENTITY: identity,
});
}).toThrow(
expect.objectContaining({
diagnostic: expect.objectContaining({ code, key: 'MOSAIC_GIT_IDENTITY' }),
}),
);
});
it('rejects unsafe generated paths before any launch consumer can use them', (): void => { it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
expect((): void => { expect((): void => {
renderGeneratedAgentEnvironment({ renderGeneratedAgentEnvironment({
@@ -73,6 +73,7 @@ export class AgentEnvBoundaryError extends Error {
export const GENERATED_AGENT_ENV_KEYS = [ export const GENERATED_AGENT_ENV_KEYS = [
'MOSAIC_AGENT_NAME', 'MOSAIC_AGENT_NAME',
'MOSAIC_GIT_IDENTITY',
'MOSAIC_AGENT_CLASS', 'MOSAIC_AGENT_CLASS',
'MOSAIC_AGENT_RUNTIME', 'MOSAIC_AGENT_RUNTIME',
'MOSAIC_AGENT_MODEL', 'MOSAIC_AGENT_MODEL',
@@ -402,6 +403,7 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, ''); if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
} }
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME'); const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
const gitIdentity = requiredGeneratedValue(values, 'MOSAIC_GIT_IDENTITY');
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS'); const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME'); const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL'); const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
@@ -412,6 +414,12 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
if (!AGENT_NAME.test(name)) if (!AGENT_NAME.test(name))
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name); throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
if (!AGENT_NAME.test(gitIdentity)) {
throw new AgentEnvBoundaryError('unsafe-git-identity', 'MOSAIC_GIT_IDENTITY', gitIdentity);
}
if (gitIdentity !== name) {
throw new AgentEnvBoundaryError('git-identity-mismatch', 'MOSAIC_GIT_IDENTITY', gitIdentity);
}
if (!POLICY_NAME.test(className)) { if (!POLICY_NAME.test(className)) {
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className); throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
} }
@@ -1405,6 +1405,7 @@ function generatedValues(
): Readonly<Record<string, string>> { ): Readonly<Record<string, string>> {
return { return {
MOSAIC_AGENT_NAME: agent.name, MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model, MOSAIC_AGENT_MODEL: agent.model,
@@ -0,0 +1,796 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
interface FakeEntry {
type: string;
customType?: string;
data?: unknown;
}
interface SentMessage {
message: {
customType: string;
content: string;
display: boolean;
};
options?: {
triggerTurn?: boolean;
deliverAs?: 'steer' | 'followUp' | 'nextTurn';
};
}
interface FakeContext {
cwd: string;
ui: {
notifications: Array<{ message: string; level?: string }>;
statuses: Map<string, string | undefined>;
notify(message: string, level?: string): void;
setStatus(key: string, value: string | undefined): void;
};
sessionManager: {
getBranch(): FakeEntry[];
};
isIdle(): boolean;
hasPendingMessages(): boolean;
abort(): void;
}
type EventHandler = (
event: Record<string, unknown>,
context: FakeContext,
) => unknown | Promise<unknown>;
type CommandHandler = (args: string, context: FakeContext) => unknown | Promise<unknown>;
interface FakeToolResult {
content: Array<{ type: string; text: string }>;
details?: unknown;
terminate?: boolean;
}
interface FakeTool {
name: string;
execute(
toolCallId: string,
params: Record<string, unknown>,
signal: AbortSignal | undefined,
onUpdate: undefined,
context: FakeContext,
): Promise<FakeToolResult>;
}
interface GoalExtensionFactory {
(api: FakePiApi): void;
}
interface GoalExtensionModule {
default: GoalExtensionFactory;
}
interface FakePiApi {
on(event: string, handler: EventHandler): void;
registerCommand(name: string, options: { description: string; handler: CommandHandler }): void;
registerTool(tool: FakeTool): void;
appendEntry(customType: string, data?: unknown): void;
sendMessage(message: SentMessage['message'], options?: SentMessage['options']): void;
}
function isGoalExtensionModule(value: unknown): value is GoalExtensionModule {
if (typeof value !== 'object' || value === null) return false;
return typeof Reflect.get(value, 'default') === 'function';
}
const goalExtensionUrl = new URL('../../framework/runtime/pi/goal-extension.ts', import.meta.url)
.href;
const importedGoalExtension: unknown = await import(goalExtensionUrl);
if (!isGoalExtensionModule(importedGoalExtension)) {
throw new Error('Pi goal extension must export a default registration function');
}
const registerGoalExtension = importedGoalExtension.default;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
class FakePi {
readonly handlers = new Map<string, EventHandler[]>();
readonly commands = new Map<string, CommandHandler>();
readonly tools = new Map<string, FakeTool>();
readonly entries: FakeEntry[] = [];
readonly sentMessages: SentMessage[] = [];
readonly notifications: Array<{ message: string; level?: string }> = [];
readonly statuses = new Map<string, string | undefined>();
branch: FakeEntry[] = [];
idle = true;
pending = false;
abortCount = 0;
readonly context: FakeContext = {
cwd: '/tmp/project',
ui: {
notifications: this.notifications,
statuses: this.statuses,
notify: (message: string, level?: string): void => {
this.notifications.push({ message, level });
},
setStatus: (key: string, value: string | undefined): void => {
this.statuses.set(key, value);
},
},
sessionManager: {
getBranch: (): FakeEntry[] => [...this.branch],
},
isIdle: (): boolean => this.idle,
hasPendingMessages: (): boolean => this.pending,
abort: (): void => {
this.abortCount += 1;
},
};
readonly api: FakePiApi = {
on: (event: string, handler: EventHandler): void => {
this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]);
},
registerCommand: (
name: string,
options: { description: string; handler: CommandHandler },
): void => {
this.commands.set(name, options.handler);
},
registerTool: (tool: FakeTool): void => {
this.tools.set(tool.name, tool);
},
appendEntry: (customType: string, data?: unknown): void => {
const entry: FakeEntry = { type: 'custom', customType, data };
this.entries.push(entry);
this.branch.push(entry);
},
sendMessage: (message: SentMessage['message'], options?: SentMessage['options']): void => {
this.sentMessages.push({ message, options });
},
};
constructor(initialBranch: FakeEntry[] = []) {
this.branch = [...initialBranch];
registerGoalExtension(this.api);
}
async emit(event: string, value: Record<string, unknown> = {}): Promise<unknown[]> {
const results: unknown[] = [];
for (const handler of this.handlers.get(event) ?? []) {
results.push(await handler(value, this.context));
}
return results;
}
async goal(args: string): Promise<void> {
const handler = this.commands.get('goal');
if (handler === undefined) throw new Error('/goal was not registered');
await handler(args, this.context);
}
async report(params: Record<string, unknown>): Promise<FakeToolResult> {
const tool = this.tools.get('mosaic_goal_report');
if (tool === undefined) throw new Error('mosaic_goal_report was not registered');
return await tool.execute('goal-report-1', params, undefined, undefined, this.context);
}
}
function latestGoalStateData(pi: FakePi): Record<string, unknown> {
for (let index = pi.entries.length - 1; index >= 0; index -= 1) {
const entry = pi.entries[index];
if (entry?.customType === 'mosaic-goal-state' && isRecord(entry.data)) return entry.data;
}
throw new Error('No persisted Mosaic goal state found');
}
function stateField(pi: FakePi, field: string): unknown {
return latestGoalStateData(pi)[field];
}
function activeGoalStatementFromContext(result: unknown): string {
if (!isRecord(result)) throw new Error('Context handler did not return an object');
const messages = result['messages'];
if (!Array.isArray(messages)) throw new Error('Context result did not include messages');
const goalMessage = messages.find(
(message: unknown): boolean =>
isRecord(message) && message['customType'] === 'mosaic-goal-context',
);
if (!isRecord(goalMessage) || typeof goalMessage['content'] !== 'string') {
throw new Error('Goal context message was not injected');
}
return goalMessage['content'];
}
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
describe('Mosaic Pi goal extension commands', () => {
it('shows help and handles controls safely when no goal exists', async () => {
const pi = new FakePi();
await pi.goal('');
expect(pi.notifications.at(-1)?.message).toContain('/goal set');
await pi.goal('status');
expect(pi.notifications.at(-1)?.message).toContain('No Mosaic goal is set');
for (const command of ['pause', 'resume', 'cancel']) {
await pi.goal(command);
expect(pi.notifications.at(-1)?.level).toBe('warning');
}
expect(pi.entries).toHaveLength(0);
expect(pi.sentMessages).toHaveLength(0);
});
it('sets, reports, pauses, resumes, and cancels a bounded goal', async () => {
const pi = new FakePi();
await pi.goal('set Deliver the local goal extension with tests');
expect(stateField(pi, 'phase')).toBe('active');
expect(stateField(pi, 'statement')).toBe('Deliver the local goal extension with tests');
expect(pi.sentMessages).toHaveLength(1);
expect(pi.sentMessages[0]?.options?.triggerTurn).toBe(true);
await pi.goal('status');
expect(pi.notifications.at(-1)?.message).toContain('Deliver the local goal extension');
expect(pi.notifications.at(-1)?.message).toContain('active');
await pi.goal('pause');
expect(stateField(pi, 'phase')).toBe('paused');
pi.sentMessages.length = 0;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
await pi.goal('resume');
expect(stateField(pi, 'phase')).toBe('active');
expect(pi.sentMessages).toHaveLength(1);
await pi.goal('cancel');
expect(stateField(pi, 'phase')).toBe('cancelled');
pi.sentMessages.length = 0;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
});
it('accepts /goal <statement> shorthand but refuses to replace an active goal', async () => {
const pi = new FakePi();
await pi.goal('First goal');
const firstGoalId = stateField(pi, 'goalId');
await pi.goal('set Second goal');
expect(stateField(pi, 'goalId')).toBe(firstGoalId);
expect(stateField(pi, 'statement')).toBe('First goal');
expect(pi.notifications.at(-1)?.level).toBe('warning');
expect(pi.notifications.at(-1)?.message).toContain('/goal cancel');
});
it('rejects invalid phase transitions, aborts busy work, and supports clear as cancel', async () => {
const pi = new FakePi();
await pi.goal('set Preserve transition safety');
await pi.goal('resume');
expect(pi.notifications.at(-1)?.message).toContain('cannot be resumed');
pi.idle = false;
await pi.goal('pause maintenance window');
expect(stateField(pi, 'stopReason')).toBe('maintenance window');
expect(pi.abortCount).toBe(1);
await pi.goal('pause');
expect(pi.notifications.at(-1)?.message).toContain('cannot be paused');
await pi.goal('clear');
expect(stateField(pi, 'phase')).toBe('cancelled');
expect(pi.abortCount).toBe(2);
});
it('rejects empty and oversized goal statements without starting a run', async () => {
const pi = new FakePi();
await pi.goal('set');
await pi.goal(`set ${'x'.repeat(8_001)}`);
expect(pi.entries).toHaveLength(0);
expect(pi.sentMessages).toHaveLength(0);
expect(pi.notifications.at(-1)?.level).toBe('warning');
});
});
describe('Mosaic Pi goal lifecycle', () => {
it('injects one fresh active contract before every model context', async () => {
const pi = new FakePi();
await pi.goal('set Keep the agent oriented');
const existingGoalContext = {
role: 'custom',
customType: 'mosaic-goal-context',
content: 'stale',
};
const existingContinuation = {
role: 'custom',
customType: 'mosaic-goal-continuation',
content: 'stale continuation',
};
const first = await pi.emit('context', {
messages: [existingGoalContext, existingContinuation],
});
const second = await pi.emit('context', { messages: [] });
expect(activeGoalStatementFromContext(first[0])).toContain('Keep the agent oriented');
expect(activeGoalStatementFromContext(first[0])).toContain('mosaic_goal_report');
expect(activeGoalStatementFromContext(first[0])).not.toContain('stale');
if (!isRecord(first[0]) || !Array.isArray(first[0]['messages'])) {
throw new Error('Expected filtered context messages');
}
expect(first[0]['messages']).toHaveLength(1);
expect(activeGoalStatementFromContext(second[0])).toContain('Keep the agent oriented');
await pi.goal('cancel');
expect(
await pi.emit('context', {
messages: [existingGoalContext, existingContinuation],
}),
).toEqual([{ messages: [] }]);
});
it('lets an existing busy run adopt the goal and waits behind a pending message', async () => {
const busy = new FakePi();
busy.idle = false;
await busy.goal('set Join the current run safely');
expect(busy.sentMessages).toHaveLength(0);
busy.pending = true;
await busy.emit('agent_settled');
expect(busy.sentMessages).toHaveLength(0);
busy.pending = false;
busy.idle = true;
await busy.emit('agent_settled');
expect(busy.sentMessages).toHaveLength(1);
});
it('records every turn and continues once when an active run settles', async () => {
const pi = new FakePi();
await pi.goal('set Finish all acceptance criteria');
pi.sentMessages.length = 0;
await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] });
expect(stateField(pi, 'turnCount')).toBe(1);
expect(stateField(pi, 'lastCheckSource')).toBe('turn');
await pi.emit('agent_settled');
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(1);
expect(pi.sentMessages[0]?.message.content).toContain('Goal remains active');
await pi.emit('agent_start');
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(2);
});
it('requires two consecutive evidence-bearing achievement reports', async () => {
const pi = new FakePi();
await pi.goal('set Prove the feature works');
pi.sentMessages.length = 0;
const first = await pi.report({
status: 'achieved',
summary: 'Focused tests pass',
evidence: ['pnpm test: 12 passed'],
});
expect(first.terminate).toBe(true);
expect(stateField(pi, 'phase')).toBe('verifying');
expect(stateField(pi, 'verificationPasses')).toBe(1);
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(1);
expect(pi.sentMessages[0]?.message.content).toContain('verification pass');
await pi.emit('agent_start');
const second = await pi.report({
status: 'achieved',
summary: 'Independent recheck confirms completion',
evidence: ['rerun: 12 passed', 'framework path verified'],
});
expect(second.terminate).toBe(true);
expect(stateField(pi, 'phase')).toBe('achieved');
expect(stateField(pi, 'verificationPasses')).toBe(2);
pi.sentMessages.length = 0;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
});
it('rejects malformed progress reports and reports submitted without an active goal', async () => {
const noGoal = new FakePi();
await expect(
noGoal.report({ status: 'continue', summary: 'work', evidence: [] }),
).rejects.toThrow(/No active Mosaic goal/);
const pi = new FakePi();
await pi.goal('set Validate report boundaries');
const invalidReports: Record<string, unknown>[] = [
{},
{ status: 'invalid', summary: 'work', evidence: [] },
{ status: 'continue', evidence: [] },
{ status: 'continue', summary: ' ', evidence: [] },
{ status: 'continue', summary: 'x'.repeat(2_001), evidence: [] },
{ status: 'continue', summary: 'work', evidence: 'not-an-array' },
{ status: 'continue', summary: 'work', evidence: Array.from({ length: 21 }, () => 'x') },
{ status: 'continue', summary: 'work', evidence: [4] },
{ status: 'continue', summary: 'work', evidence: [''] },
{ status: 'continue', summary: 'work', evidence: ['x'.repeat(1_001)] },
{ status: 'continue', summary: 'work', evidence: [], nextStep: 4 },
{ status: 'continue', summary: 'work', evidence: [], nextStep: ' ' },
{ status: 'continue', summary: 'work', evidence: [], nextStep: 'x'.repeat(2_001) },
];
for (const report of invalidReports) {
await expect(pi.report(report)).rejects.toThrow();
}
expect(stateField(pi, 'phase')).toBe('active');
});
it('rejects an achievement claim without evidence', async () => {
const pi = new FakePi();
await pi.goal('set Require evidence');
await expect(
pi.report({ status: 'achieved', summary: 'Trust me', evidence: [] }),
).rejects.toThrow(/evidence/i);
expect(stateField(pi, 'phase')).toBe('active');
});
it('redacts credential-shaped goal and report text before persistence or display', async () => {
const githubToken = `ghp_${'a'.repeat(32)}`;
const anthropicKey = `sk-ant-api03-${'b'.repeat(40)}`;
const bearerToken = 'header.payload.signature-canary';
const databaseUrl = 'postgresql://mosaic:[email protected]/mosaic';
const password = 'password-canary';
const pi = new FakePi();
await pi.goal(`set Rotate ${githubToken} without retaining it`);
const context = await pi.emit('context', { messages: [] });
expect(activeGoalStatementFromContext(context[0])).not.toContain(githubToken);
const result = await pi.report({
status: 'achieved',
summary: `Validated ${anthropicKey}`,
evidence: [`Authorization: Bearer ${bearerToken}`, `DATABASE_URL=${databaseUrl}`],
nextStep: `password=${password}`,
});
await pi.goal('status');
const persisted = JSON.stringify(latestGoalStateData(pi));
const displayed = pi.notifications.at(-1)?.message ?? '';
const toolOutput = JSON.stringify(result);
for (const secret of [githubToken, anthropicKey, bearerToken, databaseUrl, password]) {
expect(persisted).not.toContain(secret);
expect(displayed).not.toContain(secret);
expect(toolOutput).not.toContain(secret);
}
expect(persisted).toContain('[REDACTED-SECRET]');
});
it('preserves ordinary typed fields that resemble sensitive assignment names', async () => {
const typedFields = 'token: string, password: boolean, secret: false';
const pi = new FakePi();
await pi.goal(`set Preserve TypeScript fields: ${typedFields}`);
await pi.report({
status: 'continue',
summary: `Schema still contains ${typedFields}`,
evidence: [`interface Config { ${typedFields} }`],
nextStep: `Keep ${typedFields} unchanged`,
});
expect(stateField(pi, 'statement')).toContain(typedFields);
expect(JSON.stringify(stateField(pi, 'lastReport'))).toContain(typedFields);
expect(JSON.stringify(latestGoalStateData(pi))).not.toContain('[REDACTED-SECRET]');
});
it('stops autonomous continuation when the max-turn limit is reached', async () => {
vi.stubEnv('MOSAIC_GOAL_MAX_TURNS', '2');
const pi = new FakePi();
await pi.goal('set Bound this run');
pi.sentMessages.length = 0;
await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] });
await pi.emit('turn_end', { turnIndex: 1, message: {}, toolResults: [] });
expect(stateField(pi, 'phase')).toBe('exhausted');
expect(pi.abortCount).toBe(1);
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
});
it('resets the no-progress sequence when a continuation report changes', async () => {
const pi = new FakePi();
await pi.goal('set Track changing progress');
await pi.report({
status: 'continue',
summary: 'First checkpoint',
evidence: ['file A changed'],
nextStep: 'Run focused tests',
});
await pi.report({
status: 'continue',
summary: 'Second checkpoint',
evidence: ['focused tests passed'],
nextStep: 'Review the diff',
});
expect(stateField(pi, 'phase')).toBe('active');
expect(stateField(pi, 'noProgressReports')).toBe(1);
await pi.goal('status');
expect(pi.notifications.at(-1)?.message).toContain('Next step: Review the diff');
expect(pi.notifications.at(-1)?.message).toContain('focused tests passed');
});
it('stops after a bounded number of identical no-progress reports', async () => {
vi.stubEnv('MOSAIC_GOAL_MAX_NO_PROGRESS', '2');
const pi = new FakePi();
await pi.goal('set Detect stalled work');
const report = {
status: 'continue',
summary: 'No change yet',
evidence: ['same observation'],
nextStep: 'Try again',
};
await pi.report(report);
await pi.report(report);
expect(stateField(pi, 'phase')).toBe('exhausted');
expect(stateField(pi, 'noProgressReports')).toBe(2);
pi.sentMessages.length = 0;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
});
it('rejects a goal report mixed with another tool result in the same turn', async () => {
const pi = new FakePi();
await pi.goal('set Require a sole final report');
await pi.report({
status: 'achieved',
summary: 'Premature mixed claim',
evidence: ['one observation'],
});
await pi.emit('turn_end', {
turnIndex: 0,
message: {},
toolResults: [{ toolName: 'mosaic_goal_report' }, { toolName: 'read' }],
});
expect(stateField(pi, 'phase')).toBe('active');
expect(stateField(pi, 'verificationPasses')).toBe(0);
expect(stateField(pi, 'lastCheckOutcome')).toBe('mixed-goal-report-rejected');
expect(pi.notifications.at(-1)?.level).toBe('warning');
});
it('marks blocked reports terminal until the operator resumes', async () => {
const pi = new FakePi();
await pi.goal('set Stop on a real blocker');
await pi.report({
status: 'blocked',
summary: 'Missing required access',
evidence: ['provider returned 403'],
});
expect(stateField(pi, 'phase')).toBe('blocked');
pi.sentMessages.length = 0;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
await pi.goal('resume');
expect(stateField(pi, 'phase')).toBe('active');
expect(stateField(pi, 'turnCount')).toBe(0);
expect(pi.sentMessages).toHaveLength(1);
});
});
describe('Mosaic Pi goal compaction and restoration', () => {
it('resets provisional verification and defers manual-compaction continuation until idle', async () => {
vi.useFakeTimers();
const pi = new FakePi();
await pi.goal('set Survive compaction');
await pi.report({
status: 'achieved',
summary: 'Initial claim',
evidence: ['focused test passed'],
});
expect(stateField(pi, 'phase')).toBe('verifying');
pi.sentMessages.length = 0;
await pi.emit('session_compact', { reason: 'manual', willRetry: false });
expect(stateField(pi, 'phase')).toBe('active');
expect(stateField(pi, 'verificationPasses')).toBe(0);
expect(stateField(pi, 'compactionCount')).toBe(1);
expect(stateField(pi, 'lastCheckSource')).toBe('compact');
expect(pi.sentMessages).toHaveLength(0);
await vi.runAllTimersAsync();
expect(pi.sentMessages).toHaveLength(1);
expect(pi.sentMessages[0]?.message.content).toContain('compaction');
});
it('does not re-enter an active automatic compaction and relies on the settled backstop', async () => {
vi.useFakeTimers();
const pi = new FakePi();
await pi.goal('set Avoid compaction races');
pi.sentMessages.length = 0;
pi.idle = false;
await pi.emit('session_compact', { reason: 'threshold', willRetry: false });
await vi.runAllTimersAsync();
expect(pi.sentMessages).toHaveLength(0);
pi.idle = true;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(1);
});
it('restores branch-specific state on session start and tree navigation', async () => {
vi.useFakeTimers();
const source = new FakePi();
await source.goal('set Restore this exact branch goal');
const activeState = latestGoalStateData(source);
const restored = new FakePi([
{ type: 'custom', customType: 'mosaic-goal-state', data: activeState },
]);
await restored.emit('session_start', { reason: 'resume' });
expect(restored.statuses.get('mosaic-goal')).toContain('active');
const context = await restored.emit('context', { messages: [] });
expect(activeGoalStatementFromContext(context[0])).toContain('Restore this exact branch goal');
await restored.goal('pause');
const pausedState = latestGoalStateData(restored);
restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: pausedState }];
await restored.emit('session_tree', {});
await vi.runAllTimersAsync();
expect(stateField(restored, 'phase')).toBe('paused');
expect(restored.sentMessages).toHaveLength(0);
});
it('restores only fully valid persisted states and ignores malformed entries', async () => {
vi.useFakeTimers();
const source = new FakePi();
await source.goal('set Validate persisted branch state');
await source.report({
status: 'continue',
summary: 'Valid report',
evidence: ['valid evidence'],
nextStep: 'Continue validation',
});
const valid = latestGoalStateData(source);
const validRestore = new FakePi([
{ type: 'custom', customType: 'mosaic-goal-state', data: valid },
]);
await validRestore.emit('session_start', { reason: 'resume' });
expect(stateField(validRestore, 'statement')).toBe('Validate persisted branch state');
await validRestore.emit('session_shutdown', { reason: 'reload' });
const validReport = latestGoalStateData(source)['lastReport'];
if (!isRecord(validReport)) throw new Error('Expected a valid persisted report fixture');
const integerFields = [
'turnCount',
'reportCount',
'verificationPasses',
'requiredVerificationPasses',
'noProgressReports',
'maxTurns',
'maxNoProgressReports',
'compactionCount',
];
const corruptions: Array<(state: Record<string, unknown>) => unknown> = [
(): unknown => null,
(state): unknown => ({ ...state, version: 99 }),
(state): unknown => ({ ...state, goalId: '' }),
(state): unknown => ({ ...state, statement: '' }),
(state): unknown => ({ ...state, statement: 'x'.repeat(8_001) }),
(state): unknown => ({ ...state, phase: 'unknown' }),
(state): unknown => ({ ...state, lastCheckSource: 'unknown' }),
(state): unknown => ({ ...state, startedAt: 4 }),
(state): unknown => ({ ...state, lastCheckAt: 4 }),
(state): unknown => ({ ...state, lastReport: null }),
(state): unknown => ({ ...state, lastProgressFingerprint: 4 }),
(state): unknown => ({ ...state, stopReason: 4 }),
...integerFields.map((field: string) => (state: Record<string, unknown>): unknown => ({
...state,
[field]: -1,
})),
];
corruptions.push(
(state: Record<string, unknown>): unknown => ({ ...state, maxTurns: 501 }),
(state: Record<string, unknown>): unknown => ({ ...state, maxNoProgressReports: 101 }),
(state: Record<string, unknown>): unknown => ({ ...state, requiredVerificationPasses: 3 }),
);
const reportCorruptions: Array<Record<string, unknown>> = [
{ ...validReport, status: 'bad' },
{ ...validReport, summary: '' },
{ ...validReport, evidence: 'bad' },
{ ...validReport, evidence: [4] },
{ ...validReport, fingerprint: '' },
{ ...validReport, reportedAt: '' },
{ ...validReport, nextStep: 4 },
];
for (const corruptReport of reportCorruptions) {
corruptions.push((state: Record<string, unknown>): unknown => ({
...state,
lastReport: corruptReport,
}));
}
for (const corrupt of corruptions) {
const candidate = corrupt(structuredClone(valid));
const restored = new FakePi([
{ type: 'custom', customType: 'mosaic-goal-state', data: candidate },
]);
await restored.emit('session_start', { reason: 'resume' });
expect(restored.statuses.get('mosaic-goal')).toBeUndefined();
expect(await restored.emit('context', { messages: [] })).toEqual([undefined]);
}
const failClosed = new FakePi([
{ type: 'custom', customType: 'mosaic-goal-state', data: valid },
{ type: 'custom', customType: 'mosaic-goal-state', data: { ...valid, version: 99 } },
]);
await failClosed.emit('session_start', { reason: 'resume' });
expect(failClosed.statuses.get('mosaic-goal')).toBeUndefined();
});
it('fails closed instead of reusing credential-bearing legacy branch state', async () => {
const source = new FakePi();
await source.goal('set Build a valid restore fixture');
const cleanState = latestGoalStateData(source);
const legacyState = structuredClone(cleanState);
legacyState['statement'] = `Legacy secret ghp_${'z'.repeat(32)}`;
const restored = new FakePi([
{ type: 'custom', customType: 'mosaic-goal-state', data: legacyState },
{ type: 'custom', customType: 'mosaic-goal-state', data: cleanState },
]);
await restored.emit('session_start', { reason: 'resume' });
expect(restored.statuses.get('mosaic-goal')).toBeUndefined();
expect(await restored.emit('context', { messages: [] })).toEqual([undefined]);
expect(restored.notifications.at(-1)?.level).toBe('warning');
expect(restored.notifications.at(-1)?.message).toContain('was not restored');
expect(restored.entries).toHaveLength(0);
});
it('schedules an active tree-restored goal and preserves terminal state through compaction', async () => {
vi.useFakeTimers();
const source = new FakePi();
await source.goal('set Restore active tree work');
const active = latestGoalStateData(source);
const restored = new FakePi();
restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: active }];
await restored.emit('session_tree', {});
await vi.runAllTimersAsync();
expect(restored.sentMessages).toHaveLength(1);
expect(restored.sentMessages[0]?.message.content).toContain('tree navigation');
await restored.goal('cancel');
await restored.emit('session_compact', { reason: 'manual', willRetry: false });
expect(stateField(restored, 'phase')).toBe('cancelled');
expect(stateField(restored, 'compactionCount')).toBe(1);
});
it('cancels deferred continuation when the session shuts down', async () => {
vi.useFakeTimers();
const pi = new FakePi();
await pi.goal('set Do not leak a stale timer');
pi.sentMessages.length = 0;
await pi.emit('session_compact', { reason: 'manual', willRetry: false });
await pi.emit('session_shutdown', { reason: 'reload' });
await vi.runAllTimersAsync();
expect(pi.sentMessages).toHaveLength(0);
});
});
+3 -2
View File
@@ -72,8 +72,9 @@ elif [[ -n "$DATA_DIR" ]]; then
while IFS= read -r file; do while IFS= read -r file; do
[[ -z "$file" ]] && continue [[ -z "$file" ]] && continue
done_total=$((done_total + 1)) done_total=$((done_total + 1))
if git -C "$DATA_DIR" log --since="${WINDOW_DAYS} days ago" --pretty='%s' -- "$file" 2>/dev/null \ history_rc=0
| grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo'; then history="$(git -C "$DATA_DIR" log --since="${WINDOW_DAYS} days ago" --pretty='%s' -- "$file" 2>/dev/null)" || history_rc=$?
if [[ "$history_rc" -eq 0 ]] && grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo' <<<"$history"; then
detectable=$((detectable + 1)) detectable=$((detectable + 1))
fi fi
done < <(find "$DATA_DIR" -type f -name '*.json' 2>/dev/null) done < <(find "$DATA_DIR" -type f -name '*.json' 2>/dev/null)
+2 -2
View File
@@ -64,9 +64,9 @@ for line in "${LINES[@]}"; do
# - build/test/lint/type/ci signals → CI would have caught it # - build/test/lint/type/ci signals → CI would have caught it
# - security/auth/permission/data/migration → human review would flag it # - security/auth/permission/data/migration → human review would flag it
# - everything else (logic/UX/assumption/edge) → only-self-reflection bucket # - everything else (logic/UX/assumption/edge) → only-self-reflection bucket
if printf '%s' "$subj" | grep -qiE 'test|lint|type|build|ci|compile|typo'; then if grep -qiE 'test|lint|type|build|ci|compile|typo' <<<"$subj"; then
ci=$((ci + 1)) ci=$((ci + 1))
elif printf '%s' "$subj" | grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection'; then elif grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection' <<<"$subj"; then
human=$((human + 1)) human=$((human + 1))
else else
selfonly=$((selfonly + 1)) selfonly=$((selfonly + 1))
@@ -0,0 +1,28 @@
[
"tools/matrix-presence-harness/run.sh:TSX_CLI=\"$(ls -d \"${REPO}\"/node_modules/.pnpm/tsx@*/node_modules/tsx/dist/cli.mjs 2>/dev/null | head -1)\"",
"tools/e2e-install-test.sh:if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then",
"tools/install.sh:EXTRACTED_DIR=\"$(find \"$WORK_DIR\" -maxdepth 1 -mindepth 1 -type d | head -1)\"",
"scripts/analysis/reflect-board-history.sh:if git -C \"$DATA_DIR\" log --since=\"${WINDOW_DAYS} days ago\" --pretty='%s' -- \"$file\" 2>/dev/null | grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo'; then",
"scripts/analysis/reflect-git-history.sh:if printf '%s' \"$subj\" | grep -qiE 'test|lint|type|build|ci|compile|typo'; then",
"scripts/analysis/reflect-git-history.sh:elif printf '%s' \"$subj\" | grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection'; then",
"packages/mosaic/framework/tools/authentik/user-create.sh:group_pk=$(echo \"$group_response\" | jq -r \".results[] | select(.name == \\\"$GROUP\\\") | .pk\" | head -1)",
"packages/mosaic/framework/tools/git/mutate-push-guard.sh:PROSE_LO=\"$(grep -n '^usage() {' \"$BAK\" | head -1 | cut -d: -f1)\"",
"packages/mosaic/framework/tools/orchestrator/session-resume.sh:echo \"$dirty_files\" | head -20 | while IFS= read -r line; do",
"packages/mosaic/framework/tools/prdy/prdy-status.sh:if echo \"$PRD_CONTENT\" | grep -qiE \"$pattern\"; then",
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'auth|login|session|token|permission|rbac|credential|secret'; then echo auth; return; fi",
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'migration|prisma|schema|\\.sql|entity|repository|seed'; then echo data; return; fi",
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'docker|\\.woodpecker|compose|traefik|deploy|helm|k8s|terraform'; then echo infra; return; fi",
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'package\\.json|tsconfig|turbo\\.json|pnpm-|\\.config\\.|eslint|vite'; then echo build; return; fi",
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.tsx|\\.css|components/|apps/web/'; then echo ui; return; fi",
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.spec\\.|\\.test\\.|__tests__/'; then echo test; return; fi",
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.md$|docs/'; then echo docs; return; fi",
"packages/mosaic/framework/tools/qa/typecheck-hook.sh:FILE_PATH=$(echo \"$JSON_INPUT\" | grep -o '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | sed 's/.*\"\\([^\"]*\\)\"$/\\1/' | head -1)",
"packages/mosaic/framework/tools/qa/typecheck-hook.sh:RELEVANT=$(echo \"$OUTPUT\" | grep -A2 \"$BASENAME\" 2>/dev/null || echo \"$OUTPUT\" | head -20)",
"packages/mosaic/framework/tools/tmux/send-message.sh:if printf '%s' \"$pane\" | grep -qF \"$QUEUED_RE\"; then",
"packages/mosaic/framework/tools/tmux/send-message.sh:if [ -n \"$snippet\" ] && printf '%s' \"$promptline\" | grep -qF \"$snippet\"; then",
"packages/mosaic/framework/tools/wake/detector.sh:sed -n \"s/^${key}=//p\" \"$MANIFEST\" | head -n1 | tr -d '[:space:]'",
"packages/mosaic/framework/tools/wake/detector.sh:if [ -n \"$snap_sha\" ] && ! printf '%s' \"$snap_sha\" | grep -Eq '^[0-9a-f]{7,64}$'; then",
"packages/mosaic/framework/tools/wake/detector.sh:if [ -n \"$snap_ts\" ] && ! printf '%s' \"$snap_ts\" | grep -Eq '^[0-9]{1,12}$'; then",
"packages/mosaic/framework/tools/wake/digest.sh:olabel=\"$(_locator_line \"$oloc\" | head -n1)\"",
"packages/mosaic/framework/tools/wake/reconcile.sh:sed -n \"s/^${key}=//p\" \"$MANIFEST\" | head -n1 | tr -d '[:space:]'"
]
@@ -0,0 +1,24 @@
[
"packages/mosaic/framework/systemd/user/test-fleet-units.sh:if tmux -L \"$TEST_SOCKET\" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then",
"packages/mosaic/framework/tools/git/test-issue-comment-readback.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
"packages/mosaic/framework/tools/git/test-issue-comment-readback.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
"packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh:contains() { printf '%s\\n' \"$1\" | grep -qx \"$2\"; }",
"packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
"packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh:echo \"$HELP_TEXT\" | grep -q -- '-r, --repo'",
"packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh:echo \"$HELP_TEXT\" | grep -q -- '-H, --host'",
"packages/mosaic/framework/tools/orchestrator/smoke-test.sh:if [[ \"$(printf '%s\\n' \"$codex_run_prompt\" | head -n1)\" == \"Now initiating Orchestrator mode...\" ]]; then pass_case \"codex run prompt first line is mode declaration\"; else fail_case \"codex run prompt first line is mode declaration\"; fi",
"packages/mosaic/framework/tools/orchestrator/smoke-test.sh:if [[ \"$(printf '%s\\n' \"$claude_run_prompt\" | head -n1)\" == \"## Continuation Mission\" ]]; then pass_case \"claude run prompt remains continuation prompt format\"; else fail_case \"claude run prompt remains continuation prompt format\"; fi",
"packages/mosaic/framework/tools/orchestrator/test-board-roll.sh:echo \"$out\" | grep -qi \"dry run\" || note \"dry-run did not announce itself\"",
"packages/mosaic/framework/tools/orchestrator/test-board-roll.sh:echo \"$out\" | grep -q \"would roll\" || note \"dry-run did not report a plan\"",
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh:find \"$1/mosaic/backups\" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null | LC_ALL=C sort -r | head -1",
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh:SNAP_E=\"$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' \"$OUTG\" | head -1)\"",
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh:grep -o '/[^ ]*mosaic-snapshot[^ ]*' \"$OUTH\" 2>/dev/null | head -1 | while read -r s; do rm -rf \"$s\"; done",
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"named socket hello\" || fail \"send-message.sh did not deliver to named socket\"",
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if capture_default | grep -qF \"named socket hello\"; then",
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"[tester:source ->\" || fail \"agent-send.sh did not include preamble\"",
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"agent socket hello\" || fail \"agent-send.sh did not deliver to named socket\"",
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if capture_default | grep -qF \"agent socket hello\"; then",
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:printf '%s' \"$pane\" | grep -qF \"CONCPAYLOAD-${i}-END\" || fail \"concurrent send dropped payload for pane conc-$i\"",
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if printf '%s' \"$pane\" | grep -qF \"CONCPAYLOAD-${j}-END\"; then",
"packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh:if [ \"$rc\" -eq 0 ] && printf '%s' \"$out\" | grep -qF \"✓ delivered\"; then"
]
+160
View File
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
const ROOT = new URL('../', import.meta.url);
const EXPECTED_BASELINE_SITES = 26;
const EXPECTED_TEST_BASELINE_SITES = 22;
const TARGETS = [
'tools/matrix-presence-harness/run.sh',
'tools/e2e-install-test.sh',
'tools/install.sh',
'scripts/agent/session-start.sh',
'scripts/analysis/reflect-board-history.sh',
'scripts/analysis/reflect-git-history.sh',
'packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh',
'packages/mosaic/framework/tools/authentik/user-create.sh',
'packages/mosaic/framework/tools/git/mutate-push-guard.sh',
'packages/mosaic/framework/tools/orchestrator/session-resume.sh',
'packages/mosaic/framework/tools/prdy/prdy-status.sh',
'packages/mosaic/framework/tools/qa/reflect-stop-hook.sh',
'packages/mosaic/framework/tools/qa/typecheck-hook.sh',
'packages/mosaic/framework/tools/tmux/send-message.sh',
'packages/mosaic/framework/tools/wake/detector.sh',
'packages/mosaic/framework/tools/wake/digest.sh',
'packages/mosaic/framework/tools/wake/reconcile.sh',
'packages/mosaic/framework/systemd/user/test-fleet-units.sh',
'packages/mosaic/framework/tools/git/test-issue-comment-readback.sh',
'packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh',
'packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh',
'packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh',
'packages/mosaic/framework/tools/orchestrator/smoke-test.sh',
'packages/mosaic/framework/tools/orchestrator/test-board-roll.sh',
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh',
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh',
'packages/mosaic/framework/tools/tmux/test-send-message-socket.sh',
'packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh',
];
// These statuses are explicitly non-load-bearing or unreachable at designed input.
// They remain inventoried until the final #1099 tranche records every verdict.
const ACCEPTED = [
['tools/install.sh', 'mosaic-bak-', '|| true'],
['tools/install.sh', 'mosaicstack-mosaic-*.tgz', 'head -1'],
['tools/install.sh', 'mosaicstack-gateway-*.tgz', 'head -1'],
['scripts/agent/session-start.sh', 'docs/scratchpads/*.md', '|| true'],
[
'packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh',
'docs/scratchpads/*.md',
'|| true',
],
];
const earlyExit =
/(?<!\|)\|(?!\|)[^;\n]*(?:grep\b[^;\n]*(?:-[A-Za-z]*q|--quiet|-m\s*1)|head\b(?:\s|$))/;
function scan(sources) {
const found = [];
for (const [file, rawSource] of sources) {
const source = rawSource.replace(/\\\n\s*/g, ' ');
for (const rawLine of source.split('\n')) {
const line = rawLine.trim();
if (!earlyExit.test(line)) continue;
const accepted = ACCEPTED.some(
([acceptedFile, ...fragments]) =>
acceptedFile === file && fragments.every((item) => line.includes(item)),
);
if (!accepted) found.push(`${file}:${line}`);
}
}
return found;
}
async function currentSources() {
return Promise.all(
TARGETS.map(async (file) => [file, await readFile(new URL(file, ROOT), 'utf8')]),
);
}
async function assertBaselineFixture(file, expectedCount, expectedUnique = expectedCount) {
const baseline = JSON.parse(await readFile(new URL(file, ROOT), 'utf8'));
assert.equal(baseline.length, expectedCount);
assert.equal(new Set(baseline).size, expectedUnique);
const fixtureSources = baseline.map((site) => {
const separator = site.indexOf(':');
assert.ok(separator > 0, `invalid baseline site: ${site}`);
return [site.slice(0, separator), site.slice(separator + 1)];
});
assert.deepEqual(scan(fixtureSources), baseline);
}
test('the registered runtime baseline denominator is exactly 26 unsafe sites', async () => {
await assertBaselineFixture(
'scripts/fixtures/pipefail-early-exit-baseline.json',
EXPECTED_BASELINE_SITES,
);
});
test('the registered test baseline denominator is exactly 22 unsafe sites', async () => {
await assertBaselineFixture(
'scripts/fixtures/pipefail-early-exit-test-baseline.json',
EXPECTED_TEST_BASELINE_SITES,
21,
);
});
test('load-bearing pipefail paths do not pipe into early-exiting consumers', async () => {
assert.deepEqual(scan(await currentSources()), []);
});
test('gateway verify capability preserves the complete help-probe truth table', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'gateway-help-probe-'));
const mosaic = path.join(directory, 'mosaic');
const probe = new URL('tools/e2e-gateway-verify-supported.sh', ROOT).pathname;
try {
await writeFile(
mosaic,
'#!/usr/bin/env bash\nprintf \'%s\\n\' "${MOCK_HELP_OUTPUT:-}"\nexit "${MOCK_HELP_RC:-0}"\n',
);
await chmod(mosaic, 0o755);
const run = (rc, output) =>
spawnSync('bash', [probe], {
env: {
...process.env,
PATH: `${directory}:${process.env.PATH}`,
MOCK_HELP_RC: String(rc),
MOCK_HELP_OUTPUT: output,
},
}).status;
assert.equal(run(0, 'commands: verify'), 0);
assert.equal(run(0, 'commands: install'), 1);
assert.equal(run(1, 'commands: verify'), 1);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test('board-history preserves non-git data-dir as a non-detectable result', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'reflect-board-non-git-'));
try {
await writeFile(path.join(directory, 'task.json'), '{}\n');
const result = spawnSync(
'bash',
[
new URL('scripts/analysis/reflect-board-history.sh', ROOT).pathname,
'--data-dir',
directory,
],
{ encoding: 'utf8' },
);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /"done_tasks": 1/);
assert.match(result.stdout, /"detectable_outcomes": 0/);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Exit 0 only when the capability probe itself succeeds and advertises verify.
# A failed help command and a successful response without verify are both
# unsupported, matching the historical e2e-install-test.sh conditional.
set -uo pipefail
gateway_help_rc=0
gateway_help="$(mosaic gateway --help 2>&1)" || gateway_help_rc=$?
[[ "$gateway_help_rc" -eq 0 ]] || exit 1
grep -q 'verify' <<<"$gateway_help"
+1 -1
View File
@@ -136,7 +136,7 @@ fi
echo "=== [inner] Running mosaic gateway verify ===" echo "=== [inner] Running mosaic gateway verify ==="
# `gateway verify` was added in feat/mosaic-first-run-ux. # `gateway verify` was added in feat/mosaic-first-run-ux.
# If the installed version pre-dates this, skip gracefully. # If the installed version pre-dates this, skip gracefully.
if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then if ! bash /repo/tools/e2e-gateway-verify-supported.sh; then
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}." echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release." echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
echo "[SKIP] Re-run after the new version is published to validate this step." echo "[SKIP] Re-run after the new version is published to validate this step."
+8 -4
View File
@@ -308,13 +308,17 @@ ensure_monorepo() {
exit 1 exit 1
fi fi
# Gitea archives extract to <repo-name>/ inside the work dir # Gitea archives extract to exactly one <repo-name>/ inside the work dir.
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)" # Read the complete population so a malformed multi-root archive reaches the
if [[ -z "$EXTRACTED_DIR" ]] || [[ ! -d "$EXTRACTED_DIR" ]]; then # named diagnostic instead of aborting on an upstream SIGPIPE under pipefail.
fail "Could not locate extracted source in archive." local -a extracted_dirs=()
mapfile -d '' -t extracted_dirs < <(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d -print0)
if [[ "${#extracted_dirs[@]}" -ne 1 ]] || [[ ! -d "${extracted_dirs[0]:-}" ]]; then
fail "Could not locate exactly one extracted source directory in archive."
ls -la "$WORK_DIR" >&2 ls -la "$WORK_DIR" >&2
exit 1 exit 1
fi fi
EXTRACTED_DIR="${extracted_dirs[0]}"
} }
# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both # Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
+4 -1
View File
@@ -35,7 +35,10 @@ export DARK_THRESHOLD_MS="${DARK_THRESHOLD_MS:-6000}"
export AGENT_SLUGS="${AGENT_SLUGS:-alpha,bravo,charlie}" export AGENT_SLUGS="${AGENT_SLUGS:-alpha,bravo,charlie}"
export VICTIM_SLUG="${VICTIM_SLUG:-charlie}" export VICTIM_SLUG="${VICTIM_SLUG:-charlie}"
TSX_CLI="$(ls -d "${REPO}"/node_modules/.pnpm/tsx@*/node_modules/tsx/dist/cli.mjs 2>/dev/null | head -1)" shopt -s nullglob
TSX_CANDIDATES=("${REPO}"/node_modules/.pnpm/tsx@*/node_modules/tsx/dist/cli.mjs)
shopt -u nullglob
TSX_CLI="${TSX_CANDIDATES[0]:-}"
if [[ -z "${TSX_CLI}" ]]; then if [[ -z "${TSX_CLI}" ]]; then
echo "run.sh: tsx not found under node_modules — run pnpm install first" >&2 echo "run.sh: tsx not found under node_modules — run pnpm install first" >&2
exit 1 exit 1