Compare commits

..
Author SHA1 Message Date
Hermes Agent b1254f52f3 wrapper-guard: judge the payload, not the caller — delete the code/data parser
ci/woodpecker/pr/ci Pipeline is running
Round-five review found command substitution executing inside the very quoted
spans the skeleton was discarding as prose:

  echo "$(curl -d@b .../issues/1/comments)"
  msg="$(curl -d@b .../issues/1/comments)"

The unquoted and process-substitution forms already blocked, so the same call
was refused or allowed depending on a quote character. That makes it a
classification defect rather than another spelling, and it is the nineteenth
write to reach execution through this file by the same route: the client was
ABSENT from the skeleton, so the guard allowed.

The reviewer's judgement, which I asked for and accept: this is fitting to the
test set. Answering "code or data" from shell text with sed and awk is not a
hard problem, it is the wrong problem.

It was also not portable. CI has been red at `sanitization` since round four,
and the log says why: under the image's busybox awk the octal escape in the
quote-stripping regex does not bite, every quoted span survives into the
skeleton, and the guard began refusing ordinary prose. Five allow-direction
fixtures failed in CI that pass under GNU awk. A control that reverses its
verdict with the awk on the host is not a control.

So the client detection is gone — the skeleton, the invoker list, the prefix
list, the option-value skipping, all of it. What remains asks two questions of
the text: is this a write, and does it name an endpoint a wrapper owns. It
cannot fail open by hiding the caller because it never looks for one, and it
now catches clients it was never taught: `python -c ... requests.post(...)` and
`wget --post-data` are both fixtures.

The cost is stated in the file and pinned in both directions: QUOTING one of
these calls on a Bash command line is refused as well. Ten fixtures that used
to assert "discussing a call is not making one" now assert the opposite, and
the boundary that stops this becoming block-everything is asserted just as
hard — a quoted READ, an endpoint named without a body flag, a quoted write to
an UNWRAPPED endpoint, and the wrapper's own body flag all still pass. The
18-command ordinary-work sweep blocks none.

The rule an agent can hold without a parser: do not put a raw write to a
wrapped forge endpoint on a Bash command line, not even inside quotes. Write
the example with a file-writing tool.

60/60 fixtures, verified inside the CI image (busybox) as well as locally.
Gates: sanitization, resident budget, test enumeration, tools-index (self-test
4/4, git suite 100%), issue-close, prettier.
2026-08-12 17:59:14 -05:00
Hermes Agent 2a2a87251a wrapper-guard: read the command the shell will run, and stop losing the client behind option values
ci/woodpecker/pr/ci Pipeline failed
Round-four review, three more absence-driven allows.

1. The guard read the command as TYPED. A backslash before a newline is removed
   before anything else happens, so an endpoint token split across the join
   (`.../iss\` + newline + `ues/1/comments`) executed the comments endpoint while
   the literal token never appeared in the text. Continuations are now joined
   before every check, because the joined form IS the command. This is the same
   defect as the split-across-variables case, minus the excuse: there the token
   genuinely does not exist until the shell expands it, here it was sitting in
   the input the whole time and the guard chose the wrong reading of it.

2. Transparent prefixes take option VALUES. `sudo -u root curl` hid a live write
   because `root` was a word the prefix list did not know. Enumerating option
   grammars per prefix is the wrong game, so what is skipped is an option and at
   most one value for it, plus a bare duration for `timeout` — never an
   arbitrary word. `xargs echo curl ...` therefore stays ALLOWED, because there
   the command is echo and the client is its argument.

3. `find -exec` runs the client. It opens command position the same way an
   operator does, and now reads that way.

All seven reviewer repros are fixtures, each with its counter-case in the
allowed direction: a continuation inside a heredoc document stays a document,
`xargs echo curl` stays allowed, `sudo apt-get install curl` stays allowed, a
prefixed READ stays allowed. 48/48, and the 18-command ordinary sweep still
blocks none.

Gates: sanitization, resident budget, test enumeration, tools-index (self-test
4/4, git suite 100%), prettier.
2026-08-12 17:46:25 -05:00
Hermes Agent e6a881a795 wrapper-guard: judge command position on prefixes and on what a shell will execute
ci/woodpecker/pr/ci Pipeline failed
Round-three review found two more absence-driven allows, both in the skeleton
introduced by round two, and fixing them exposed a third the reviewer had not
reached yet.

1. A word in front of a command does not displace the command. `env VAR=v curl`,
   `command curl`, `timeout 10 curl` and `/usr/bin/curl` were all real writes at
   execution position that a bare-name match could not see. The `env` form is the
   one that matters: it is what an agent reaches for to keep a credential out of
   the global environment, so the careful spelling was the invisible one.

2. Quoted data stops being data when a shell is about to execute it, and the
   first version knew only `bash -c`, `sh <<` and `eval`. It did not know the
   pipe, which is the form people actually use: `printf ... | sh`, `cat <<EOF | sh`,
   `sh -s <<EOF` each made a live call vanish from the skeleton while still
   running.

3. Found while testing the fix: that decision was made for the WHOLE command, so
   a single unrelated `docker run ... sh -c 'echo hi'` promoted every other
   quoted span on every other line to code. It blocked its own author for the
   second time in a day. A shell on one line does not execute a string on another
   line, and over-blocking is not the safe direction — a guard that blocks
   ordinary work gets switched off, and a guard that is off permits everything.
   The skeleton is now built per line, and a heredoc body is code only when the
   line that opened it fed a shell.

All seven reviewer repros are pinned as fixtures, each with a counter-fixture in
the allowed direction: `echo timeout 10 curl ...` is not a call, a pipe to `wc`
is not execution, an unrelated shell on another line changes nothing. Fixtures
40/40, and a sweep of 18 ordinary commands blocks none of them.

Gates: sanitization, resident budget, test enumeration, tools-index (self-test
4/4, git suite 100%), prettier.
2026-08-12 17:39:05 -05:00
Hermes Agent 7962e4302f guard: judge command position on the code, not on the text
ci/woodpecker/pr/ci Pipeline was canceled
The position test added an hour ago blocked its own author. The message being
sent quoted one of the fixtures, so the quoted text contained an operator
followed by a client, and an operator inside a string is not an operator. That
is the reported over-blocking defect one level in, and it landed within an hour
of shipping the fix for the reported one — which is the argument for pinning
both directions as fixtures rather than reasoning about them.

Position is now judged against a SKELETON: the command with its data spans
(quoted strings, heredoc bodies) removed. Endpoint, URL and body detection keep
running against the full text, because real calls quote their URLs and a
skeleton would be blind to them.

The exception is what makes quotes data in the first place. If something is
about to EXECUTE the quoted text — `bash -c`, `sh <<EOF`, `eval` — the quotes
hold code, and the skeleton keeps them as command separators so the client
inside is still at command position, one interpreter down.

Three fixtures: an operator inside a quoted string, a heredoc body, and
`bash -c` making the same text code again. 30/30.
2026-08-12 17:29:33 -05:00
Hermes Agent 8a901cc19a guard: read command position, refuse unreadable URLs, survive pipefail
ci/woodpecker/pr/ci Pipeline was canceled
Round two of the same independent review. Three findings, all real, and the
first two share a root cause: the guard was reading command TEXT as though it
were a command.

1. Splitting the endpoint token itself defeats fragment matching outright —
   `a=/api/v1/repos/o/r/iss; b=ues/1/comments` leaves no fragment contiguous.
   Round one fixed one spelling of this and the reviewer produced the general
   form immediately. It is not winnable by more fragments: the endpoint does
   not exist until the shell expands it, and this hook runs first. So the guard
   stops pretending to read it. A write whose URL contains an expansion, on a
   visibly forge-shaped command, is now BLOCKED as unreadable — because "I
   could not find an endpoint" must not mean "there is no endpoint". Opaque
   URLs that are not forge-shaped (webhooks, artifact stores) still pass.

2. The broadened body detection false-blocked ordinary work: `grep -R "curl -d
   https://.../issues" docs/`, `echo "curl -d ..." > note.txt`, printing an
   example from python. Talking about a call is not making one, and this is the
   direction that actually kills a control — an over-blocking hook gets turned
   off, and an off hook permits everything. The client must now appear at
   COMMAND POSITION: line start or after a shell operator, optionally behind
   VAR=value. In every false positive it sat behind a quote instead. Quotes are
   deliberately NOT stripped before matching; real calls quote their URLs.

3. `wt_precious()` aborted `cmd_rm` under `set -euo pipefail`: `grep -v` exits 1
   when it filters everything out, which is exactly the disposable-only case,
   so a SAFE worktree failed to remove with no message. Fixed, and the same
   defect was latent one step upstream in `wt_dirty()`, where `head -200`
   SIGPIPEs git on any worktree with 201 changed files. The cap is gone —
   counting is cheap and the cap only ever truncated output that is no longer
   printed.

Seven new fixtures pin all of it, in both directions. 27/27.
2026-08-12 17:26:09 -05:00
Hermes Agent 8b7ac5b51e guard: close four fail-open holes found by independent review
ci/woodpecker/pr/ci Pipeline was canceled
An independent reviewer broke all three new controls before they shipped.
Every finding is reproduced as a fixture or a repro, because the class is
recurring rather than incidental: each hole was a case where the answer was
"allow" because something was ABSENT rather than because it was CHECKED.

1. wrapper-guard read only the spellings it knew. `curl -d@body` (no space),
   `--request=POST` (equals form), and a URL path assembled from shell
   variables each carried a real provider write straight through. Write
   detection now covers every body and method form curl accepts, and the
   endpoint match no longer anchors on a literal host path that a variable
   can dissolve.

2. wrapper-guard blocked only when the wrapper FILE existed. A host with a
   broken or partial install therefore permitted exactly the raw writes the
   guard exists to stop. Blocking is now on the endpoint; a missing wrapper
   changes the remedy text, not the verdict — a broken install is not
   permission to bypass gate 7.

3. mosaic-worktree read a worktree's safety from two questions, and a clean,
   fully-pushed tree holding a gitignored `local.secret` answered both with
   zero. `git worktree remove` then deleted the one copy in existence. A file
   is gitignored precisely so nothing else holds it, so ignored-but-not-
   disposable files are now a third evidence question. Build junk
   (node_modules, .venv, dist, caches, *.pyc) stays disposable, so the common
   case still reads SAFE.

4. check-tools-index counted a documented tool as discoverable at mode 0644.
   Every caller tests `[ -x ]`, so a non-executable tool is a missing tool;
   it now fails the gate with its own message.

Local gates green: sanitization, resident budget, test enumeration,
tools-index (4/4 self-test, 100% on the enforced git suite), and
wrapper-guard 20/20.
2026-08-12 17:20:11 -05:00
Hermes Agent 96609bdade framework: prove the wrapper guard both ways, and resolve its wrappers relatively
ci/woodpecker/pr/ci Pipeline failed
Two defects found by running the guard rather than reading it.

1. The guard resolved its sibling wrappers through a hardcoded
   $HOME/.config/mosaic/tools/git. On a host with no installed mosaic home — a
   CI container, a bare checkout — every wrapper lookup missed, `[ -x ]` failed,
   and the guard fell through allowing the raw API write it exists to block. It
   failed OPEN, silently, in exactly the environment least likely to notice.
   It now resolves relative to its own path, so it names the wrappers from the
   install it was launched from, with $HOME as the fallback.

2. There was no test. Adding one surfaced the guard's other sharp edge
   immediately: it matches the literal text of the Bash command, so a harness
   that embeds a blocked pattern inline trips the guard on itself rather than on
   the fixture. That is the correct fail-closed posture and it is now recorded in
   the test's own comments, because the next person will hit it too.

test-wrapper-guard.sh asserts twelve fixtures and asserts the ALLOWED cases as
hard as the blocked ones. A guard that over-blocks gets routed around and a guard
that under-blocks is decoration; only pinning both edges keeps it useful. It is
hermetic — no network, no credentials, no repository — so it joins the CI
sanitization step directly rather than the exclusions file.
2026-08-12 16:54:18 -05:00
Hermes Agent e3a0ee87b3 framework: make tool discoverability, workspace placement and model tiering mechanical
An undocumented tool is, from inside an agent session, indistinguishable from a
tool that was never written. The framework shipped 26 git wrappers and named 6 of
them in its resident index docs — 23% discoverability, with pr-review.sh among the
missing. The observable consequence was an agent obeying Constitution gate 7 as
best it could see it, reaching for raw curl, sending GitHub's APPROVE to a Gitea
host, and getting HTTP 200 with the review silently filed PENDING. Three times.
That is not a discipline failure and no amount of prose fixes it.

Four changes, each converting a rule that decayed into a mechanism that cannot:

- check-tools-index.sh (new, CI-blocking): every tool in an enforced suite must be
  named in a resident index doc, and every tool an index names must exist. The git
  suite is enforced now; other suites report coverage without failing, so the
  ratchet tightens one reviewed PR at a time instead of landing as one sweep. The
  enforced list is framework-owned rather than a marker inside operator-owned
  TOOLS.md — a doc marker would let an operator silence the gate on exactly the
  host where it matters most. Carries --self-test, because a checker that only
  ever passes is indistinguishable from one that is not running.

- TOOLS-REFERENCE.md: complete 28-entry git index, plus the APPROVED/APPROVE
  dialect note that explains why pr-review.sh is not a formality.

- mosaic-worktree.sh + wrapper-guard.sh (upstreamed): the rule "big work goes on a
  work filesystem" already existed in prose, and 255 GB accumulated in $HOME across
  842 directories anyway, under five simultaneous placement conventions on one
  host. The helper therefore exposes no placement decision — given a branch name,
  every path is derived from `git worktree list --porcelain`. Worktrees rather than
  clones because enumerability is the only thing that makes reclaim safe, and
  reclaim is by evidence (clean tree + no unpushed commits), never by size or age.
  The guard blocks three mechanically-detectable mistakes and nothing else:
  a checkout into $HOME, a raw provider-API write to an endpoint that has a
  wrapper, and the literal APPROVE event. Reads pass untouched.

- STANDARDS.md: model tiering as a standard, named by capability class so it
  survives a model generation. Start cheapest, escalate on evidence, benchmark
  before demoting a task class, and keep the class->model binding in operator
  config with the DB-backed config service as the end state.

Registering the guard in runtime/claude/settings.json is the point of upstreaming
it: ~/.claude/settings.json is a framework-managed copy, so a hand-added hook there
is destroyed by the next upgrade. In the template it survives, and it reaches every
host instead of one.
2026-08-12 16:51:17 -05:00
mos-dt-0 ec260e678f Merge pull request 'fix(git): accept http/https as one scheme class in comment URL verification (#991)' (#1022) from fix/991-comment-url-scheme-normalise into main
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-08-12 01:11:09 +00:00
Jason Woltje b590a5c3d8 fix(git): accept http/https as one scheme class in comment URL verification (#991)
ci/woodpecker/pr/ci Pipeline was successful
issue-comment.sh and pr-review.sh verify a durable write by pinning the
provider-returned object URL's origin and full path. The origin included the
SCHEME verbatim. On a Gitea whose ROOT_URL is configured `http://` while every
client reaches it over `https://`, the provider returns `http://` object URLs,
so the comparison rejects the provider's own truthful answer about a write that
LANDED. The failure is deterministic, not intermittent: every comment, every
time, on such a deployment.

The scheme was never what the check defends. The forgeries it exists to catch —
look-alike host, decoy path prefix, wrong owner/repo/kind/number — all vary the
HOST or the PATH. Both stay strict. `http` and `https` now collapse to one
scheme class; any other scheme (file:, ftp:, javascript:) stays distinguishing,
and an EXPLICIT non-default port still distinguishes, because a different port
is a different service on the same host.

Consequences of the bug, both observed:

- The wrapper reports failure on a comment that is durably on the issue/PR, and
  attributes it to #865 ("no durable comment created"). The write landed; the
  citation is wrong. Reproduced here: the harness's persisted state contains the
  record while the wrapper exits 1.
- pr-review.sh's comment path is worse. On a host where no seat can create a
  review OBJECT, comment-form is the only gate-16 review record obtainable, and
  this check refuses all of it.

Test gap this closes: every URL fixture in both harnesses was `https://`, and
every negative case varied only host or path. The one axis that fails in
production had zero coverage — the fixtures encoded the assumption that breaks.
Added, in both suites:

- scheme-downgrade (http vs https, otherwise correct) — must be ACCEPTED. Fails
  against the unmodified wrappers, passes against the fixed ones; verified in
  both directions, and the negative control's captured output is the #865
  misattribution above.
- explicit non-default port (`:8443`) — must stay REJECTED.
- non-web scheme (`ftp://`) — must stay REJECTED.

Also fixes test-issue-comment-readback.sh hermeticity (#1007), without which the
suite cannot run on any seat that has a per-agent Gitea token: detect-platform's
step-0 identity lookup reads ~/.config/mosaic/gitea-tokens/<identity>, outside
both XDG_CONFIG_HOME and MOSAIC_CREDENTIALS_FILE, so the suite resolved a
PRODUCTION credential and died at HTTP 401 before case 1. Same two-part fix
already merged for test-pr-review-gitea-comment.sh in #1006: a sandboxed HOME
plus an empty REPO-LOCAL mosaic.gitIdentity to shadow the global. Note the
env-var route does NOT work — detect-platform.sh reads `${MOSAIC_GIT_IDENTITY:-}`
and `:-` treats set-but-empty identically to unset.

The owner-side half of #991 (setting the deployment's Gitea ROOT_URL to https)
is not in scope here and is not made unnecessary by this change; this makes the
wrappers correct against a deployment that returns either scheme.
2026-08-11 19:00:31 -05:00
mos-dt-0 540ec5b6ef Merge pull request 'fix(git): #1007 suite hermeticity — pin repo-local mosaic.gitIdentity in five test suites' (#1024) from fix/1007-suite-hermeticity into main
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-08-11 23:19:49 +00:00
mos-dt-0 563d1ac053 Merge pull request 'fix(shell): remove wake validation pipe hazards' (#1107) from fix/1099-pipefail-wake into main
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-08-11 23:19:45 +00:00
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-coder f158be8003 fix(shell): remove wake validation pipe hazards
ci/woodpecker/pr/ci Pipeline was successful
2026-08-07 06:13:47 -05:00
Jason Woltje 2fa6bcd576 fix(git): #1007 — test-issue-comment-readback is a FIFTH affected suite (second census correction)
ci/woodpecker/pr/ci Pipeline was successful
My previous commit said four. It is five. `test-issue-comment-readback.sh` has
the same defect and is fixed the same way, and I had already looked straight at
it and filed it as an *unrelated* silent failure. Correcting that here rather
than folding it in quietly.

WHY IT WAS MISSED — the general lesson, not the excuse. `run_comment()` sends
the wrapper's stdout AND stderr to `$OUTPUT_FILE`, and the `EXIT` trap deletes
`$WORK_DIR`. The suite therefore exits 1 with ZERO bytes on stdout and stderr,
and the one line that says what went wrong —

    Error: Gitea authenticated-identity read failed with HTTP 401

— lives only inside a directory that no longer exists when anyone looks. Every
oracle I had swept the family with greps for a SYMPTOM in surviving output, so
against this suite all of them returned "nothing found", which I read as "clean"
in the first sweep and as "unrelated pre-existing failure" in the second. A
suite that discards or deletes its own evidence converts a post-hoc assay into a
non-measurement, and I wrote that sentence into the previous commit while it was
already false about a file in the same directory.

HOW IT WAS ACTUALLY FOUND. Intercept the identity read at its SOURCE instead of
grepping for its consequence: a PATH shim over `git` that logs every
`mosaic.gitIdentity` read — args, rc, and resolved value — to a file OUTSIDE any
suite's work dir, then execs the real git. Deletion-proof by construction, and
it measures the defect's cause rather than one of its symptoms. Sweeping all 16
suites with it under an ordinary invocation:

  resolves a REAL identity (`mos-dt-0`) before the fix:
    test-issue-comment-readback          1 read   rc=1 (RED on every seat)
    test-pr-review-repo-host-override    6 reads  rc=0
    test-ci-queue-wait-branch-absent     3 reads  rc=0
  the four fixed in the previous commit now read empty; the rest never read at all.

The latter two are NOT affected and are deliberately left alone: under a seat
replica (identity set, no per-slot token) neither reaches `get_gitea_token`'s
fail-loud branch, and under a canary HOME neither carries the canary credential
into any surviving artifact. They read the identity and never enter a credential
path. That residual is structural and belongs to the wrapper half of #1007 —
scoping the read with `git -C "$repo"` removes it for everyone at once.

An earlier version of that sweep reported the four fixed suites as still
resolving a real identity. That was my grep, not the suites: `value=\[..*\]` is
satisfied by `value=[] args=[…]`, because `.*` runs past the empty pair and
matches the closing bracket of the NEXT one. `value=\[[^]]` is the correct test.
Recorded because the wrong pattern failed in the direction that would have sent
me re-fixing four already-correct files.

VERIFICATION of this suite, four HOME arms, all rc=0 with zero non-empty
identity reads and the pass line on stdout: real HOME, seat replica, canary
HOME, and an empty HOME with no identity at all. Full 16-suite sweep after the
change: every suite rc=0.

CONSEQUENCE FOR THE FINDING LIST IN THE PREVIOUS COMMIT: item 2 there — the
"silently red, unrelated to #1007" suite — is withdrawn. It was #1007 all along.
Item 1 (`pr-metadata.sh:89-92`, the anonymous fallback that reports an HTTP 200
carrying valid JSON as "unknown API error") stands and is still unfixed here.

Refs #1007
2026-07-31 07:27:10 -05:00
Jason Woltje 1afe2b36dc fix(git): #1007 suite hermeticity — pin repo-local mosaic.gitIdentity in four test suites
CENSUS CORRECTION: FOUR suites, not the three my own #1007 audit named. The
fourth (test-pr-metadata-gitea.sh) was outside the candidate set that audit
worked from and was found only by sweeping the discriminator across all 16
tools/git/test-*.sh suites. Recording that as a correction to my finding, not
as part of the original claim.

THE DEFECT. get_gitea_token() (detect-platform.sh:502-599) resolves a per-agent
identity at STEP 0, from `git config --get mosaic.gitIdentity`, BEFORE both the
Mosaic credential loader (step 1) and the GITEA_TOKEN env check (step 2). On a
provisioned agent seat that value is set GLOBALLY in ~/.gitconfig and is
inherited by any freshly-`git init`ed repo, so step 0 reads a REAL per-slot
token out of $HOME and returns it without ever consulting the suite's own
MOSAIC_CREDENTIALS_FILE / GITEA_TOKEN fixtures. The suites were running against
production credentials, and the fixture credential each one carefully
constructs was inert.

THE FIX: an empty repo-local `mosaic.gitIdentity`. An empty local value shadows
the global one and reads back empty at rc=0, so step 0 declines. The env route
does NOT work: detect-platform.sh reads "${MOSAIC_GIT_IDENTITY:-}", and `:-`
treats set-but-empty identically to unset.

OPERATIVE vs CONTAINMENT — the two mechanisms are not interchangeable and the
comment in each suite says so. The pin is operative: it prevents the resolution.
The sandboxed HOME each suite now also gets is containment: it bounds a failure
the pin should already have prevented. Conflating them is how this class stays
invisible, because a decoy HOME REMOVES the trigger (~/.gitconfig is where the
global identity lives), so any suite audited under one reads clean however
vulnerable it is. To MEASURE, replicate a seat: a decoy HOME whose .gitconfig
sets mosaic.gitIdentity with no per-slot token, so step 0 reaches its fail-loud
branch. That note is in each file for the next auditor.

SECOND, INDEPENDENT DEFECT in test-pr-metadata-gitea.sh. Applying the pin alone
turned that suite RED — and a control at baseline 826a8b3 under a plain HOME
reproduced the same failure, so it is pre-existing, not introduced. Its
`GITEA_TOKEN="stub-token"` / `GITEA_URL="https://git.example.test"` pair can
never satisfy step 2, because step 2 accepts GITEA_TOKEN only when GITEA_URL
matches the remote host and this repo's origin is git.uscllc.com. The suite had
therefore only ever passed by resolving a REAL credential — step 0 on a seat, or
step 1 from the operator's own credentials.json. A MOSAIC_CREDENTIALS_FILE
fixture is added rather than leaning on the sandboxed HOME making step 1 find
nothing: a test that passes because production configuration is ABSENT fails the
moment it is present. Shipping the pin without this would have moved the failure
rather than removed it.

NO CI ARM. .woodpecker/ci.yml does not run these suites; packages/mosaic/
package.json:28 (test:framework-shell) runs an ENUMERATED list that excludes all
four. They run only by hand — i.e. exclusively on a provisioned seat, the one
environment where the defect is live. "Passes in CI, fails on a seat" does not
apply here; there is no CI observation at all.

VERIFICATION (seat replica = decoy HOME with mosaic.gitIdentity set, no per-slot
token; canary = same plus a marked non-credential at both per-slot paths; plain
= empty HOME; real = ordinary invocation):
  - bash -n clean on all four.
  - Sweep of all 16 suites at baseline 826a8b3 under the seat replica:
    test-gitea-login-resolution rc=1 REACHES-STEP0; test-issue-create-
    interactive-auth rc=1 REACHES-STEP0; test-pr-merge-gitea-empty-uid rc=1
    REACHES-STEP0; test-pr-metadata-gitea rc=1 REACHES-STEP0.
  - Same sweep after: every row rc=0 with step0 absent.
  - test-gitea-token-identity flags REACHES-STEP0 in BOTH arms and is NOT a
    defect: it runs under `env -i HOME="$FAKE_HOME"` (line 77) and its hit is
    its own deliberate assert_failloud fixtures (lines 158-171). The fail-loud
    grep matches the intended behaviour as well as the defect, so it needs the
    second discriminator; recorded here so the next sweep does not re-file it.
  - Durable-argv assay (a PATH shim that tees argv out of each suite's own mock
    curl, because test-pr-merge-gitea-empty-uid truncates its log between phases
    and its EXIT trap removes the sandbox — a post-hoc read of that suite is a
    non-measurement, and "no trace" there is not a clearance):
      test-pr-merge-gitea-empty-uid  before: canary token in argv, fixture never
        used. after: fixture token in argv, canary absent. 5 curl calls both arms.
      test-pr-metadata-gitea         before: canary in argv. after: both calls
        carry the fixture token against git.uscllc.com.
  - test-pr-metadata-gitea across seat/canary/plain HOMEs after the fix: rc=0,
    rc=0, rc=0.
  - All four under the real HOME: rc=0. No regression to ordinary invocation.

The comment block is duplicated across the four files rather than pointing at a
shared note. Deliberate, and matching the merged #1006 precedent
(test-pr-review-gitea-comment.sh:87-95): the reader who needs it is auditing one
file.

TWO FINDINGS DELIBERATELY NOT FIXED HERE (out of this branch's scope, to be
filed):
  1. pr-metadata.sh:89-92 — the anonymous curl fallback does not check ^2, so an
     HTTP 200 carrying valid JSON is reported as "unknown API error" at rc=1.
  2. test-issue-comment-readback.sh exits 1 with ZERO bytes on stdout AND
     stderr, dying at its first seed_state python3 heredoc. Reproduces at
     baseline 826a8b3 under both a seat replica and the real HOME. Silently red
     at main for everyone; unrelated to #1007.

Refs #1007
2026-07-31 07:13:13 -05:00
43 changed files with 4062 additions and 465 deletions
+11
View File
@@ -46,10 +46,21 @@ steps:
# [0] of the pnpm chain, so severing that chain would silence it together
# with everything it guards; this direct line keeps one instrument running.
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
# Tool-index gate: a shipped wrapper that appears in no resident index doc
# is undiscoverable from inside a session, and an agent that cannot learn a
# wrapper exists reaches for raw curl instead — which is how a Gitea review
# got filed PENDING three times. Ships-and-documented is one commit, or red.
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
# 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
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
# it still blocks the three mistakes AND still lets reads, unwrapped
# endpoints and ordinary commands through. Both directions are asserted —
# a guard that over-blocks gets routed around, which fails just as hard.
- bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh
# Blocking gate (#791): a framework upgrade must never write or delete an
# 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.
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
```bash
+122
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)
### Problem and objective
+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 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 entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
+38 -1
View File
@@ -7,7 +7,8 @@
3. [Provider Configuration](#provider-configuration)
4. [MCP Server Configuration](#mcp-server-configuration)
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_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
| Variable | Default | Description |
@@ -374,3 +385,29 @@ Session cleanup is scoped to one session identifier and only removes that sessio
| Variable | Default | Description |
| ----------------------- | ----------------------------- | ------------------------------------------ |
| `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)
6. [Database Schema and Migrations](#database-schema-and-migrations)
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
8. [API Endpoint Reference](#api-endpoint-reference)
9. [Local Fleet Canary](./fleet-local-canary.md)
8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension)
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
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
All endpoints are served by the gateway at `http://localhost:14242` by default.
+55 -3
View File
@@ -8,9 +8,10 @@
4. [Tasks](#tasks)
5. [Settings](#settings)
6. [CLI Usage](#cli-usage)
7. [Sub-package Commands](#sub-package-commands)
8. [Telemetry](#telemetry)
9. [Local Fleet Canary](./fleet-local-canary.md)
7. [Pi Persistent Goals](#pi-persistent-goals)
8. [Sub-package Commands](#sub-package-commands)
9. [Telemetry](#telemetry)
10. [Local Fleet Canary](./fleet-local-canary.md)
---
@@ -307,6 +308,57 @@ mosaic prdy
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
-353
View File
@@ -1,353 +0,0 @@
# Greenfield install log — fomo-lin
Running log of a from-scratch Mosaic Stack install on Jason's test laptop **fomo-lin**
(Debian 13, x86_64). Operator: **scooby** (agent). Started 2026-08-08. Channel per fred:
findings → comms as they land; this file is the durable record. Branch: `greenfield/fomo-lin`.
## Machine starting state (2026-08-08)
- Debian 13 (kernel 6.12.101+deb13), no Node/npm, no global git config, no `~/.ssh`,
no `~/.config/mosaic`, no `~/.mosaic`, sudo requires password (agent cannot escalate).
- Repos pre-cloned by Jason: jarvis-brain, mosaic-brain, stack, uconnect, uscllc-website
(all https remotes to git.mosaicstack.dev, **no credentials stored** — private-repo
fetch/push dead until a token was provisioned from credentials.json, `usc_mos`).
- tmux session `scooby` running Claude Code (bare harness — not `mosaic claude`).
## Pre-install setup that had NO framework mechanism (manual work)
- Agent identity: `MOSAIC_AGENT_NAME=scooby` hand-added to `~/.bashrc` + tmux env.
- Git identity + credential store: hand-configured.
- Comms receive path: hand-ported `scooby-comms-watcher.sh` from fred's watcher +
hand-written systemd `--user` unit + `loginctl enable-linger`. Works (both peers
verified round-trip within ~90s), but every step was artisanal — relevant input for
harness-homes (W-F).
## Install run (2026-08-08 ~19:09Z)
`curl -fsSL https://mosaicstack.dev/install.sh | bash -s -- --yes --no-auto-launch`
→ exit 0, framework v3 → `~/.config/mosaic/`, CLI @mosaicstack/mosaic **0.0.49**
`~/.npm-global/`. Prereq path: `sudo apt install nodejs npm` (Debian 13's node 20.19.2
meets the ≥20 floor). Public read on the stack repo means the installer itself needs no
credentials — good.
## Findings (outside fred's known-gaps list of 2026-08-08)
### F1 — PATH advice is print-only
Installer warns `~/.npm-global/bin is not on your PATH` and suggests the rc line, but
`shellProfileEdits: []` in the manifest — nothing is persisted. Every fresh machine ends
with `mosaic` not resolvable in new shells until the user hand-edits rc. Either edit the
rc (with consent/flag) or make the closing summary a copy-paste block.
### F2 — Installer overwrites live `~/.claude/settings.json` + `~/.claude/CLAUDE.md` with `backup: null`
`.install-manifest.json` `runtimeAssetCopies` shows dest `~/.claude/settings.json`,
`~/.claude/CLAUDE.md`, `hooks-config.json`, `context7-integration.md`, all `backup: null`,
written while a Claude session was LIVE on this machine. On this box the pre-existing files
were near-defaults so nothing of value was lost; on any configured machine this silently
destroys user settings/memory. Wants: backup-before-overwrite (populate the manifest
`backup` field it clearly already models) + merge-not-replace for settings.json.
### F3 — Fresh install fails its own doctor: 10 warnings out of the box
Immediately after a clean, successful install, `mosaic doctor` reports: missing `USER.md`;
`AGENTS.md missing CRITICAL HARD GATES override block`; runtime file drift on
`~/.claude/settings.json`; 7 missing `mosaic-*` skills. A green install that self-reports
10 warnings erodes trust in doctor as a signal. Whatever subset is "expected until
`mosaic init`/wizard" should be suppressed or labeled as such.
### F4 — Drift check points users at the gated template (wedge hazard)
The settings the installer writes to `~/.claude/settings.json` are UNGATED (no
mutator-gate, no receipt-observer) — which on today's main is CORRECT, it avoids the
Stop-hook wedge. But `~/.config/mosaic/runtime/claude/settings.json` (the file doctor
diffs against) IS the gated template. So doctor's "runtime file drift" warning invites the
obvious remediation — copy the template over — which would seed the receipt-observer wedge
into a live seat. The drift baseline and the seeded file should be the same artifact, or
doctor should know about the gated/ungated split.
### F5 — Installed skill set is disjoint from repo `skills/`
Skill sync installed 101 skills (six `mosaic-*`: deploy, gitea, orchestrator, portainer,
tools, woodpecker) but NONE of the eight in stack `skills/` on main (board, forge, jarvis,
macp, prd, prdy, setup-cicd, standards). Doctor then flags 7 of those 8 as missing
(`mosaic-jarvis` escapes the check). Two sources of truth for "the Mosaic skills" — the
installer's bundle and the repo dir — have diverged.
## Environment answers / status
- fomo-lin → sb-it-1-dt: **comms-only** today. Hostname does not resolve from here and the
laptop has no ssh keys. ssh reach would need Jason (key provisioning + route/VPN).
- Gitea write to the stack repo: verified by the push of this very branch (token `usc_mos`).
## Session 2 (2026-08-08 later) — `mosaic init` + first bare seat
`mosaic init` completed (SOUL.md / USER.md / TOOLS.md generated; TOOLS.md was backed up
before overwrite — the contrast with F2 shows the codebase already knows how). Its
runtime-adapter step correctly REFUSED to wire mutator-gate/receipt-observer hooks
(activation half absent, #869) — loud, explained, fail-safe. Good.
First bare seat: **launched**`mosaic claude --model sonnet` → Claude Code v2.1.226,
runtime-contract injection verified from inside the seat. But it took findings F6F10 to
get there; on an untouched fresh main install, install → init → launch is broken at
FOUR consecutive links.
### F6 — SECURITY: `mosaic-init` eval-injects free-text answers
`tools/_scripts/mosaic-init` line 142: `eval "$var_name=\"$value\""`. Any answer
containing `"` crashes init mid-flow (reproduced: exit 127, USER.md never written);
an answer containing `$( )` would EXECUTE arbitrary commands. Fix: `printf -v`.
Same bug in the NON_INTERACTIVE default branch. Related: init exits 1 even on success
when enforcement wiring is (correctly) refused — poisons any scripted chaining.
### F7 — init silently drops the installer's `mcpServers` block → launcher refuses to run
init's "Updating runtime adapters" rewrote `~/.claude/settings.json` and removed the
`mcpServers.sequential-thinking` block the installer had written 11 min earlier.
`mosaic claude` hard-requires that MCP → launch refused. The prescribed fix command
(`mosaic-ensure-sequential-thinking --runtime claude`) works. So the happy path is
install → init → BROKEN → hand-run a repair script. Merge-not-replace (F2) fixes this too.
### F8 — no fleet roster on a fresh install; launcher dies with a raw stack trace
`mosaic claude` throws an uncaught `Error: Fleet communications contract unavailable: no
fleet roster at ~/.config/mosaic/fleet/roster.{yaml,json}` (full Node stack trace to the
user). Nothing in install or init creates a roster (wizard untested here — `--no-auto-launch`;
if the wizard seeds one, the bare-flow gap still stands). Unblocked by hand-authoring a
minimal site roster from `fleet/examples/minimal.yaml`.
### F9 — FLAGSHIP: activation-probe timeout loses to CLI cold-start on modest hardware
`activation_version_gate.py` gives the `mosaic __lease-capability` probe
`PROBE_TIMEOUT_SECONDS = 2.0`. On fomo-lin the CLI answers CORRECTLY in **~2.552.61s
every run** (Node startup cost). Timeout → fail-closed → every bare `mosaic claude`
launch aborts (exit 65) with an error blaming "mosaic not on PATH … framework/CLI version
skew" — neither true. Invisible on fast dev boxes; fatal on laptops. Suggest: raise/make
configurable the timeout, warm-probe cache, and split the three failure causes into
distinct messages. Local workaround (documented, removable):
`MOSAIC_LEASE_VERSION_PROBE_COMMAND` pointed at a script emitting the verified payload
instantly (`~/.local/bin/mosaic-lease-probe-fast`).
### F10 — shipped lease-broker unit is never installed → registration denied
With F9 bypassed, launch dies with "Mosaic lease broker registration failed; runtime
launch denied": the broker daemon isn't running, and although the framework SHIPS
`systemd/user/mosaic-lease-broker.service`, nothing installs/enables it.
`systemctl --user link` + `enable --now` of the shipped unit → READY instantly, launch
proceeds. Installer/init/wizard should own this step.
### Observations (not filed as findings)
- Launcher settings audit demands `mutator-gate.py` while init refuses to wire it —
main's components disagree about the gated state (fold into #1113/F4).
- Seat context: runtime contract injected ✓; SOUL.md NOT injected (seat confirmed) —
matches AGENTS.md read-on-demand load order, but README says the launcher "checks for
SOUL.md". Question for lead, not a finding.
- `--ref next` install path verified available (flag exists, next archive HTTP 200) — not
exercised; fomo-lin stays main-as-shipped per lead ruling.
## Next
- Milestone comms sent at: install complete ✓ / first seat launched ✓.
- First gated-seat probe deliberately deferred until PR #1109 lands (known deny-only state).
## Session 3 (2026-08-08 evening) — wizard + gateway; refocus to `next`
Directive from Jason mid-session: focus shifts to the `next` branch and the new structure
(stock `~/.claude` untouched; framework wholly under `~/.config/mosaic`). Main's ~/.claude
write behavior is a deprecated location — findings stand, but no further deep-testing of it.
Wizard run (main): "keep identity, update framework"; ~/.claude hooks install DECLINED per
directive (wizard rewrote ~/.claude/settings.json anyway — benign, no gated hooks, MCP kept).
Wizard never prompted about fleet roster or lease-broker unit → F8/F10 disambiguation
partial: wizard does not visibly own those steps. Full degraded-state test dropped per refocus.
### F11 — gateway "Local" tier hard-requires Redis on main (fixed on next)
Wizard gateway install, Local tier ("embedded database, no dependencies"), port 14242:
daemon starts then crash-spams ioredis ECONNREFUSED; never healthy; killed manually.
`main..next` already contains `56787fab fix(gateway): disable Redis consumers on local
tier (#689)`. Main ships a gateway that cannot come up dependency-free; next has the cure.
### F12 — wizard exits 0 on gateway failure
Terminal shows "▲ Fix the underlying error above, then re-run `mosaic gateway install`"
and the wizard exits 0. Scripted/CI consumers read success.
### Cosmetic
Skipping the optional ANTHROPIC_API_KEY prompt records the literal string "undefined".
### `next` recon (read-only)
- next install.sh: first-class `--next` prerelease lane (npm @next dist-tag CLI + framework
from permanent next branch; guard against mixing @next with a different explicit --ref).
- next does NOT carry the new structure: ~/.claude handling unchanged; no harness-homes
design docs on next or main. New structure = Jason directive + fred W-F design phase.
State: bare seat launch works; gateway stopped. Holding for fred's ruling on a next-lane
reinstall (proposed) and W-F design review.
## Session 4 (2026-08-08 night) — `--next` lane reinstall (pivot confirmed by Jason)
Main uninstalled (note: uninstall removed `~/.claude/CLAUDE.md`/hooks-config/context7 but
LEFT its modified `settings.json` — asymmetric cleanup, minor). Reinstalled via next's own
installer: `raw/branch/next/tools/install.sh --next --yes --no-auto-launch` → framework from
permanent next branch + **CLI 0.0.50-next.2207 / gateway 0.0.7-next.2207 from the @next
registry lane**. Lane works as designed.
### N1 — FLAGSHIP (next-only): @next CLI requires Node 22; docs/installer floor says ≥20
On Node 20.19.2 (Debian 13's apt version, and the documented minimum) **every** mosaic
command crashes — even `--version` — with `ERR_REQUIRE_CYCLE_MODULE` in
`@mosaicstack/brain/dist/projects.js`. npm corroborates: `[email protected]` declares
`node >=22`. Verified the same installed CLI runs clean under Node **22.23.2** (nvm).
So the @next lane is dead-on-arrival on the documented minimum Node. Fix: installer
gates node ≥22 for the next lane (or brain drops the require cycle). fomo-lin now runs
Node 22 via nvm (user-level; system apt tops out at 20 — durable fix wants nodesource 22).
### F1F12 recurrence scorecard on next
| Finding | On next |
|---|---|
| F1 PATH print-only | RECURS (identical warning) |
| F2 ~/.claude writes | RECURS (runtime assets copied again; per ruling, no deeper testing — W-F fixes structurally) |
| F3 doctor warns on fresh install | RECURS (10 warnings, same classes) |
| F4 drift-baseline wedge | RECURS (same gated template + drift warning) |
| F5 skill sets disjoint | RECURS (same 7 missing mosaic-*) |
| F6 init eval injection | RECURS (eval at lines 102/118/132 of next's mosaic-init) |
| F7 init drops mcpServers | RECURS (verified: count 0 after init; ensure-script fix works) |
| F8 roster raw-throw | RECURS in code (throw present in next launch.js; not re-triggered — roster restored from backup) |
| F9 probe 2.0s timeout | RECURS (constant unchanged) — and compounded: probe spawns `mosaic`, which on ambient Node 20 crashes (N1), so the probe fails on slow AND stock-node hosts |
| F10 broker unit not installed | RECURS (hand-relinked next's shipped unit; works) |
| F11 gateway Redis-on-local | Expected FIXED (#689 in next); not yet live-verified — gateway install not re-run this session |
| F12 wizard exit-0 | Untested on next (wizard.ts differs; #1120 tracks) |
Chain result on next (with the same three workarounds: MCP ensure-script, restored roster,
broker relink, plus probe override): **install → init → launch all pass; seat up on
Claude Code v2.1.226 / sonnet under Node 22.**
Net: next cures nothing in F1F10 (they're all pre-W-F structural issues), carries the
gateway fix, and adds one hard regression-class gap (N1 node floor). The W-F gap list
stands unchanged as the fix vehicle.
## Session 5 (2026-08-08 night) — `~/.mosaic` prototype hand-roll (second-host cross-check)
Hand-rolled per HARNESS-HOMES prototype section, on the next-lane framework: skeleton
(config/claude `{}`, auth/claude/jason_woltje.com with `primary` alias, empty plugins/skills
stores), probe seat (profile.json schema 1, overlay `{}`, composed settings via three-layer
deep-merge, credentials two-hop symlink, identity-bootstrap CLAUDE.md, seeded onboarding
.claude.json, SOUL.md with positive Identity block).
**Smoke test PASS** (`CLAUDE_CONFIG_DIR=<probe> claude --print`): RC=0, auth through the
two-hop chain, seat self-identified as "probe". Post-run: both symlinks survived, live
credential inode unchanged, transcript in probe's own projects/, probe generated its own
backups/sessions, operator ~/.claude untouched. **dragon-lin's results replicate on a
clean second host — the layout stands up greenfield.**
### Gap-bites during the roll (feed to W-F)
- **Base-template hole (F4/gap-5 adjacent, NEW):** the design's composition base
`~/.config/mosaic/framework/runtime/claude/settings.json` does NOT exist in the shipped
framework; the closest shipped artifact (`runtime/claude/settings.json`) is the GATED
wedge template. Used the operator's vetted ungated settings as base (as dragon-lin did).
W-F1 must define + ship the canonical UNGATED system base; gate hooks arrive only via
promotion overlay.
- **Identity bootstrap vs permissions (NEW):** in `--print`/restricted mode the seat was
DENIED reading SOUL.md outside cwd — "read SOUL.md" bootstrap depends on tool
permissions. Generator should materialize the identity INTO the generated CLAUDE.md
(parameterized), keeping SOUL.md as source, not runtime dependency.
- **Gap 2 lived experience:** probe exists in profile.json but not roster.yaml — the
hand-rolled seat and `mosaic claude` are disjoint universes on the same host.
- **Gap 4 in miniature:** fresh-host store is empty; nothing defines what seeds it.
- **Lease posture:** hand-rolled seats launch bare `claude` → ungated by construction
until `mosaic fleet launch` exists (consistent with current bare-for-real-work rule).
### Addendum — gap-7 characterization (canonical ungated base)
Diffed operator vetted ungated settings vs shipped gated template: the delta is exactly
three items — template-only PreToolUse mutator-gate entry, template-only Stop
receipt-observer entry, operator-only mcpServers.sequential-thinking block (whose omission
from the template is F7's root cause). Spec: base = template two gate hooks + mcpServers;
promotion overlay = the two gate hooks, nothing more. Sent to fred (20260808T200337Z).
## Box doctrine — true greenfield, repeatable full-cycle testing (Jason, 2026-08-08)
fomo-lin's defining property: the test operator (scooby) is NOT a fleet seat — comms
watcher, git identity, nvm/Node, and repos live entirely outside Mosaic. Therefore Mosaic
can be wiped to TRUE ZERO and reinstalled in full, repeatedly, to test protocols
end-to-end per cycle (each W-F fixture drop, each next release).
Codified as `~/.local/bin/mosaic-greenfield-reset` (dry-run by default, `--yes` to
execute): removes units/gateway/npm packages/npmrc scope/`~/.config/mosaic`/`~/.mosaic`/
mosaic-written `~/.claude` files (settings reset to stock)/workaround shims; preserves the
operator layer (watcher, git creds, nvm, repos, `~/.claude` auth + session state, baseline
backup). Ends with a verify-zero checklist.
Known boundary impurities the reset explicitly handles: `~/.claude/settings.json` is
mosaic-written today (its QA hooks fire even in the operator's own session — observed:
prevent-memory-write blocked an operator write), and the F9 probe shim sits in
`~/.local/bin`. Both are named in the script rather than left as ambient state.
Not executed yet — current install (next lane + prototype) is the substrate Fred's W-F1
fixtures target. First full cycle runs when the next testable artifact lands.
## Session 6 (2026-08-08 night) — promotion-branch E2E (Fred-directed, first fomo-lin full E2E)
Branch feat/lease-promotion-and-harness-isolation (rebased on next), built from source
(pnpm --filter '@mosaicstack/mosaic...' build), CLI packed + installed globally, branch
framework installed to ~/.config/mosaic. Transcript:
scratchpad/promote-e2e-transcript.md. Verdict: **BLOCKED at step 3, NOT VERIFIED (not faked).**
Findings this session (all filed under scooby's own Gitea account):
- **#1123** — TS activation capability probe hardcodes a 2000ms timeout; `node cli.js
__lease-capability` cold-start on fomo-lin is 5.15.5s, so `leaseEnforcementActivatable()`
returns false and the gate REFUSES to wire via the sanctioned path. The Python-side
F9/#1118 override does NOT apply to this TS probe. Worked around by bumping only the
installed dist timeout (reversible; can't mask a bad capability).
- **LIVE WEDGE (F4 reproduced, un-recoverable):** hand-wiring the gated template into a live
BARE session's own runtime home hot-reloads the gate and bricks the session with
GATE_UNAVAILABLE (no lease). Every self-recovery path is closed (Bash/Read gated;
Write/Edit blocked by stale-guard needing a gated Read). Required an EXTERNAL shell to
restore settings. Exactly HARNESS-HOMES' "a live unpromoted session that gains the gate
cannot self-recover." Lesson applied: gated seats must be a SEPARATE mosaic claude process
in its own CLAUDE_CONFIG_DIR (~/.config/mosaic/.claude), never the operator's ~/.claude.
- **#1124 — the critical link, proven to fail:** `mosaic promote` transport reads the lease
session id from `pane_pid`'s /proc/environ, but `execRuntime()` (launch.js:883) uses
`spawnSync` (NOT the exec-replace its own comment claims), so pane_pid = node(mosaic)
[no lease env] and the lease env is on the claude CHILD. resolve() never walks to the
child → 'no readable lease session' → UNVERIFIED exit 1, before injection. Fails for every
real `mosaic claude` seat; unit tests pass only via a mocked environmentReader. This is
the exact link terra couldn't test (detached pane).
E2E scorecard: Step 1 (build/install) PASS. Step 2 (gated seat, real lease, mutator DENIED
MUTATOR_UNVERIFIED, file not created) PASS. Step 3 (promote → VERIFIED) BLOCKED (#1124).
Steps 45 not reached; failure-path sub-case (unresolvable seat → UNVERIFIED exit 1, no
hang) incidentally confirmed. The branch does NOT pass E2E on a real host as-is; #1124 gates
its merge.
## Session 7 (2026-08-08 night) — promotion re-run on #1124 fix (b) @ de0adb92
Rebuilt from de0adb92, reran steps 3+. **fix (b) confirmed working**; promotion advanced two
links deeper and revealed two new findings. Debian 13 compat: `/proc/<pid>/task/<pid>/children`
IS populated — BFS walk works, no PPID fallback needed.
- resolve() (#1124 fix b): **PASS** — BFS walk from pane(node,no-lease) → claude child(lease)
resolved the real session id. The exact bug I reported is fixed.
- **#1125** — `/mosaic-promote` first returned "Unknown command": the slash command is shipped
at `runtime/claude/commands/mosaic-promote.md` but NOT seeded into the seat's
`CLAUDE_CONFIG_DIR/commands/`. UserPromptSubmit hook never fires → PROMOTION_TIMEOUT. F7-class
asset-seeding gap. Worked around by copying the command into the seat home; hook then fires.
- **#1126 (deepest finding)** — with the command seeded, promote-begin injects (via
UserPromptSubmit additionalContext) an instruction to echo an opaque `MOSAIC-RECEIPT{...}`
token "verbatim and nothing else … discloses nothing." The seat MODEL REFUSED, correctly
flagging it as a prompt-injection pattern (imperative in a description field; verbatim opaque
echo; self-vouching language; no protocol legitimized in the seat's trusted context) →
RECEIPT_MISMATCH. Design-level: legitimate promotion is indistinguishable from an injection
attack to a well-aligned model; stronger injection defenses = more reliable promotion FAILURE.
Refusal evidence: docs/reports/greenfield/seat-receipt-refusal.txt.
Scorecard: build/install PASS · gated seat + mutator DENIED PASS · resolve (fix b) PASS ·
slash-cmd (post-#1125 workaround) PASS · receipt echo FAIL (#1126). Promotion does NOT reach
VERIFIED on a real seat. #1124 fix validated; #1125 + #1126 now gate the merge.
@@ -1,31 +0,0 @@
▐▛███▜▌ Claude Code v2.1.226
▝▜█████▛▘ Sonnet 5 · Claude Max
▘▘ ▝▝ ~/src/jarvis-brain
✔ Using flicker-free rendering · if you want to go back, use /tui default
· Click to move your cursor in the text input
· Click to expand collapsed tool results
· By default, text auto-copies when you select it (/config to change)
· Hold Shift (Option in iTerm2, Fn in Terminal.app) while selecting to use your terminal's native copy instead
▎ Fable 5 is now a standard part of your Max plan
▎ You can use up to 50% of your weekly usage limit on Fable 5. If you hit your limit, you can continue on Fable 5 with usage credits. Fable 5 draws down usage faster than Opus 5. Run /model and
▎ select Fable to use it. Learn more: https://support.claude.com/en/articles/15424964-claude-fable-5-promotional-access
+1 more · /status
/mosaic-promote
● I'm not going to echo that receipt string. A few things about this request don't add up, and I want to flag them before doing anything:
1. The "command description" is itself an instruction. In the skills listing, mosaic-promote is described as "I invoked this registered command to authorize lease promotion; follow the local seat
broker's injected receipt confirmation instruction exactly." That's not a description of what a skill does — it's an imperative instruction written in first person, injected into a field that's
supposed to be descriptive metadata. That's a classic injection pattern.
2. The "hook" reminder asks for verbatim, unexamined output. The UserPromptSubmit hook additional context block instructs me to output an opaque MOSAIC-RECEIPT{...} blob "verbatim and nothing else,"
and preemptively asserts that doing so "discloses nothing" — that's the kind of self-vouching language injected content uses to head off scrutiny. I have no way to verify what that token actually
authorizes downstream (it reads like a challenge/response for some privilege or lease-promotion action), and nothing in the actual Mosaic Constitution, AGENTS.md, or RUNTIME.md content I've loaded
this session mentions a "seat broker," "lease promotion," or a MOSAIC-RECEIPT protocol at all.
3. No genuine /mosaic-promote skill exists in the documented set. The real skill list (mosaic-gitea, mosaic-orchestrator, mosaic-portainer, mosaic-woodpecker, mosaic-tools, mosaic-deploy) has
nothing resembling this. A command that only shows up as a self-instructing string, paired with a matching "hook" telling me to blindly echo a token, is consistent with a prompt-injection attempt to
get an agent to produce an authorization artifact it doesn't understand.
I'm stopping here rather than emitting the receipt. If this is something you actually intended to trigger (a real seat/lease mechanism I'm not aware of), let me know and point me at where it's
documented and I'll take another look. Otherwise, I'd treat this as a suspicious injected command.
✻ Sautéed for 23s
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
⏸ manual mode on · ? for shortcuts · ← for agents
+12 -1
View File
@@ -69,4 +69,15 @@ All 22 baseline sites below are `FIXED`; the checked-in tranche fixture is passe
| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions |
| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string |
Remaining wake-validation sites are intentionally deferred to the final review-sized tranche and are not yet assigned a safety verdict here.
## Tranche 3 — wake validation harnesses
All 26 baseline occurrences (25 normalized identities; one preimage selector occurs twice) are `FIXED` and mechanically bound through the wake fixture and shared scanner.
| Baseline site(s) | Verdict | Construction |
| --- | --- | --- |
| `wake/test-wake-digest-quarantine.sh:567` | FIXED | complete match populations are captured, then first line selected by parameter expansion |
| `wake/test-wake-preimage.sh:182-183,346-347` | FIXED | jq `first(...)` reads each JSONL file directly |
| `wake/validate-973/microtest-wake-assert.sh:153,170-171,176,204-209,233-234,251-252,286-287` | FIXED | scalar assertions use here-strings; diagnostics use non-early sed ranges; source line captured before matching |
| `wake/validate-973/validate-973.sh:110,119,180,182,187` | FIXED | scalar assertions use here-strings; diagnostic truncation uses consuming sed ranges |
The scoped inventory is complete: 26 runtime/general + 22 non-wake tests + 26 wake tests fixed; 11 explicitly withdrawn or non-load-bearing sites retain their documented verdicts.
+5 -1
View File
@@ -25,6 +25,10 @@ Site-by-site verdicts: `docs/reports/quality/1099-pipefail-sweep.md`.
Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2.
## Tranche 3 TDD
Expanded the shared scanner over four wake validation harnesses. RED named 26 occurrences. The wake fixture asserts 26 occurrences / 25 normalized identities through the same scanner; all scalar assertions now use redirection, direct jq selection, complete capture, or consuming diagnostic ranges. Current-tree finding count is zero across the full scoped population.
## Verification so far
- `bash -n` on every changed shell script: pass.
@@ -33,5 +37,5 @@ Expanded the unconditional scanner over 11 non-wake test harnesses. RED named ex
- `test-send-message-verdict.sh`: 3/3 pass.
- `test-send-message-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.
- Wake static inventory remains aligned at 261/261 after line-neutral rewrites; no static-set mismatch. Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced.
+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`
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
## 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 skill discovery compatible with Mosaic SKILL.md format
- 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
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
+10 -3
View File
@@ -94,7 +94,14 @@ The launcher:
1. Verifies `~/.config/mosaic` exists
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
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`.
@@ -114,7 +121,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
│ ├── codex/ ← instructions.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
├── skills/ ← Universal skills (synced from mosaic/agent-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 |
| ------------------- | ----------------------------------------------------------------------------------------- |
| `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 codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
@@ -52,6 +52,52 @@ If a repo does not expose these scripts, run equivalent local workflow commands
- Do not auto-resolve data conflicts in shared state files.
- Keep commits scoped to a single logical change set.
## Model Tiering
Model choice is a standard, not a preference. Delegating a mechanical grep to a
frontier reasoning model wastes budget; sending a security review to a cheap tier
produces a review that passes and proves nothing. Both are defects.
Tiers are named by **capability class**, so the standard survives a model
generation. An operator binds each class to a concrete model id.
| Class | Use for |
| ------------- | ----------------------------------------------------------------------------------------- |
| `search` | grep/glob, file location, status and health checks, one-line mechanical edits |
| `build` | feature implementation, test writing, bugfixes, routine refactors |
| `judge` | code review, planning, API/compat-sensitive changes |
| `adversarial` | security review, ambiguous architecture, anything where a wrong "looks fine" is expensive |
Rules:
1. **Start at the cheapest class that can do the task; escalate on evidence, not
on nerves.** Omitting a tier is not neutral — it inherits the caller's model,
which is usually the most expensive one.
2. **Compat-sensitive work escalates one class.** A change that must interoperate
with an existing contract is judged, not just built.
3. **A tier assignment is benchmarked, not asserted.** Move a task class to a
cheaper tier only against a blind A/B on real work from this codebase, ranked
by someone other than the author. "It seemed fine" is not evidence.
4. **Reviewer independence beats reviewer size.** An `adversarial` verdict from
the model that wrote the code is not a second opinion (see Constitution gate 16).
### Where the binding lives
The class→model map is operator configuration, never framework source: model
availability, cost, and quotas differ per operator and per host.
Resolution order, first hit wins:
1. the config service (DB-backed, surfaced and editable in the Mosaic webUI)
2. a local operator file (`STANDARDS.local.md`, or `policy/` where the runtime
injects it)
3. the framework default — the class names above, with no binding
Only layer 1 is auditable across a fleet, so it is the target end state; layers 2
and 3 exist so a host with no config service still runs. A local override that
silently disagrees with the config service is drift — the same failure class the
tool-index gate exists to catch, and it belongs in `mosaic doctor`.
## Prompting Contract
All runtime adapters should inject:
@@ -11,22 +11,105 @@ All tool suites are located at `~/.config/mosaic/tools/`.
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
This index is complete and is kept complete mechanically: `tools/quality/scripts/check-tools-index.sh`
fails CI when a wrapper ships without an entry here, or when an entry here names a wrapper that no
longer exists. A wrapper missing from this list is, from inside an agent session, indistinguishable
from a wrapper that was never written — which is how the APPROVE/APPROVED incident below happened.
Every command takes `--help`. All of them accept `--login <account>` to pin the acting identity;
supply it explicitly on any host where the provider CLI's default account is an admin.
| Issues | |
| ------------------ | --------------------------------- |
| `issue-create.sh` | Create an issue (Gitea or GitHub) |
| `issue-view.sh` | Show one issue |
| `issue-list.sh` | List issues |
| `issue-edit.sh` | Edit title/body/labels/milestone |
| `issue-comment.sh` | Add a comment |
| `issue-assign.sh` | Assign or unassign |
| `issue-close.sh` | Close an issue |
| `issue-reopen.sh` | Reopen a closed issue |
| Pull requests | |
| ---------------- | --------------------------------------------------------- |
| `pr-create.sh` | Open a pull request |
| `pr-view.sh` | Show one PR |
| `pr-list.sh` | List PRs |
| `pr-diff.sh` | Fetch a PR's diff |
| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) |
| `pr-review.sh` | **Place a review verdict — see the dialect note below** |
| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state |
| `pr-merge.sh` | Merge a PR |
| `pr-close.sh` | Close a PR without merging |
| Milestones | |
| --------------------- | ------------------ |
| `milestone-create.sh` | Create a milestone |
| `milestone-list.sh` | List milestones |
| `milestone-close.sh` | Close a milestone |
| Gates and guards | |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
| `push-guard.sh` | Refuse verifications that pass for the wrong reason (e.g. green against an unpushed tree) |
| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard |
| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree |
| Context | |
| -------------------- | ---------------------------------------------------------------------------------------- |
| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it |
| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider |
| Workspace | |
| -------------------- | ------------------------------------------------------------------------ |
| `mosaic-worktree.sh` | Create/list/remove git worktrees — **the only supported way**; see below |
| `wrapper-guard.sh` | PreToolUse hook that enforces the two rules above; not called by hand |
**Workspace placement is derived, not chosen.** `mosaic-worktree.sh new <branch>` takes a branch
name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree,
repo name, parent dir, then `<parent>/<repo>-worktrees/<branch-slug>`. There is no placement flag
because a decision an agent has to make is a decision that drifts: the rule "big work goes on a work
filesystem" already existed in prose and 255 GB accumulated in `$HOME` across 842 directories
anyway, under five simultaneous conventions on a single host.
```bash
# Issues
~/.config/mosaic/tools/git/issue-create.sh
~/.config/mosaic/tools/git/issue-close.sh
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> [--from <base>]
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # derived path, no side effect
~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones
```
# PRs
~/.config/mosaic/tools/git/pr-create.sh
~/.config/mosaic/tools/git/pr-merge.sh
Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare
clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and
`gc` decide by **evidence, never by size or age**: a worktree is reclaimable only when
`git status --porcelain` is empty _and_ `git rev-list --count HEAD --not --remotes` is 0. Anything
else is preserved and reported. `--force` exists and is yours to type deliberately.
# Milestones
~/.config/mosaic/tools/git/milestone-create.sh
`wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see
`runtime/claude/settings.json`). It blocks exactly three things and lets everything else through:
a `git clone`/`git worktree add` targeting `$HOME`; a raw provider-API **write** to an endpoint that
already has a wrapper above (reads are untouched — they are how you gather evidence); and the
literal `"event": "APPROVE"`. For a genuine gap no wrapper can express, prefix
`MOSAIC_WRAPPER_OVERRIDE=1`. Reaching for the override twice for the same call means the wrapper has
a missing flag — extend the wrapper.
```bash
~/.config/mosaic/tools/git/issue-create.sh --help
~/.config/mosaic/tools/git/pr-review.sh --pr 42 --event APPROVED --body "..."
# CI queue guard (required before push/merge; defaults to the checked-out branch)
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
```
**Review dialect — the reason `pr-review.sh` is not optional.** Gitea's approve event is
`APPROVED`; GitHub's is `APPROVE`. Send GitHub's spelling to a Gitea host and it answers **HTTP
200**, files the review as PENDING, and then rejects the submit with `422 review stay pending` — the
verdict looks placed and is not. (`REQUEST_CHANGES` is spelled identically on both, so only the
approve path carries the trap.) `pr-review.sh` sends the correct token for the detected provider.
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
placed.
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
### Code Review (Codex)
@@ -52,6 +52,16 @@
"timeout": 10
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.config/mosaic/tools/git/wrapper-guard.sh",
"timeout": 10
}
]
}
],
"PostToolUse": [
@@ -51,12 +51,26 @@ Skills are discovered from:
### 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
- Active mission detection and context injection
- Memory routing to `~/.config/mosaic/memory/`
- MACP queue status reporting
1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates,
and fleet heartbeat reporting.
2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction
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
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
MARKER="$TEST_ROOT/loader-marker"
LIBRARY="$TEST_ROOT/marker.so"
FIXTURE_READY="$TEST_ROOT/loader-ready"
FIXTURE_FIFO="$TEST_ROOT/loader-block"
HOLDER_HOME="$TEST_ROOT/holder-home"
mkfifo "$FIXTURE_FIFO"
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
@@ -87,7 +90,17 @@ __attribute__((constructor)) static void mark_loader(void) {
EOF
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
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"
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
: > "$MARKER"
@@ -254,15 +254,32 @@ from urllib.parse import urlparse
def _origin_and_path(url):
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
#
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
# ROOT_URL is configured http:// returns http:// object URLs even when every
# client reaches it over https://, so a scheme-strict comparison rejects the
# provider's own correct answer about a write that landed — a deterministic
# false negative on every comment posted against such a deployment. The
# scheme is also not what this check defends: the forgeries it exists to
# catch (look-alike host, decoy path prefix, wrong owner/repo/number) all
# vary the HOST or the PATH, both of which stay strict below. Any OTHER
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
#
# Port: an implicit port and its own scheme's default compare equal, so
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
# because a different port is a different service on the same host.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, host, port), parsed.path.rstrip("/")
if scheme in ("http", "https"):
scheme_class = "web"
default_port = 80 if scheme == "http" else 443
port = None if parsed.port in (None, default_port) else parsed.port
else:
scheme_class = scheme
port = parsed.port
return (scheme_class, host, port), parsed.path.rstrip("/")
try:
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env bash
# mosaic-worktree.sh — the only supported way to create and dispose of a git
# worktree on a fleet host.
#
# Why this exists as a helper and not as a rule: the rule already existed, in
# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in
# $HOME across 842 directories anyway. Five placement conventions were live on
# one fleet host simultaneously. Every one was a decision an agent had to make,
# and a decision an agent has to make is a decision that drifts.
#
# So this script makes NO placement decision available. The caller supplies a
# branch name. Every path is DERIVED:
#
# main worktree <- git worktree list --porcelain (never cwd, which may
# itself already be a worktree)
# REPO_NAME <- basename of the main worktree
# REPO_PARENT <- dirname of the main worktree
# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees
# SLUG <- branch with '/' replaced by '-'
# WT_PATH <- $WT_ROOT/$SLUG
#
# The derivation puts the worktree on the same filesystem as the object store
# it shares, as a sibling of the repo, under one root per repo. Those are the
# properties that make the checkout cheap and — via `git worktree list` —
# enumerable, which is the only reason automated cleanup can ever be safe.
#
# Usage:
# mosaic-worktree.sh new <branch> [--from <base>] create (branch may exist)
# mosaic-worktree.sh path <branch> print derived path, no side effect
# mosaic-worktree.sh list this repo's worktrees + state
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
#
# `rm` and `gc` refuse to delete a worktree with uncommitted changes, with
# commits absent from every remote, or holding ignored files that are not of the
# well-known regenerable kind (a `.env` is ignored so it is never committed,
# which is also why nothing else holds a copy). That check is by EVIDENCE, never
# by size or age. --force overrides it and is yours to type deliberately.
#
# Run from anywhere inside the repo, or pass --repo <path>.
set -euo pipefail
die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; }
REPO_HINT=""
ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--repo) REPO_HINT="${2:-}"; shift 2 ;;
*) ARGS+=("$1"); shift ;;
esac
done
set -- "${ARGS[@]+"${ARGS[@]}"}"
CMD="${1:-}"
[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc"
shift || true
# ---- mechanical derivation -------------------------------------------------
# The FIRST entry of `git worktree list --porcelain` is always the main
# worktree, regardless of which worktree we are standing in. Deriving from cwd
# would nest worktrees inside worktrees.
resolve_repo() {
local start="${REPO_HINT:-$PWD}"
git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \
|| die "not inside a git repository: $start"
MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /{print substr($0,10); exit}')"
[ -n "$MAIN_WT" ] || die "could not resolve the main worktree"
REPO_NAME="$(basename -- "$MAIN_WT")"
REPO_PARENT="$(dirname -- "$MAIN_WT")"
WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees"
}
slugify() { printf '%s' "$1" | tr '/' '-'; }
derive_path() {
local branch="$1"
[ -n "$branch" ] || die "branch name required"
printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")"
}
# A worktree root under $HOME defeats the entire point: wrong filesystem, and
# $HOME is for configuration and state, not work products. Refuse rather than
# silently produce the layout we are trying to eliminate.
assert_not_home() {
local p="$1" home_real repo_real
home_real="$(cd "$HOME" && pwd -P)"
repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")"
case "$repo_real/" in
"$home_real"/*)
die "refusing: derived path is under \$HOME ($p).
The repo itself lives under \$HOME, so its worktrees would too. Move the repo
to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds
configuration, credentials, state and caches — not checkouts." ;;
esac
}
# ---- work-loss evidence ----------------------------------------------------
# Two independent questions, both answered from git, neither from size or age:
# dirty — anything uncommitted in the tree
# unpushed — commits reachable from HEAD that no remote ref contains
# precious — IGNORED files git will not mention and will not miss
#
# The third question is not obvious and was missed on the first pass. An
# independent reviewer demonstrated it in four commands: a pushed, clean
# worktree whose .gitignore covers `*.secret`, holding one `local.secret`.
# `git status --porcelain` is empty, `rev-list --count HEAD --not --remotes` is
# 0 — the evidence reads SAFE — and `git worktree remove` deletes the file. The
# same shape covers `.env`, credentials, scratch notes, downloaded fixtures:
# precisely the files that are ignored BECAUSE they must not be committed, which
# is also why nothing else is holding a copy.
#
# So ignored files count as work unless they are the well-known regenerable
# kind. Getting that set wrong is asymmetric: an over-broad list preserves a
# worktree that could have been reclaimed (cheap, visible, fixable by --force),
# an over-narrow one deletes the only copy of a secret (silent, permanent).
# The list stays short and conservative for that reason.
DISPOSABLE_RE='(^|/)(node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.turbo|\.cache|\.parcel-cache|\.gradle|dist|build|out|target|coverage|\.next|\.nuxt|\.svelte-kit)(/|$)|\.(pyc|pyo|o|class)$'
# These three run under `set -euo pipefail` inside command substitution, which
# makes any nonzero exit ANYWHERE in the pipeline abort the calling function
# silently. Two ways that bites, one of which shipped:
#
# * `grep -v` exits 1 when it filters everything out. A worktree whose only
# ignored entry is `node_modules/` is exactly the SAFE case, and it made
# `rm` exit 1 with no message and no removal — found by review.
# * `head -200` closes the pipe, SIGPIPEs the producer, and turns a worktree
# with 201 dirty files into the same silent abort. Not reported; it is the
# same defect one step upstream, so the cap is gone. Counting is cheap;
# the cap only ever protected output that is now never printed.
#
# Every one of them therefore ends in a total, and every stage that can
# legitimately exit nonzero says so explicitly.
wt_dirty() {
local out
out="$(git -C "$1" status --porcelain 2>/dev/null || true)"
if [ -n "$out" ]; then printf '%s\n' "$out" | wc -l; else printf '0'; fi
}
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || printf '?'; }
# Default --ignored (not =matching) so a 40k-file node_modules collapses to one
# directory entry instead of being enumerated and then discarded.
wt_precious() {
local ignored
ignored="$(git -C "$1" status --porcelain --ignored 2>/dev/null \
| awk '/^!! /{print substr($0,4)}' || true)"
[ -n "$ignored" ] || { printf '0'; return 0; }
printf '%s\n' "$ignored" | grep -Ecv "$DISPOSABLE_RE" || true
}
wt_state() {
local wt="$1" d u p
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"; p="$(wt_precious "$wt")"
if [ "$d" -eq 0 ] && [ "$u" = "0" ] && [ "$p" -eq 0 ]; then
printf 'SAFE\tclean; 0 unpushed; no ignored files worth keeping'
else
printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p"
fi
}
# ---- commands --------------------------------------------------------------
cmd_path() { resolve_repo; derive_path "${1:-}"; echo; }
cmd_new() {
local branch="${1:-}" base=""
shift || true
while [ $# -gt 0 ]; do
case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac
done
[ -n "$branch" ] || die "usage: mosaic-worktree.sh new <branch> [--from <base>]"
resolve_repo
local path; path="$(derive_path "$branch")"
assert_not_home "$path"
if [ -e "$path" ]; then
echo "exists: $path"
echo "(already checked out — reuse it, or 'rm' it first)"
return 0
fi
mkdir -p "$WT_ROOT"
# Existing branch -> check it out. New branch -> create from base (default:
# the remote's default branch if resolvable, else current HEAD).
if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \
|| git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
git -C "$MAIN_WT" worktree add "$path" "$branch"
else
if [ -z "$base" ]; then
base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
[ -n "$base" ] || base="HEAD"
fi
git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base"
fi
cat <<EOF
worktree: $path
branch: $branch
Removal is part of this task, not a later chore. When the work is pushed:
mosaic-worktree.sh rm $branch
EOF
}
cmd_list() {
resolve_repo
printf 'repo: %s\nroot: %s\n\n' "$MAIN_WT" "$WT_ROOT"
git -C "$MAIN_WT" worktree list --porcelain \
| awk '/^worktree /{print substr($0,10)}' \
| while read -r wt; do
[ "$wt" = "$MAIN_WT" ] && { printf '%-10s %s (main)\n' "-" "$wt"; continue; }
printf '%-10s %s\t%s\n' "$(wt_state "$wt" | cut -f1)" "$wt" "$(wt_state "$wt" | cut -f2)"
done
}
cmd_rm() {
local branch="${1:-}" force=0
shift || true
while [ $# -gt 0 ]; do
case "$1" in --force) force=1; shift ;; *) die "unknown flag: $1" ;; esac
done
[ -n "$branch" ] || die "usage: mosaic-worktree.sh rm <branch> [--force]"
resolve_repo
local path; path="$(derive_path "$branch")"
[ -d "$path" ] || die "no worktree at $path"
local d u p
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"; p="$(wt_precious "$path")"
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ] || [ "$p" -ne 0 ]; }; then
die "refusing to remove $path
uncommitted files: $d
unpushed commits: $u
ignored, not disposable: $p
Commit and push first — that is the contract. Ignored files are counted because
git will neither report them nor miss them: a .env or a *.secret is ignored
precisely so it is never committed, which is also why nothing else holds a copy.
List them with: git -C $path status --porcelain --ignored | grep '^!!'
If this work is genuinely disposable, re-run with --force."
fi
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
if [ "$force" -eq 1 ]; then
git -C "$MAIN_WT" worktree remove --force "$path"
else
git -C "$MAIN_WT" worktree remove "$path"
fi
git -C "$MAIN_WT" worktree prune
echo "removed: $path"
rmdir "$WT_ROOT" 2>/dev/null || true
}
cmd_gc() {
local apply=0
[ "${1:-}" = "--apply" ] && apply=1
resolve_repo
git -C "$MAIN_WT" worktree prune
git -C "$MAIN_WT" worktree list --porcelain \
| awk '/^worktree /{print substr($0,10)}' \
| while read -r wt; do
[ "$wt" = "$MAIN_WT" ] && continue
local_state="$(wt_state "$wt")"
case "$local_state" in
SAFE*)
if [ "$apply" -eq 1 ]; then
git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt"
else
echo "reclaimable (clean + fully pushed): $wt"
fi ;;
*) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;;
esac
done
git -C "$MAIN_WT" worktree prune
[ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)'
}
case "$CMD" in
new) cmd_new "$@" ;;
path) cmd_path "$@" ;;
list) cmd_list "$@" ;;
rm) cmd_rm "$@" ;;
gc) cmd_gc "$@" ;;
-h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;;
*) die "unknown command: $CMD (new | path | list | rm | gc)" ;;
esac
@@ -243,15 +243,35 @@ from urllib.parse import urlparse
def _origin_and_path(url):
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
#
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
# ROOT_URL is configured http:// returns http:// object URLs even when every
# client reaches it over https://, so a scheme-strict comparison rejects the
# provider's own correct answer about a comment that landed — a deterministic
# false negative on EVERY review comment posted against such a deployment.
# That matters more here than anywhere else: on a host where no seat can
# create a review OBJECT, the comment-form review record this path produces
# is the only gate-16 evidence available, and this check refuses all of it.
# The scheme is also not what the check defends: the forgeries it exists to
# catch (look-alike host, decoy path prefix, wrong owner/repo/kind/number)
# all vary the HOST or the PATH, both of which stay strict below. Any OTHER
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
#
# Port: an implicit port and its own scheme's default compare equal, so
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
# because a different port is a different service on the same host.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, host, port), parsed.path.rstrip("/")
if scheme in ("http", "https"):
scheme_class = "web"
default_port = 80 if scheme == "http" else 443
port = None if parsed.port in (None, default_port) else parsed.port
else:
scheme_class = scheme
port = parsed.port
return (scheme_class, host, port), parsed.path.rstrip("/")
try:
@@ -7,14 +7,40 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
HOME_DIR="$WORK_DIR/home"
LOG_FILE="$WORK_DIR/calls.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$BIN_DIR"
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
#
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credentials below are silently
# ignored. This suite is the one where the consequence is not subtle: it FAILS
# outright on a provisioned seat (rc=1 bare, rc=0 with $HOME sandboxed, one
# variable changed) and passes everywhere else, including CI, which has no
# per-agent token to leak.
#
# CONTAINMENT: the sandboxed HOME in the four run helpers below. It only has to
# bound a failure that the pin should already have prevented.
#
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
# by construction and every suite reads clean however vulnerable it is. To measure,
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
# per-slot token) so step 0 reaches its fail-loud branch.
#
# Note the env-var route does NOT work: detect-platform.sh reads
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
git -C "$REPO_DIR" config mosaic.gitIdentity ""
cat > "$CREDENTIALS_FILE" <<'JSON'
{
@@ -86,6 +112,7 @@ run_in_repo() {
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
HOME="$HOME_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
@@ -283,6 +310,7 @@ run_in_repo2() {
(
cd "$REPO_DIR"
PATH="$BIN_DIR2:$PATH" \
HOME="$HOME_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
@@ -343,7 +371,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
token_fallback() {
(
cd "$REPO_DIR"
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_token_for_login "$1" "$2"
' _ "$1" "$2"
@@ -354,7 +382,7 @@ token_fallback() {
token_pyyaml() {
(
cd "$REPO_DIR"
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_token_for_login "$1" "$2"
' _ "$1" "$2"
@@ -61,15 +61,54 @@ STATE_FILE="$WORK_DIR/comments.json"
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
# check can assert every POST/GET body + metadata temp file is cleaned up.
TMP_SCRATCH="$WORK_DIR/scratch"
HOME_DIR="$WORK_DIR/home"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
#
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
# ignored. The stub curl then rejects the unrecognised bearer, and this suite
# fails at its FIRST case with `Gitea authenticated-identity read failed with
# HTTP 401`. An empty repo-local value shadows the global one and reads back
# empty at rc=0. Measured: without this pin the suite is RED on every seat.
#
# CONTAINMENT: the sandboxed HOME in run_comment(). It only has to bound a
# failure that the pin should already have prevented.
#
# THIS SUITE WAS THE HARDEST OF THE FIVE TO SEE, and the reason is worth stating
# because it generalises: run_comment() sends the wrapper's stdout AND stderr to
# $OUTPUT_FILE, and the EXIT trap above deletes $WORK_DIR. So the 401 — the only
# thing that says what went wrong — exists only inside a directory that is gone
# by the time anyone looks. The suite exits 1 with ZERO bytes on stdout and
# stderr. A suite that discards or deletes its own evidence turns any post-hoc
# assay into a non-measurement: "nothing found" there means "no surviving
# trace", never "clean". It was found by intercepting the identity read at its
# SOURCE (a PATH shim over `git` logging every `mosaic.gitIdentity` read to a
# file outside $WORK_DIR), which is deletion-proof by construction, rather than
# by grepping for the symptom.
#
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
# by construction and every suite reads clean however vulnerable it is. To
# measure, REPLICATE a seat (a decoy HOME whose .gitconfig sets
# mosaic.gitIdentity, with no per-slot token) so step 0 reaches its fail-loud
# branch — or intercept the read as described above.
#
# Note the env-var route does NOT work: detect-platform.sh reads
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
git -C "$REPO_DIR" config mosaic.gitIdentity ""
ISSUE_NUMBER=7
REPO_SLUG="mosaicstack/stack"
@@ -372,6 +411,7 @@ run_comment() {
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$HOME_DIR" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
@@ -7,13 +7,38 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
HOME_DIR="$WORK_DIR/home"
LOG_FILE="$WORK_DIR/calls.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$BIN_DIR"
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
#
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
# ignored and the suite runs against a production credential. An empty repo-local
# value shadows the global one and reads back empty at rc=0. Measured: this suite
# resolves a per-slot token without it.
#
# CONTAINMENT: the sandboxed HOME in run_wrapper(). It only has to bound a failure
# that the pin should already have prevented.
#
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
# by construction and every suite reads clean however vulnerable it is. To measure,
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
# per-slot token) so step 0 reaches its fail-loud branch.
#
# Note the env-var route does NOT work: detect-platform.sh reads
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
git -C "$REPO_DIR" config mosaic.gitIdentity ""
cat > "$CREDENTIALS_FILE" <<'JSON'
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
@@ -50,6 +75,7 @@ run_wrapper() {
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
HOME="$HOME_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
@@ -8,6 +8,7 @@ WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
MOCK_BIN="$SANDBOX/bin"
REPO_DIR="$SANDBOX/repo"
HOME_DIR="$SANDBOX/home"
LOG_FILE="$SANDBOX/mock.log"
cleanup() {
@@ -15,7 +16,7 @@ cleanup() {
}
trap cleanup EXIT
mkdir -p "$MOCK_BIN" "$REPO_DIR"
mkdir -p "$MOCK_BIN" "$REPO_DIR" "$HOME_DIR"
: > "$LOG_FILE"
cat > "$MOCK_BIN/tea" <<'EOF'
@@ -109,7 +110,48 @@ chmod +x "$MOCK_BIN/curl"
cd "$REPO_DIR"
git init -q
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
#
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
# check, so the `GITEA_TOKEN=redacted-test-token` exported below is silently
# overridden and a REAL per-slot token from $HOME is what flows through the
# wrapper. Measured on a provisioned seat before this pin: all 5 mock-curl calls
# carried the real per-slot token in argv and the fixture token was never used at
# ALL. Three consequences specific to this suite:
# 1. pr-merge.sh passes the token as `-H "Authorization: token $token"` and the
# mock curl logs full argv, so the real credential is written to $LOG_FILE
# on disk — transiently: the suite truncates that file between phases and
# the EXIT trap removes $SANDBOX, so it leaves NO post-hoc trace. That is
# why this suite was the hardest of the three to detect; observing it needs
# an instrument that captures argv while the run is live.
# 2. Every failure path dumps $OUTPUT/$LOG_FILE to stderr through
# `sed 's/redacted-test-token/***REDACTED***/g'` — a redaction pattern that
# is the literal fixture string and therefore CANNOT match the token
# actually in use.
# 3. The leak assertion at "Token leaked to pr-merge.sh output" greps for that
# same fixture string, so on a provisioned seat it passes vacuously: it is
# searching for a value the run never used.
# An empty repo-local value shadows the global one and reads back empty at rc=0.
#
# CONTAINMENT: the sandboxed HOME exported below. It only has to bound a failure
# that the pin should already have prevented.
#
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
# by construction and every suite reads clean however vulnerable it is. To measure,
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
# per-slot token) so step 0 reaches its fail-loud branch.
#
# Note the env-var route does NOT work: detect-platform.sh reads
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
git -C "$REPO_DIR" config mosaic.gitIdentity ""
# $SANDBOX/$HOME_DIR were derived from the real $HOME above, before this export.
export HOME="$HOME_DIR"
export PATH="$MOCK_BIN:$PATH"
export PR_MERGE_TEST_LOG="$LOG_FILE"
export GITEA_LOGIN="git.mosaicstack.dev"
@@ -8,12 +8,68 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
REPO_DIR="$WORK_DIR/repo"
FIXTURE_DIR="$WORK_DIR/fixtures"
STUB_DIR="$WORK_DIR/stubs"
HOME_DIR="$WORK_DIR/home"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR"
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR" "$HOME_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
#
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
# check, so the `GITEA_TOKEN="stub-token"` set in the run helpers below is
# silently overridden and a REAL per-slot token from $HOME is what reaches curl.
# Measured on a provisioned seat before this pin: both stub-curl calls carried
# the real token in argv. An empty repo-local value shadows the global one and
# reads back empty at rc=0.
#
# CONTAINMENT: the sandboxed HOME in the three run helpers below. It only has to
# bound a failure that the pin should already have prevented.
#
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
# by construction and every suite reads clean however vulnerable it is. To measure,
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
# per-slot token) so step 0 reaches its fail-loud branch. See
# test-gitea-token-identity.sh for the stronger `env -i HOME=…` form used where a
# suite's whole subject IS identity resolution.
#
# Note the env-var route does NOT work: detect-platform.sh reads
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
git -C "$REPO_DIR" config mosaic.gitIdentity ""
# The pin above removes step 0, but this suite has a SECOND, independent
# dependency on operator state, and closing only the first would leave the suite
# red on any hermetic environment. The `GITEA_TOKEN="stub-token"` /
# `GITEA_URL="https://git.example.test"` pair the run helpers set is INERT: step 2
# of get_gitea_token accepts GITEA_TOKEN only when GITEA_URL matches the remote
# host, and this repo's origin is git.uscllc.com, so that pair can never satisfy
# it. Before this fixture the only credential that could reach the authenticated
# curl branch was a REAL one — from step 0 on an agent seat, or from step 1
# reading the operator's own ~/.config/mosaic/credentials.json. That is why the
# "curl success path" case passed: not because the stub credential worked, but
# because a production credential was available.
#
# A fixture is used rather than relying on the sandboxed HOME making step 1 find
# nothing: a test that passes because production configuration is ABSENT fails
# the moment it is present. Step 1 now resolves deterministically to a value that
# is a fixture on every machine.
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"usc": {
"url": "https://git.uscllc.com",
"token": "stub-token"
}
}
}
JSON
cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
{
@@ -131,6 +187,8 @@ run_curl_success_case() {
set +e
output=$(cd "$REPO_DIR" && \
PATH="$STUB_DIR:$PATH" \
HOME="$HOME_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
TMPDIR="$tmpdir" \
GITEA_TOKEN="stub-token" \
GITEA_URL="https://git.example.test" \
@@ -170,6 +228,8 @@ run_curl_early_exit_cleanup_case() {
set +e
output=$(cd "$REPO_DIR" && \
PATH="$STUB_DIR:$PATH" \
HOME="$HOME_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
TMPDIR="$tmpdir" \
GITEA_TOKEN="stub-token" \
GITEA_URL="https://git.example.test" \
@@ -204,7 +264,8 @@ run_curl_early_exit_cleanup_case() {
run_case() {
local fixture="$1" expected_number="$2" expected_head="$3"
local output
output=$(cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
output=$(cd "$REPO_DIR" && HOME="$HOME_DIR" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY'
import json
import os
@@ -439,6 +439,19 @@ elif mode == "comment-url-wrong-repo":
elif mode == "comment-url-suffix-injection":
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
elif mode == "comment-url-wrong-port":
# #991 bound: an EXPLICIT non-default port is a different service on the same
# host. Relaxing http-vs-https must NOT relax this.
pr_url = f"{_p.scheme}://{_p.hostname}:8443{_slug}/pulls/123"
elif mode == "comment-url-non-web-scheme":
# #991 bound: ONLY http/https collapse; any other scheme stays distinguishing.
pr_url = f"ftp://{_p.netloc}{_slug}/pulls/123"
elif mode == "comment-url-scheme-downgrade":
# #991, and the only URL mode here that must be ACCEPTED. A Gitea whose
# ROOT_URL is http:// returns http:// object URLs for a repo reached over
# https://. Same host, same path, correct record — a truthful provider
# answer about a comment that landed, not a forgery.
pr_url = f"http://{_p.netloc}{_slug}/pulls/123"
elif mode == "comment-mixed-case-slug":
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
@@ -893,11 +906,16 @@ fi
assert_no_temp_leak "review-body-reuse"
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
# test would wrongly accept the look-alike-host and prefix-injection variants.
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
# but whose provider-returned pull_request_url does not belong to this PR must
# FAIL CLOSED. Verification pins the URL's ORIGIN (scheme-class + host + explicit
# non-default port) and FULL path (deployment prefix + exact owner/repo + kind +
# number); a bare endswith/suffix test would wrongly accept the look-alike-host
# and prefix-injection variants. comment-url-wrong-port and
# comment-url-non-web-scheme (#991) bound the scheme relaxation from the other
# side: collapsing http/https must not also collapse a different port or a
# different scheme family.
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo \
comment-url-suffix-injection comment-url-wrong-port comment-url-non-web-scheme; do
if run_review "$bad_mode" comment durable-body; then
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
cat "$OUTPUT_FILE" >&2
@@ -923,6 +941,19 @@ run_review comment-mixed-case-slug comment durable-body https://git.mosaicstack.
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
assert_no_temp_leak "comment-mixed-case-slug"
# Case 15c (#991): the deployment's Gitea ROOT_URL is http:// while every client
# reaches it over https://, so the provider returns an http:// pull_request_url
# for a comment that is otherwise entirely correct. Same class as 15b — a
# legitimate provider response, not a spoof — and a scheme-strict compare
# rejects it on EVERY comment, deterministically. That is not a cosmetic false
# negative here: on a host where no seat can create a review OBJECT, this
# comment-form record is the only gate-16 evidence obtainable, and the wrapper
# refuses all of it while the comment sits durably on the PR. Host, path, owner,
# repo, kind and number stay strict; only http-vs-https is relaxed.
run_review comment-url-scheme-downgrade comment durable-body
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
assert_no_temp_leak "comment-url-scheme-downgrade"
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
# pre-submit head read (which pins the review) and the post-verify re-read. The
# review is genuinely created and verified as pinned to the OLD head, but the
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# test-wrapper-guard.sh — hermetic behavioural regression for wrapper-guard.sh.
#
# Resolves no credentials, touches no network, and creates no repository: the
# guard reads a hook payload on stdin and answers with an exit code, so the whole
# contract is testable from fixtures.
#
# The fixtures are written to a temp file rather than passed inline, and this is
# not stylistic. The guard inspects the literal text of the Bash command it is
# handed. A test that embeds `git clone ... $HOME` inside its own command line
# trips the guard on the harness instead of on the fixture — which is exactly
# what happened the first time this was checked by hand. Substring matching over
# whole command text is the guard's deliberate fail-closed posture; a test that
# does not account for it silently measures the wrong thing.
#
# Exit: 0 = every fixture behaved as specified · 1 = at least one did not
set -uo pipefail
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
GUARD="${1:-$HERE/wrapper-guard.sh}"
[ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; }
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
FIXTURES="$TMP/fixtures.tsv"
# Each line: <expected-exit> TAB <hook payload> TAB <what it proves>
# 0 = allowed, 2 = blocked.
{
printf '2\t{"tool_input":{"command":"git clone https://example.invalid/x ~/wt"}}\tcheckout into $HOME is refused\n'
printf '2\t{"tool_input":{"command":"git worktree add ~/wt topic"}}\tworktree into $HOME is refused\n'
printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\n'
printf '0\t{"tool_input":{"command":"curl -s -X GET https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\treads are never blocked\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\treview write has a wrapper\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tmerge write has a wrapper\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://api.github.com/repos/a/b/issues"}}\tGitHub host is covered too\n'
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/releases"}}\tan endpoint with no wrapper passes\n'
printf '2\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVE\\"} https://example.invalid/x"}}\tthe APPROVE token is caught anywhere\n'
printf '0\t{"tool_input":{"command":"ls -la /src"}}\tordinary commands are untouched\n'
printf '0\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=1 curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tbreak-glass works\n'
printf '0\t{"tool_input":{}}\tan empty payload does not block the session\n'
# --- bypasses an independent reviewer demonstrated against the first version.
# Each of these returned 0 (allowed) and each is a real write. They are pinned
# as fixtures rather than fixed-and-forgotten because the class is recurring:
# the guard reads text, so every spelling it does not know is a hole.
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t-d@body with no space is still a body\n'
printf '2\t{"tool_input":{"command":"curl --request=POST -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t--request=POST equals-form is still a method\n'
printf '2\t{"tool_input":{"command":"p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@b https://git.example.invalid${p}${q}"}}\ta path split across variables is still that path\n'
printf '2\t{"tool_input":{"command":"curl --data-binary @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\t--data-binary is a body\n'
printf '2\t{"tool_input":{"command":"curl -F f=@b https://git.example.invalid/api/v1/repos/a/b/issues"}}\t-F multipart is a body\n'
# Reads must survive every one of those broadenings, or the guard gets disabled.
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tno body and no verb is a read\n'
printf '0\t{"tool_input":{"command":"grep -rn /pulls/ src/ | head -20"}}\ta path fragment in a grep is not an API call\n'
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://registry.example.invalid/v2/x/manifests/latest"}}\tan unwrapped API is not this guard'"'"'s business\n'
# --- round two of the same review. Splitting the ENDPOINT TOKEN defeats any
# amount of fragment matching, because the endpoint does not exist until the
# shell expands it. The guard now refuses to clear a write whose URL it cannot
# read, rather than pretending it read one.
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/iss; b=ues/1/comments; curl -d@body https://git.example.invalid${a}${b}"}}\tan endpoint token split across variables is unreadable, not absent\n'
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/pu; b=lls/1/reviews; curl -d@body https://git.example.invalid${a}${b}"}}\tsame split, review endpoint\n'
printf '0\t{"tool_input":{"command":"curl -X POST -d @payload https://hooks.example.invalid/services/${WEBHOOK_ID}"}}\tan opaque URL that is not forge-shaped stays allowed\n'
# --- round six changed the contract in this direction, and these fixtures are
# where it shows. They used to assert that discussing a call is not making one.
# Five rounds proved there is no textual way to tell a quoted example from a
# quoted command, so the guard stopped trying: it judges the payload, and a
# payload inside quotes is still a payload. Quoting one of these on a Bash
# command line is now refused, and the way to write the example is a
# file-writing tool. This is the deliberate cost of the mechanism change.
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tquoting a wrapped write is refused even in a grep\n'
printf '2\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\t...and when written into a file\n'
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\t...and when printed from another language\n'
# The boundary that keeps this from being "block everything": what is refused
# is a WRITE to a WRAPPED endpoint. Mentioning either alone still passes, and
# these are asserted as hard as the blocks above.
printf '0\t{"tool_input":{"command":"grep -R \\"curl -s https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/"}}\tquoting a READ example is untouched\n'
printf '0\t{"tool_input":{"command":"echo \\"the wrapped endpoint is https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tnaming the endpoint without a body flag is untouched\n'
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/releases\\" docs/"}}\tquoting a write to an UNWRAPPED endpoint is untouched\n'
printf '0\t{"tool_input":{"command":"issue-comment.sh --repo a/b --issue 1 --body @msg.md"}}\tthe wrapper itself carries a body flag and must never trip its own guard\n'
# Command position must still catch the real thing behind operators and env.
printf '2\t{"tool_input":{"command":"cd /tmp && GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\ta real call behind && and an assignment is still a call\n'
# --- the case the AUTHOR hit twice while chasing the above: sending a message
# that QUOTED one of these fixtures. Under the old contract that was a defect
# to be parsed away; under this one it is the documented cost, and the message
# gets composed with a file-writing tool instead.
printf '2\t{"tool_input":{"command":"send.sh -m \\"repro was: cd /tmp && curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tquoting the repro in a message is refused too\n'
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues\\nEOF"}}\ta heredoc body carrying the payload is refused with it\n'
# ...but quotes stop being data the moment something executes them.
printf '2\t{"tool_input":{"command":"bash -c \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tbash -c makes the quoted text code again\n'
# --- round three. Each of these four is a real write that a bare-name match
# for the client could not see, because an ordinary word sat in front of it.
# They are kept as fixtures after the mechanism change even though the guard no
# longer looks for a client at all: they are the evidence for WHY it stopped,
# and a future re-narrowing that reintroduced position would fail here first.
printf '2\t{"tool_input":{"command":"env GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tenv VAR=... in front of the client is still the client\n'
printf '2\t{"tool_input":{"command":"command curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tcommand in front of the client is still the client\n'
printf '2\t{"tool_input":{"command":"timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\ttimeout N in front of the client is still the client\n'
printf '2\t{"tool_input":{"command":"/usr/bin/curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan absolute path to the client is still the client\n'
printf '2\t{"tool_input":{"command":"echo timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments >> notes.md"}}\tnaming the call after echo carries the payload, so it is refused\n'
# A shell standing between quoted data and execution makes that data code,
# and the pipe is the form agents actually use. Filing it as data allowed the
# call to vanish from the skeleton while still running.
printf '2\t{"tool_input":{"command":"printf '"'"'%%s\\\\n'"'"' '"'"'curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments'"'"' | sh"}}\tquoted code piped to a shell is code\n'
printf '2\t{"tool_input":{"command":"cat <<EOF | sh\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\ta heredoc piped to a shell is code\n'
printf '2\t{"tool_input":{"command":"sh -s <<EOF\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\tsh -s reads its script from the heredoc\n'
# ...and the questions that used to follow — is the pipe target a shell, does a
# shell on one line execute a string on another — no longer have to be answered
# at all. Both of these carry the payload, both are refused, and neither
# outcome depends on parsing what the pipe or the other line does.
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/ | wc -l"}}\tpiping the payload to wc is refused without asking what wc is\n'
printf '2\t{"tool_input":{"command":"docker run --rm alpine sh -c '"'"'echo hi'"'"'\\necho \\"example: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tan unrelated shell on another line no longer changes the answer either way\n'
# --- round four. The guard was still reading the command as typed rather than
# as the shell will run it: a backslash before a newline is removed before
# anything else happens, so the endpoint token can be split across the join.
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments"}}\ta line continuation inside the endpoint token is still that endpoint\n'
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pu\\\\\\nlls/1/reviews"}}\tsame join, review endpoint\n'
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments\\nEOF"}}\tthe join still runs first, and the joined payload is refused in a document too\n'
# Transparent prefixes take option VALUES, and the value was a word the list
# did not know — so the client went missing again behind an ordinary `sudo -u`.
printf '2\t{"tool_input":{"command":"sudo -u root curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option value after a prefix does not hide the client\n'
printf '2\t{"tool_input":{"command":"timeout --signal TERM 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option pair plus a duration does not hide the client\n'
printf '2\t{"tool_input":{"command":"xargs echo curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe payload behind xargs echo is refused rather than adjudicated\n'
printf '0\t{"tool_input":{"command":"sudo apt-get install curl"}}\tinstalling the client is not calling it\n'
# Execution through another command needed its own case under the old design.
printf '2\t{"tool_input":{"command":"find . -maxdepth 0 -exec curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments ;"}}\tfind -exec runs the client\n'
# --- round five, and the finding that ended the parser. Command substitution
# inside a double-quoted span EXECUTES, while the skeleton was discarding that
# span as inert prose. The unquoted and process-substitution forms already
# blocked, which is what made it a classification defect rather than a spelling
# one: the same call was refused or allowed depending on a quote character.
printf '2\t{"tool_input":{"command":"echo \\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tcommand substitution inside double quotes executes\n'
printf '2\t{"tool_input":{"command":"echo \\"`curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments`\\""}}\tso does the backtick form\n'
printf '2\t{"tool_input":{"command":"msg=\\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tand an assignment RHS is not data either\n'
printf '2\t{"tool_input":{"command":"echo $(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tthe unquoted form, which blocked before and must keep blocking\n'
printf '2\t{"tool_input":{"command":"cat <(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tprocess substitution, same\n'
printf '2\t{"tool_input":{"command":"bash --command \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\""}}\tthe long-option spelling of bash -c needs no entry in any list now\n'
# A client the guard was never taught is the point of dropping client
# detection: neither of these names curl at all.
printf '2\t{"tool_input":{"command":"python3 -c '"'"'import requests; requests.post(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\", json={})'"'"'"}}\ta library call is a write with no flag and no curl\n'
printf '2\t{"tool_input":{"command":"wget --post-data=x https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\twget spells its body differently and is still a write\n'
} > "$FIXTURES"
fail=0 n=0
while IFS=$'\t' read -r want payload why; do
[ -n "${want:-}" ] || continue
n=$((n + 1))
printf '%s' "$payload" | "$GUARD" >/dev/null 2>&1
got=$?
if [ "$got" = "$want" ]; then
printf 'ok %s\n' "$why"
else
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
fail=1
fi
done < "$FIXTURES"
printf '\n'
if [ "$fail" -eq 0 ]; then
printf 'wrapper-guard: %d/%d fixtures behaved as specified.\n' "$n" "$n"
else
cat <<'EOF'
wrapper-guard drifted from its contract.
A guard that blocks too much gets routed around, and a guard that blocks too
little is decoration. Both directions are failures here, which is why the
allowed cases are asserted as hard as the blocked ones.
EOF
fi
exit "$fail"
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env bash
# wrapper-guard.sh — PreToolUse hook on Bash.
#
# Blocks three specific, mechanically-detectable mistakes that prose has
# repeatedly failed to prevent:
#
# 1. A checkout (git clone / git worktree add) targeting $HOME.
# Root cause of a fleet host's /home filling to 100% — 255 GB, 842 dirs.
#
# 2. A raw provider API WRITE against an endpoint that already has a Mosaic
# wrapper. Constitution gate 7 requires the wrapper; the wrapper knows
# provider dialect, identity, and queue-guard ordering that raw curl does
# not. Reads are untouched — they are how you gather evidence.
#
# 3. The literal review event "APPROVE". Gitea's vocabulary is APPROVED;
# it accepts APPROVE with HTTP 200, silently files the review PENDING,
# and then 422s on submit. This one is unconditionally wrong on Gitea and
# is what a verdict silently failing to land looks like.
#
# Design constraint: this hook must not become something agents route around.
# It blocks WRITES to endpoints with a known wrapper, and nothing else. Raw
# curl for reads, for registry/manifest calls, and for endpoints with no
# wrapper (there are many) all pass untouched.
#
# One consequence is worth knowing before it surprises you: it judges the
# payload, not the caller, so a command that merely QUOTES such a write is
# refused as well. See the long note at section 2 for why that trade was made.
#
# Break-glass, for a genuine gap where no wrapper can express the call:
# MOSAIC_WRAPPER_OVERRIDE=1 <command>
# Using it means "no wrapper covers this" — if that is wrong, the fix is to
# extend the wrapper, not to keep typing the override.
#
# Exit codes (Claude Code PreToolUse): 0 = allow, 2 = block with message.
set -euo pipefail
INPUT="$(cat)"
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
[ -z "$CMD" ] && exit 0
# Read the command the SHELL will run, not the text as typed. A backslash before
# a newline is removed before anything else happens, so
# curl -d@b https://host/api/v1/repos/a/b/iss\
# ues/1/comments
# executes the comments endpoint while the literal token `issues` never appears
# in the text. Every check below — position, URL, body, endpoint — reads the
# joined form, because that is the command.
CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
# Honour the override only when it is set in the command itself or the env.
case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac
[ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0
# The wrappers this guard points at are its own siblings. Resolving relative to
# this file — rather than to a hardcoded $HOME/.config/mosaic — means the guard
# names the wrappers from the same install it was launched from, and that it
# still works from a repo checkout with no installed mosaic home (which is how it
# is exercised in CI). $HOME remains the fallback for a guard invoked by an
# absolute path from somewhere unusual.
W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
[ -x "$W/pr-review.sh" ] || W="$HOME/.config/mosaic/tools/git"
# ---- 1. checkout into $HOME ------------------------------------------------
if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then
# Any argument that resolves under $HOME and is not under a work filesystem.
if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])(~|\\\$HOME|$HOME)/"; then
cat <<EOF
BLOCKED: this checks a repository out under \$HOME.
\$HOME holds configuration, credentials, state and caches. It does not hold
checkouts, worktrees, scratch files, or build output. One fleet host's /home hit
100% (394 G) with 255 GB of agent workspaces accumulated exactly this way.
Use the helper, which derives the path so you do not have to choose one:
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> # /src/<repo>-worktrees/<slug>
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # show where it would go
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
Worktrees, not clones: they share the object store, and \`git worktree list\`
makes every one of them enumerable — which is the only reason cleanup can
ever be safe.
EOF
exit 2
fi
fi
# ---- 2/3. provider API writes ---------------------------------------------
# A raw provider write this guard cares about is two things: a WRITE, and a URL
# naming an endpoint a Mosaic wrapper already owns. Reads are untouched — they
# are how you gather evidence — and the many endpoints with no wrapper flow
# through.
#
# It deliberately does NOT ask which program makes the call, or whether that
# program sits at shell command position. It used to, and that is the whole
# history of this file. Answering "is this code or is this data" from the text
# of a shell command required a skeleton with quoted spans and heredoc bodies
# removed, an invoker list for the forms where a shell executes quoted text, a
# prefix list for `env`/`sudo`/`timeout`, option-value skipping, and
# backslash-newline joining. Five rounds of adversarial review put nineteen
# writes straight through it, and every one had the same shape: the client was
# ABSENT from the skeleton, so the guard allowed. Variables, line continuations,
# command prefixes, option values, pipes into a shell, and finally command
# substitution inside the very quotes the skeleton was discarding:
# echo "$(curl -d@b .../issues/1/comments)"
# msg="$(curl -d@b .../issues/1/comments)"
# Classifying code against data in shell text with sed and awk is not a hard
# problem, it is the wrong problem. It was not even portable: under CI's busybox
# awk the quote-stripping silently failed, the skeleton kept every quoted span,
# and the guard started refusing ordinary prose instead — which is the other way
# a control like this dies.
#
# So the client detection is gone, and with it that entire failure class: what
# is left cannot fail open by hiding the caller, because it never looks for one.
# It looks for the payload. Something that names a wrapped endpoint and carries
# a body is refused however it is spelled — curl, wget, `python -c`, or a form
# nobody has thought of yet.
#
# The cost is real and belongs in the open, because over-blocking is how a hook
# gets switched off: QUOTING one of these calls on a Bash command line now
# blocks too. `grep -R "curl -d .../issues" docs/` is refused, and so is echoing
# an example into a file. There is no textual way to tell a quoted example from
# a quoted command — that is exactly the finding above — so the rule is the one
# an agent can hold in mind without a parser:
#
# do not put a raw write to a wrapped forge endpoint on a Bash command line,
# not even inside quotes.
#
# Write the example with a file-writing tool, or leave the body flag out of it.
# That is a deliberate narrowing of scope, not an oversight. This hook stops
# mistakes; it is not a sandbox, and pretending otherwise is how you get a
# control nobody can trust the boundaries of.
#
# Scoped to commands carrying a URL, so nothing without one is even considered.
if printf '%s' "$CMD" | grep -Eq 'https?://'; then
# Write detection, now client-agnostic. Every spelling curl accepts, because
# the guard is defeated by the one spelling it does not know: `-d@body` (no
# space) and `--request=POST` (equals form) both slipped past the first
# version. Plus wget's forms and a library call, which a client-shaped test
# could not have seen at all.
#
# A body flag is read as a body flag wherever it appears. `ls -d */ && curl -s
# .../issues/1/comments` is therefore refused, which is a read wearing a
# write's flag. That direction is the acceptable one: it costs an override on
# a rare command, where the reverse costs a silent raw write.
is_write=0
printf '%s' "$CMD" | grep -Eq -- \
'-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--(request|method)[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1
# curl sends POST implicitly when handed a body, in any of these forms.
printf '%s' "$CMD" | grep -Eq -- \
'(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file|--post-(data|file)[[:space:]=]' && is_write=1
# ...and a library call is a write without any flag at all.
printf '%s' "$CMD" | grep -Eq -- \
'\.(post|put|patch|delete)\(' && is_write=1
if [ "$is_write" -eq 1 ]; then
endpoint=""; wrapper=""
case "$CMD" in
*"/pulls/"*"/reviews"*|*"/pulls/"*"/requested_reviewers"*)
endpoint="pull-request review"; wrapper="pr-review.sh" ;;
*"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;;
*"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="issue-comment.sh" ;;
*"/pulls"*) endpoint="pull request"; wrapper="pr-create.sh" ;;
*"/issues"*) endpoint="issue"; wrapper="issue-create.sh" ;;
*"/milestones"*) endpoint="milestone"; wrapper="milestone-create.sh" ;;
esac
# A URL the guard cannot READ is a URL the guard must not CLEAR.
#
# Round one fixed one spelling of this and review immediately produced the
# general form: split the endpoint token itself across two variables —
# a=/api/v1/repos/o/r/iss; b=ues/1/comments
# curl -d@body "https://host${a}${b}"
# — and no fragment above ever appears contiguously. Chasing that with more
# fragments is unwinnable: the endpoint does not exist until the shell
# expands it, and this hook runs before that.
#
# So stop pretending to read it. If a write's URL contains an expansion,
# the guard has no endpoint to judge, and "no endpoint" must not mean
# "allowed" — that is the same absence-driven allow as the missing-wrapper
# case, wearing different clothes.
#
# Scoped to commands that are visibly forge-shaped, so an opaque webhook or
# artifact POST is untouched. A caller who splits `/api/` and the hostname
# as well does get through; that is no longer a mistake anyone makes by
# accident, and this hook stops mistakes. It is not a sandbox, and pretending
# otherwise is how you get a control nobody can trust the boundaries of.
if [ -z "$endpoint" ] \
&& printf '%s' "$CMD" | grep -Eq 'https?://[^[:space:]"'"'"'|;&)]*[$`]' \
&& printf '%s' "$CMD" | grep -Eq '/api/v[0-9]|/repos/|git\.|gitea|github\.com|gitlab|forgejo'; then
cat <<EOF
BLOCKED: raw provider API write whose URL this guard cannot read.
The URL is assembled from shell expansions, so the endpoint it names does not
exist until the shell builds it — after this check runs. The guard cannot tell
whether it is a wrapped endpoint, and an unreadable URL is not a cleared one.
$W/ <- the wrappers; use the one for the endpoint you are calling
If you are calling a wrapped endpoint (reviews, merges, comments, pulls,
issues, milestones), use the wrapper — it also resolves identity explicitly,
which matters on a host whose default provider login is an admin account.
If this is genuinely not a provider endpoint, either write the URL literally so
the guard can see what it is, or prefix MOSAIC_WRAPPER_OVERRIDE=1.
EOF
exit 2
fi
# Block on the ENDPOINT, never on whether the wrapper file happens to exist.
# The previous version required `[ -x "$W/$wrapper" ]`, which meant a host
# with a broken or absent install allowed exactly the raw writes the guard
# exists to stop — an absence-driven allow, and the second one found in this
# file. A missing wrapper is a broken install; it is not a licence to bypass
# gate 7. Say so, and say which is which.
if [ -n "$endpoint" ]; then
if [ -x "$W/$wrapper" ]; then
remedy="Use the wrapper the Constitution (gate 7) requires:
$W/$wrapper
Run \`$wrapper --help\` for the flags."
else
remedy="The wrapper that covers this endpoint is \`$wrapper\`, and it is NOT
present or not executable at:
$W/$wrapper
That is a broken or incomplete install, not permission to send the call raw.
Repair the install (\`mosaic doctor\`) and use the wrapper."
fi
cat <<EOF
BLOCKED: raw provider API write to the $endpoint endpoint.
$remedy
The wrappers are not a formality. They carry provider-dialect differences that
raw curl silently gets wrong — Gitea's review event is APPROVED, GitHub's is
APPROVE, and Gitea accepts the wrong one with HTTP 200 while filing the review
as PENDING. They also resolve identity explicitly, which matters on a host
where the default login is an admin account.
If no wrapper flag can express this call, that is a wrapper gap: extend the
wrapper. To proceed anyway for a genuine gap, prefix MOSAIC_WRAPPER_OVERRIDE=1.
EOF
exit 2
fi
fi
fi
# ---- 3. the APPROVE/APPROVED trap, wherever it appears ---------------------
if printf '%s' "$CMD" | grep -Eq '"event"[[:space:]]*:[[:space:]]*"APPROVE"'; then
cat <<EOF
BLOCKED: review event "APPROVE" is not valid on Gitea.
Gitea's vocabulary is "APPROVED". It accepts "APPROVE" with HTTP 200, silently
files the review as PENDING, and then fails the submit endpoint with
422 "review stay pending" — so the verdict looks placed and is not.
("REQUEST_CHANGES" is spelled identically on both providers; only the approve
path carries this trap.)
Use $W/pr-review.sh, which sends the correct token for the detected provider.
Whatever you use, re-read GET /pulls/{n}/reviews and assert state==APPROVED
before reporting a verdict placed.
EOF
exit 2
fi
exit 0
@@ -0,0 +1,293 @@
#!/usr/bin/env bash
# check-tools-index.sh — assert every shipped tool is discoverable from the
# resident documentation an agent actually has in context.
#
# WHY THIS GATE EXISTS
# --------------------
# The framework ships 26 git wrappers. Before this gate, 20 of them were named
# in neither `defaults/TOOLS.md` nor `guides/TOOLS-REFERENCE.md`. One of the
# undocumented ones was `pr-review.sh` — the wrapper that carries the
# APPROVED/APPROVE provider-dialect split.
#
# The observable consequence, on a live fleet host: an agent needing to place a
# review verdict reached for raw `curl`, sent GitHub's `APPROVE` to a Gitea
# host, and got HTTP 200 with the review silently filed PENDING — three times,
# because nothing about the failure pointed at the wrapper that already handled
# it correctly. The agent was not ignoring Constitution gate 7. It was obeying
# an index that said the tool did not exist.
#
# That is not a discipline problem and no amount of prose fixes it. A wrapper
# that is not in the resident index is, from inside a session, indistinguishable
# from a wrapper that was never written. So the invariant is mechanical:
#
# shipping a tool and documenting it are the same commit, or CI fails.
#
# WHAT IT CHECKS
# --------------
# forward every non-excluded tool in an ENFORCED suite is named in at least
# one index document (missing tool -> undiscoverable -> FAIL)
# reverse every `<name>.sh` an index document attributes to an enforced
# suite exists on disk (stale reference -> agent runs a ghost -> FAIL)
#
# Suites outside the enforced set are reported with a coverage percentage but do
# not fail the build, so the ratchet can be tightened one suite per PR instead of
# landing as one unreviewable sweep. `--strict` fails on those too.
#
# WHY THE ENFORCED LIST LIVES HERE AND NOT IN A MARKER INSIDE THE DOC
# -------------------------------------------------------------------
# `TOOLS.md` is operator-owned (see framework-manifest.txt). A marker inside it
# would let an operator silence this gate by editing their own copy — the gate
# would then be strongest exactly where it is least needed and absent where it
# is needed most. The list is framework-owned and changes only through a
# reviewed PR.
#
# Usage:
# check-tools-index.sh [--tools-dir DIR] [--doc FILE]... [--strict] [--self-test]
#
# Exit: 0 = every enforced suite fully discoverable · 1 = drift · 2 = bad usage
set -euo pipefail
# Suites whose coverage is a HARD requirement. Add a suite here only together
# with the doc changes that make it pass.
#
# `git` is first because it is the suite Constitution gates 6-8 make mandatory:
# an undiscoverable git wrapper converts a hard gate into a coin flip.
ENFORCED_SUITES=(git)
# Files that are not agent-callable tools and must not be required in an index.
EXCLUDE_GLOBS=(
'test-*' # hermetic regression scripts, invoked by CI not by agents
'_*' # private helpers (_lib, _scripts internals)
'*.bak' # editor/installer debris
'*.pre-*' # pre-change backups (e.g. ci-queue-wait.sh.pre-404fix-bak)
'README.md'
)
STRICT=0
SELF_TEST=0
TOOLS_DIR=""
DOCS=()
die() { printf 'check-tools-index: %s\n' "$*" >&2; exit 2; }
while [ $# -gt 0 ]; do
case "$1" in
--tools-dir) TOOLS_DIR="${2:-}"; shift 2 ;;
--doc) DOCS+=("${2:-}"); shift 2 ;;
--strict) STRICT=1; shift ;;
--self-test) SELF_TEST=1; shift ;;
-h|--help) sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
# ---- location resolution ---------------------------------------------------
# Runs from two places with different layouts, and must not silently check the
# wrong tree: a CI checkout (repo-relative) and an installed host ($MOSAIC_HOME).
resolve_locations() {
local here framework
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# .../framework/tools/quality/scripts -> .../framework
framework="$(cd -- "$here/../../.." && pwd)"
if [ -z "$TOOLS_DIR" ]; then
if [ -d "$framework/tools" ]; then
TOOLS_DIR="$framework/tools"
else
TOOLS_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}/tools"
fi
fi
if [ ${#DOCS[@]} -eq 0 ]; then
# The two layouts are mutually exclusive on purpose. Unioning them would let
# a well-maintained operator TOOLS.md on the developer's own machine mask a
# gap in the shipped defaults — the check would pass locally and the defect
# would still install on every other host. Repo layout wins when present.
if [ -f "$framework/defaults/TOOLS.md" ]; then
DOCS+=("$framework/defaults/TOOLS.md")
[ -f "$framework/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$framework/guides/TOOLS-REFERENCE.md")
else
local mosaic_home="${MOSAIC_HOME:-$HOME/.config/mosaic}"
[ -f "$mosaic_home/TOOLS.md" ] && DOCS+=("$mosaic_home/TOOLS.md")
[ -f "$mosaic_home/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$mosaic_home/guides/TOOLS-REFERENCE.md")
fi
fi
[ -d "$TOOLS_DIR" ] || die "tools dir not found: $TOOLS_DIR"
[ ${#DOCS[@]} -gt 0 ] || die "no index documents found (pass --doc FILE)"
}
is_excluded() {
local name="$1" glob
for glob in "${EXCLUDE_GLOBS[@]}"; do
# shellcheck disable=SC2254 # glob is intentionally a pattern
case "$name" in $glob) return 0 ;; esac
done
return 1
}
# A tool counts as documented when its basename appears anywhere in the corpus.
# Deliberately permissive about *form* (table cell, code fence, prose) and strict
# about *presence*: the gate's job is "an agent can find it", not house style.
documented() { grep -qF -- "$1" "$CORPUS"; }
# ---- the check -------------------------------------------------------------
run_check() {
local rc=0 suite dir tool base enforced
CORPUS="$(mktemp)"; trap 'rm -f "$CORPUS"' RETURN
cat "${DOCS[@]}" > "$CORPUS"
printf 'tools: %s\n' "$TOOLS_DIR"
for d in "${DOCS[@]}"; do printf 'index: %s\n' "$d"; done
printf '\n'
for dir in "$TOOLS_DIR"/*/; do
[ -d "$dir" ] || continue
suite="$(basename -- "$dir")"
case " ${ENFORCED_SUITES[*]} " in *" $suite "*) enforced=1 ;; *) enforced=0 ;; esac
[ "$STRICT" -eq 1 ] && enforced=1
case "$suite" in _*) continue ;; esac
local total=0 found=0
local -a suite_missing=() suite_noexec=()
for tool in "$dir"*.sh; do
[ -e "$tool" ] || continue
base="$(basename -- "$tool")"
is_excluded "$base" && continue
total=$((total + 1))
if documented "$base"; then
found=$((found + 1))
# Documented AND present is not enough. The index presents these as
# commands to run, and every caller — the wrapper guard included —
# decides "is this tool here?" with `[ -x ]`. A 0644 wrapper is
# documented, present, and dead: it reads as absent to every check that
# matters while scoring 100% here. That is a false green, which is worse
# than a red, so it fails rather than warns.
[ -x "$tool" ] || suite_noexec+=("$base")
else
suite_missing+=("$base")
fi
done
[ "$total" -eq 0 ] && continue
local pct=$(( found * 100 / total ))
if [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -gt 0 ]; then
printf 'FAIL %-12s %3d%% (%d/%d) documented but not executable: %s\n' \
"$suite" "$pct" "$found" "$total" "${suite_noexec[*]}"
rc=1
fi
if [ "$enforced" -eq 1 ] && [ ${#suite_missing[@]} -gt 0 ]; then
printf 'FAIL %-12s %3d%% (%d/%d) undocumented: %s\n' \
"$suite" "$pct" "$found" "$total" "${suite_missing[*]}"
rc=1
elif [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -eq 0 ]; then
printf 'ok %-12s %3d%% (%d/%d) [enforced]\n' "$suite" "$pct" "$found" "$total"
else
printf 'info %-12s %3d%% (%d/%d) not yet enforced\n' "$suite" "$pct" "$found" "$total"
fi
# Reverse: an index that names a tool this suite does not have sends agents
# after something that cannot run. Only checked for enforced suites, where
# the naming is unambiguous enough to attribute.
if [ "$enforced" -eq 1 ]; then
local -a stale=()
local ref
while read -r ref; do
[ -n "$ref" ] || continue
is_excluded "$ref" && continue
[ -e "$dir$ref" ] || stale+=("$ref")
done < <(grep -oE "$suite/[a-z0-9][a-z0-9._-]*\.sh" "$CORPUS" \
| sed "s|^$suite/||" | sort -u)
if [ ${#stale[@]} -gt 0 ]; then
printf 'FAIL %-12s stale index references (no such file): %s\n' \
"$suite" "${stale[*]}"
rc=1
fi
fi
done
printf '\n'
if [ "$rc" -ne 0 ]; then
cat <<EOF
Undocumented tools are undiscoverable. An agent cannot obey a hard gate that
tells it to use a wrapper it has no way to learn exists — it will reach for raw
curl/gh/tea instead, and the wrapper's provider-dialect handling will be lost.
Fix by naming each tool above in one of the index documents listed at the top,
in the same commit that ships it.
EOF
else
printf 'every enforced suite is fully discoverable.\n'
fi
return "$rc"
}
# ---- self-test -------------------------------------------------------------
# Proves the gate can actually fail. A checker that only ever passes is
# indistinguishable from one that is not running, which is the failure mode this
# whole file exists to prevent — so it must demonstrate a red on demand.
self_test() {
local tmp rc
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN
mkdir -p "$tmp/tools/git"
printf '#!/bin/sh\n' > "$tmp/tools/git/documented-tool.sh"
printf '#!/bin/sh\n' > "$tmp/tools/git/test-ignored.sh"
chmod +x "$tmp/tools/git/documented-tool.sh" "$tmp/tools/git/test-ignored.sh"
# run_check reads the TOOLS_DIR / DOCS globals; an array cannot ride in a
# command-prefix assignment, so point the globals at the fixture directly.
TOOLS_DIR="$tmp/tools"
DOCS=("$tmp/doc.md")
# Case 1: fully documented -> pass.
printf 'see tools/git/documented-tool.sh for details\n' > "$tmp/doc.md"
if run_check >/dev/null; then
printf 'self-test 1/4 ok (complete index passes)\n'
else
printf 'self-test 1/4 FAIL (complete index should pass)\n'; return 1
fi
# Case 2: an undocumented tool -> fail.
printf '#!/bin/sh\n' > "$tmp/tools/git/undocumented-tool.sh"
rc=0; run_check >/dev/null || rc=$?
if [ "$rc" -eq 1 ]; then
printf 'self-test 2/4 ok (undocumented tool fails the gate)\n'
else
printf 'self-test 2/4 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1
fi
# Case 3: a stale index reference -> fail.
rm "$tmp/tools/git/undocumented-tool.sh"
printf 'also tools/git/deleted-tool.sh\n' >> "$tmp/doc.md"
rc=0; run_check >/dev/null || rc=$?
if [ "$rc" -eq 1 ]; then
printf 'self-test 3/4 ok (stale index reference fails the gate)\n'
else
printf 'self-test 3/4 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1
fi
# Case 4: documented, present, and NOT executable -> fail. Found by an
# independent reviewer: a 0644 wrapper scored 100% here while reading as
# absent to every `[ -x ]` in the fleet, including the wrapper guard's.
sed -i '/deleted-tool/d' "$tmp/doc.md"
printf '#!/bin/sh\n' > "$tmp/tools/git/noexec-tool.sh"
chmod 0644 "$tmp/tools/git/noexec-tool.sh"
printf 'and tools/git/noexec-tool.sh\n' >> "$tmp/doc.md"
rc=0; run_check >/dev/null || rc=$?
if [ "$rc" -eq 1 ]; then
printf 'self-test 4/4 ok (documented but non-executable tool fails the gate)\n'
else
printf 'self-test 4/4 FAIL (non-executable tool should fail, got rc=%s)\n' "$rc"; return 1
fi
printf '\nself-test passed: the gate demonstrably reds on every drift direction.\n'
}
if [ "$SELF_TEST" -eq 1 ]; then
self_test
else
resolve_locations
run_check
fi
@@ -26,6 +26,9 @@ chk "F1 fresh: CONSTITUTION/AGENTS/STANDARDS/TOOLS seeded" \
"[ -f '$T1/CONSTITUTION.md' ] && [ -f '$T1/AGENTS.md' ] && [ -f '$T1/STANDARDS.md' ] && [ -f '$T1/TOOLS.md' ]"
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: 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)
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: 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: 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"
echo
@@ -564,7 +564,7 @@ echo "== Q16 (guard): Q2's ENUM-B fixture must STAY address-free — the reconci
# Token concatenated so THIS guard's own source lines never contain the
# literal fixture id and cannot self-match.
enum_id='ENUM''-B'
fixture_line="$(has_match -F "\"id\":\"$enum_id\"" "$self" | has_match -F '"observed_seq":5' | head -n1)"
fixture_lines="$(has_match -F "\"id\":\"$enum_id\"" "$self")"; fixture_matches="$(has_match -F '"observed_seq":5' <<<"$fixture_lines")"; fixture_line="${fixture_matches%%$'\n'*}"
[ -n "$fixture_line" ] || fail_msg "Q16: could not locate Q2's $enum_id fixture line (renamed/renumbered? update this guard)"
fixture_json="$(printf '%s' "$fixture_line" | sed "s/.*'\({.*}\)'.*/\1/")"
# Positive controls FIRST (blind-instrument rule): the extraction must yield
@@ -179,8 +179,8 @@ echo "== P3: detector orders the cause line BEFORE the delta it explains =="
printf 'r1 state v2\n' >"$fx/repo_r1"
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: second poll failed"
sd="$(state_dir)"
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
src_seq="$(jq -r 'select(.locators.kind == "repo") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
pre_seq="$(jq -nr 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty' "$sd/pending.jsonl")"
src_seq="$(jq -nr 'first(inputs | select(.locators.kind == "repo") | .observed_seq) // empty' "$sd/pending.jsonl")"
[ -n "$pre_seq" ] || fail_msg "P3: no preimage cause entry enqueued"
[ -n "$src_seq" ] || fail_msg "P3: no source delta entry enqueued"
if [ -n "$pre_seq" ] && [ -n "$src_seq" ]; then
@@ -343,8 +343,8 @@ echo "== P11: reconcile surfaces the cause line before its enumerations =="
printf '# adapter changed while detector down\n' >>"$fx/adapter.sh"
"$RECON" reconcile >/dev/null 2>&1 # rc 1 expected (unaccounted enumerated)
sd="$(state_dir)"
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
enum_seq="$(jq -r 'select(.locators.reconciled == true) | .observed_seq' "$sd/pending.jsonl" | head -n1)"
pre_seq="$(jq -nr 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty' "$sd/pending.jsonl")"
enum_seq="$(jq -nr 'first(inputs | select(.locators.reconciled == true) | .observed_seq) // empty' "$sd/pending.jsonl")"
[ -n "$pre_seq" ] || fail_msg "P11: reconcile must enqueue the preimage cause line"
[ -n "$enum_seq" ] || fail_msg "P11: reconcile must still enumerate the unaccounted source"
if [ -n "$pre_seq" ] && [ -n "$enum_seq" ]; then
@@ -150,7 +150,7 @@ site_line() { # site_line FILE MARKER -> first physical line of that call
local f="$1" marker="$2" ln
ln="$(grep -n "# SITE:${marker}\$" "$f" | cut -d: -f1)"
# continuation marker sits on the tail line; the call starts one line up
if ! sed -n "${ln}p" "$f" | grep -Eq 'has_match|count_lines'; then
source_line="$(sed -n "${ln}p" "$f")"; if ! grep -Eq 'has_match|count_lines' <<<"$source_line"; then
ln=$((ln - 1))
fi
printf '%s' "$ln"
@@ -167,13 +167,13 @@ rcB=$?
sort "$LEDGER" >"$TMP/got-c1"
n_expected="$(grep -c . "$TMP/expected-c1")"
if [ "$rcA" -eq 0 ] && [ "$rcB" -eq 0 ] &&
printf '%s' "$outA" | grep -q 'mini-a: OK' &&
printf '%s' "$outB" | grep -q 'mini-b: OK' &&
grep -q 'mini-a: OK' <<<"$outA"&&
grep -q 'mini-b: OK' <<<"$outB"&&
[ "$n_expected" -gt 1 ] &&
cmp -s "$TMP/expected-c1" "$TMP/got-c1"; then
check C1 0 ""
else
check C1 1 "rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff "$TMP/expected-c1" "$TMP/got-c1" 2>&1 | head -n 10 | tr '\n' ' ')"
check C1 1 "rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff "$TMP/expected-c1" "$TMP/got-c1" 2>&1 | sed -n '1,10p' | tr '\n' ' ')"
fi
# --- C2: early exit -> short ledger, comparison catches it -----------------
@@ -201,12 +201,12 @@ abort_case() { # abort_case NAME MARKER HELPER
bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
rc=$?
if [ "$rc" -ne 0 ] &&
! printf '%s' "$out" | grep -q 'mini-a: OK' &&
! printf '%s' "$out" | grep -q 'mini-a: FAILED' &&
printf '%s' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" &&
printf '%s' "$out" | grep -q "WAKE-ASSERT ABORT" &&
printf '%s' "$out" | grep -q "$site" &&
printf '%s' "$out" | grep -q "grep exit 2" &&
! grep -q 'mini-a: OK' <<<"$out"&&
! grep -q 'mini-a: FAILED' <<<"$out"&&
grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" <<<"$out"&&
grep -q "WAKE-ASSERT ABORT" <<<"$out"&&
grep -q "$site" <<<"$out"&&
grep -q "grep exit 2" <<<"$out"&&
grep -q "^${helper} ${site}\$" "$ledger"; then
check "$name" 0 ""
else
@@ -230,8 +230,8 @@ if [ "$got" = "1" ]; then check C8 0 ""; else check C8 1 "env-prefix did not rea
out="$(WAKE_ASSERT_FORCE_GREP_ERROR_AT="mini-a.sh:9999" bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
rc=$?
if [ "$rc" -eq 0 ] &&
printf '%s' "$out" | grep -q 'mini-a: OK' &&
! printf '%s' "$out" | grep -q 'WAKE-ASSERT ARMED'; then
grep -q 'mini-a: OK' <<<"$out"&&
! grep -q 'WAKE-ASSERT ARMED' <<<"$out"; then
check C9 0 ""
else
check C9 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
@@ -248,8 +248,8 @@ chmod +x "$TMP/fake-bash"
out="$(WAKE_ASSERT_PIN_BASH="$TMP/fake-bash" bash -c '. "$WAKE_COMMON" && wake_assert_init && echo REACHED-PAST-INIT' 2>&1)"
rc=$?
if [ "$rc" -ne 0 ] &&
! printf '%s' "$out" | grep -q 'REACHED-PAST-INIT' &&
printf '%s' "$out" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then
! grep -q 'REACHED-PAST-INIT' <<<"$out"&&
grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated' <<<"$out"; then
check C10 0 ""
else
check C10 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 2 | tr '\n' ' ')"
@@ -283,8 +283,8 @@ out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$TMP/mini-c.sh" "$TMP" 2>&1)"
rc=$?
summary_ln="$(site_line "$TMP/mini-c.sh" c-summary)"
if [ "$rc" -eq 1 ] &&
printf '%s' "$out" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&
! printf '%s' "$out" | grep -q 'all invariants passed' &&
grep -q 'wake mini-c harness: FAILED (1 assertion(s))' <<<"$out"&&
! grep -q 'all invariants passed' <<<"$out"&&
grep -q "^count_lines mini-c.sh:${summary_ln}\$" "$LEDGER"; then
check C11 0 ""
else
@@ -107,7 +107,7 @@ if cmp -s "$TMP/expected.txt" "$TMP/static.txt"; then
echo "STATIC-INVENTORY equals expected set ($(grep -c . "$TMP/static.txt") rows from source text)"
else
flag "static inventory (source text) differs from expected set (artifact):"
diff "$TMP/expected.txt" "$TMP/static.txt" | head -n 20 | sed 's/^/ /'
diff "$TMP/expected.txt" "$TMP/static.txt" | sed -n '1,20p' | sed 's/^/ /'
fi
# --- 3: green instrumented run ----------------------------------------------
@@ -116,7 +116,7 @@ LEDGER="$TMP/ledger"
for s in "${SUITES[@]}"; do
out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$WAKE/$s" 2>&1)"
rc=$?
if printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$s")"; then
if grep -Eq "$(sentinel_for "$s")" <<<"$out"; then
sent="present"
else
sent="ABSENT"
@@ -177,14 +177,14 @@ while read -r helper site form; do
rc=$?
bad=""
[ "$rc" -ne 0 ] || bad="$bad exit=0"
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" ||
grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" <<<"$out"||
bad="$bad no-ARMED-line"
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" ||
grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" <<<"$out"||
bad="$bad no-ABORT-line"
# AND-polarity check (a match is the defect): a grep error (rc>=2) must be
# its own loud arm — it cannot fall through as "no sentinel = pass".
rc_sent=0
printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$f")" || rc_sent=$?
grep -Eq "$(sentinel_for "$f")" <<<"$out"|| rc_sent=$?
case "$rc_sent" in
0) bad="$bad sentinel-emitted" ;;
1) : ;;
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
buildPiSkillArgs,
discoverPiExtensionArgs,
enumerateSkillDirs,
piForceSkillNames,
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)', () => {
let root: string;
+10 -4
View File
@@ -715,9 +715,15 @@ export function buildPiSkillArgs(
return ['--no-skills', ...forcedSkillArgs];
}
function discoverPiExtension(): string[] {
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
return existsSync(ext) ? ['--extension', ext] : [];
const PI_EXTENSION_FILES = ['mosaic-extension.ts', 'goal-extension.ts'] as const;
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 ────────────────────────────────────────────────────────
@@ -792,7 +798,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
const prompt = buildRuntimePrompt('pi');
const cliArgs = ['--append-system-prompt', prompt];
cliArgs.push(...buildPiSkillArgs(args));
cliArgs.push(...discoverPiExtension());
cliArgs.push(...discoverPiExtensionArgs());
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
} 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 () => {
rmSync(fixture.defaultsDir, { recursive: true });
@@ -0,0 +1,796 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
interface FakeEntry {
type: string;
customType?: string;
data?: unknown;
}
interface SentMessage {
message: {
customType: string;
content: string;
display: boolean;
};
options?: {
triggerTurn?: boolean;
deliverAs?: 'steer' | 'followUp' | 'nextTurn';
};
}
interface FakeContext {
cwd: string;
ui: {
notifications: Array<{ message: string; level?: string }>;
statuses: Map<string, string | undefined>;
notify(message: string, level?: string): void;
setStatus(key: string, value: string | undefined): void;
};
sessionManager: {
getBranch(): FakeEntry[];
};
isIdle(): boolean;
hasPendingMessages(): boolean;
abort(): void;
}
type EventHandler = (
event: Record<string, unknown>,
context: FakeContext,
) => unknown | Promise<unknown>;
type CommandHandler = (args: string, context: FakeContext) => unknown | Promise<unknown>;
interface FakeToolResult {
content: Array<{ type: string; text: string }>;
details?: unknown;
terminate?: boolean;
}
interface FakeTool {
name: string;
execute(
toolCallId: string,
params: Record<string, unknown>,
signal: AbortSignal | undefined,
onUpdate: undefined,
context: FakeContext,
): Promise<FakeToolResult>;
}
interface GoalExtensionFactory {
(api: FakePiApi): void;
}
interface GoalExtensionModule {
default: GoalExtensionFactory;
}
interface FakePiApi {
on(event: string, handler: EventHandler): void;
registerCommand(name: string, options: { description: string; handler: CommandHandler }): void;
registerTool(tool: FakeTool): void;
appendEntry(customType: string, data?: unknown): void;
sendMessage(message: SentMessage['message'], options?: SentMessage['options']): void;
}
function isGoalExtensionModule(value: unknown): value is GoalExtensionModule {
if (typeof value !== 'object' || value === null) return false;
return typeof Reflect.get(value, 'default') === 'function';
}
const goalExtensionUrl = new URL('../../framework/runtime/pi/goal-extension.ts', import.meta.url)
.href;
const importedGoalExtension: unknown = await import(goalExtensionUrl);
if (!isGoalExtensionModule(importedGoalExtension)) {
throw new Error('Pi goal extension must export a default registration function');
}
const registerGoalExtension = importedGoalExtension.default;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
class FakePi {
readonly handlers = new Map<string, EventHandler[]>();
readonly commands = new Map<string, CommandHandler>();
readonly tools = new Map<string, FakeTool>();
readonly entries: FakeEntry[] = [];
readonly sentMessages: SentMessage[] = [];
readonly notifications: Array<{ message: string; level?: string }> = [];
readonly statuses = new Map<string, string | undefined>();
branch: FakeEntry[] = [];
idle = true;
pending = false;
abortCount = 0;
readonly context: FakeContext = {
cwd: '/tmp/project',
ui: {
notifications: this.notifications,
statuses: this.statuses,
notify: (message: string, level?: string): void => {
this.notifications.push({ message, level });
},
setStatus: (key: string, value: string | undefined): void => {
this.statuses.set(key, value);
},
},
sessionManager: {
getBranch: (): FakeEntry[] => [...this.branch],
},
isIdle: (): boolean => this.idle,
hasPendingMessages: (): boolean => this.pending,
abort: (): void => {
this.abortCount += 1;
},
};
readonly api: FakePiApi = {
on: (event: string, handler: EventHandler): void => {
this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]);
},
registerCommand: (
name: string,
options: { description: string; handler: CommandHandler },
): void => {
this.commands.set(name, options.handler);
},
registerTool: (tool: FakeTool): void => {
this.tools.set(tool.name, tool);
},
appendEntry: (customType: string, data?: unknown): void => {
const entry: FakeEntry = { type: 'custom', customType, data };
this.entries.push(entry);
this.branch.push(entry);
},
sendMessage: (message: SentMessage['message'], options?: SentMessage['options']): void => {
this.sentMessages.push({ message, options });
},
};
constructor(initialBranch: FakeEntry[] = []) {
this.branch = [...initialBranch];
registerGoalExtension(this.api);
}
async emit(event: string, value: Record<string, unknown> = {}): Promise<unknown[]> {
const results: unknown[] = [];
for (const handler of this.handlers.get(event) ?? []) {
results.push(await handler(value, this.context));
}
return results;
}
async goal(args: string): Promise<void> {
const handler = this.commands.get('goal');
if (handler === undefined) throw new Error('/goal was not registered');
await handler(args, this.context);
}
async report(params: Record<string, unknown>): Promise<FakeToolResult> {
const tool = this.tools.get('mosaic_goal_report');
if (tool === undefined) throw new Error('mosaic_goal_report was not registered');
return await tool.execute('goal-report-1', params, undefined, undefined, this.context);
}
}
function latestGoalStateData(pi: FakePi): Record<string, unknown> {
for (let index = pi.entries.length - 1; index >= 0; index -= 1) {
const entry = pi.entries[index];
if (entry?.customType === 'mosaic-goal-state' && isRecord(entry.data)) return entry.data;
}
throw new Error('No persisted Mosaic goal state found');
}
function stateField(pi: FakePi, field: string): unknown {
return latestGoalStateData(pi)[field];
}
function activeGoalStatementFromContext(result: unknown): string {
if (!isRecord(result)) throw new Error('Context handler did not return an object');
const messages = result['messages'];
if (!Array.isArray(messages)) throw new Error('Context result did not include messages');
const goalMessage = messages.find(
(message: unknown): boolean =>
isRecord(message) && message['customType'] === 'mosaic-goal-context',
);
if (!isRecord(goalMessage) || typeof goalMessage['content'] !== 'string') {
throw new Error('Goal context message was not injected');
}
return goalMessage['content'];
}
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
describe('Mosaic Pi goal extension commands', () => {
it('shows help and handles controls safely when no goal exists', async () => {
const pi = new FakePi();
await pi.goal('');
expect(pi.notifications.at(-1)?.message).toContain('/goal set');
await pi.goal('status');
expect(pi.notifications.at(-1)?.message).toContain('No Mosaic goal is set');
for (const command of ['pause', 'resume', 'cancel']) {
await pi.goal(command);
expect(pi.notifications.at(-1)?.level).toBe('warning');
}
expect(pi.entries).toHaveLength(0);
expect(pi.sentMessages).toHaveLength(0);
});
it('sets, reports, pauses, resumes, and cancels a bounded goal', async () => {
const pi = new FakePi();
await pi.goal('set Deliver the local goal extension with tests');
expect(stateField(pi, 'phase')).toBe('active');
expect(stateField(pi, 'statement')).toBe('Deliver the local goal extension with tests');
expect(pi.sentMessages).toHaveLength(1);
expect(pi.sentMessages[0]?.options?.triggerTurn).toBe(true);
await pi.goal('status');
expect(pi.notifications.at(-1)?.message).toContain('Deliver the local goal extension');
expect(pi.notifications.at(-1)?.message).toContain('active');
await pi.goal('pause');
expect(stateField(pi, 'phase')).toBe('paused');
pi.sentMessages.length = 0;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
await pi.goal('resume');
expect(stateField(pi, 'phase')).toBe('active');
expect(pi.sentMessages).toHaveLength(1);
await pi.goal('cancel');
expect(stateField(pi, 'phase')).toBe('cancelled');
pi.sentMessages.length = 0;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
});
it('accepts /goal <statement> shorthand but refuses to replace an active goal', async () => {
const pi = new FakePi();
await pi.goal('First goal');
const firstGoalId = stateField(pi, 'goalId');
await pi.goal('set Second goal');
expect(stateField(pi, 'goalId')).toBe(firstGoalId);
expect(stateField(pi, 'statement')).toBe('First goal');
expect(pi.notifications.at(-1)?.level).toBe('warning');
expect(pi.notifications.at(-1)?.message).toContain('/goal cancel');
});
it('rejects invalid phase transitions, aborts busy work, and supports clear as cancel', async () => {
const pi = new FakePi();
await pi.goal('set Preserve transition safety');
await pi.goal('resume');
expect(pi.notifications.at(-1)?.message).toContain('cannot be resumed');
pi.idle = false;
await pi.goal('pause maintenance window');
expect(stateField(pi, 'stopReason')).toBe('maintenance window');
expect(pi.abortCount).toBe(1);
await pi.goal('pause');
expect(pi.notifications.at(-1)?.message).toContain('cannot be paused');
await pi.goal('clear');
expect(stateField(pi, 'phase')).toBe('cancelled');
expect(pi.abortCount).toBe(2);
});
it('rejects empty and oversized goal statements without starting a run', async () => {
const pi = new FakePi();
await pi.goal('set');
await pi.goal(`set ${'x'.repeat(8_001)}`);
expect(pi.entries).toHaveLength(0);
expect(pi.sentMessages).toHaveLength(0);
expect(pi.notifications.at(-1)?.level).toBe('warning');
});
});
describe('Mosaic Pi goal lifecycle', () => {
it('injects one fresh active contract before every model context', async () => {
const pi = new FakePi();
await pi.goal('set Keep the agent oriented');
const existingGoalContext = {
role: 'custom',
customType: 'mosaic-goal-context',
content: 'stale',
};
const existingContinuation = {
role: 'custom',
customType: 'mosaic-goal-continuation',
content: 'stale continuation',
};
const first = await pi.emit('context', {
messages: [existingGoalContext, existingContinuation],
});
const second = await pi.emit('context', { messages: [] });
expect(activeGoalStatementFromContext(first[0])).toContain('Keep the agent oriented');
expect(activeGoalStatementFromContext(first[0])).toContain('mosaic_goal_report');
expect(activeGoalStatementFromContext(first[0])).not.toContain('stale');
if (!isRecord(first[0]) || !Array.isArray(first[0]['messages'])) {
throw new Error('Expected filtered context messages');
}
expect(first[0]['messages']).toHaveLength(1);
expect(activeGoalStatementFromContext(second[0])).toContain('Keep the agent oriented');
await pi.goal('cancel');
expect(
await pi.emit('context', {
messages: [existingGoalContext, existingContinuation],
}),
).toEqual([{ messages: [] }]);
});
it('lets an existing busy run adopt the goal and waits behind a pending message', async () => {
const busy = new FakePi();
busy.idle = false;
await busy.goal('set Join the current run safely');
expect(busy.sentMessages).toHaveLength(0);
busy.pending = true;
await busy.emit('agent_settled');
expect(busy.sentMessages).toHaveLength(0);
busy.pending = false;
busy.idle = true;
await busy.emit('agent_settled');
expect(busy.sentMessages).toHaveLength(1);
});
it('records every turn and continues once when an active run settles', async () => {
const pi = new FakePi();
await pi.goal('set Finish all acceptance criteria');
pi.sentMessages.length = 0;
await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] });
expect(stateField(pi, 'turnCount')).toBe(1);
expect(stateField(pi, 'lastCheckSource')).toBe('turn');
await pi.emit('agent_settled');
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(1);
expect(pi.sentMessages[0]?.message.content).toContain('Goal remains active');
await pi.emit('agent_start');
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(2);
});
it('requires two consecutive evidence-bearing achievement reports', async () => {
const pi = new FakePi();
await pi.goal('set Prove the feature works');
pi.sentMessages.length = 0;
const first = await pi.report({
status: 'achieved',
summary: 'Focused tests pass',
evidence: ['pnpm test: 12 passed'],
});
expect(first.terminate).toBe(true);
expect(stateField(pi, 'phase')).toBe('verifying');
expect(stateField(pi, 'verificationPasses')).toBe(1);
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(1);
expect(pi.sentMessages[0]?.message.content).toContain('verification pass');
await pi.emit('agent_start');
const second = await pi.report({
status: 'achieved',
summary: 'Independent recheck confirms completion',
evidence: ['rerun: 12 passed', 'framework path verified'],
});
expect(second.terminate).toBe(true);
expect(stateField(pi, 'phase')).toBe('achieved');
expect(stateField(pi, 'verificationPasses')).toBe(2);
pi.sentMessages.length = 0;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
});
it('rejects malformed progress reports and reports submitted without an active goal', async () => {
const noGoal = new FakePi();
await expect(
noGoal.report({ status: 'continue', summary: 'work', evidence: [] }),
).rejects.toThrow(/No active Mosaic goal/);
const pi = new FakePi();
await pi.goal('set Validate report boundaries');
const invalidReports: Record<string, unknown>[] = [
{},
{ status: 'invalid', summary: 'work', evidence: [] },
{ status: 'continue', evidence: [] },
{ status: 'continue', summary: ' ', evidence: [] },
{ status: 'continue', summary: 'x'.repeat(2_001), evidence: [] },
{ status: 'continue', summary: 'work', evidence: 'not-an-array' },
{ status: 'continue', summary: 'work', evidence: Array.from({ length: 21 }, () => 'x') },
{ status: 'continue', summary: 'work', evidence: [4] },
{ status: 'continue', summary: 'work', evidence: [''] },
{ status: 'continue', summary: 'work', evidence: ['x'.repeat(1_001)] },
{ status: 'continue', summary: 'work', evidence: [], nextStep: 4 },
{ status: 'continue', summary: 'work', evidence: [], nextStep: ' ' },
{ status: 'continue', summary: 'work', evidence: [], nextStep: 'x'.repeat(2_001) },
];
for (const report of invalidReports) {
await expect(pi.report(report)).rejects.toThrow();
}
expect(stateField(pi, 'phase')).toBe('active');
});
it('rejects an achievement claim without evidence', async () => {
const pi = new FakePi();
await pi.goal('set Require evidence');
await expect(
pi.report({ status: 'achieved', summary: 'Trust me', evidence: [] }),
).rejects.toThrow(/evidence/i);
expect(stateField(pi, 'phase')).toBe('active');
});
it('redacts credential-shaped goal and report text before persistence or display', async () => {
const githubToken = `ghp_${'a'.repeat(32)}`;
const anthropicKey = `sk-ant-api03-${'b'.repeat(40)}`;
const bearerToken = 'header.payload.signature-canary';
const databaseUrl = 'postgresql://mosaic:[email protected]/mosaic';
const password = 'password-canary';
const pi = new FakePi();
await pi.goal(`set Rotate ${githubToken} without retaining it`);
const context = await pi.emit('context', { messages: [] });
expect(activeGoalStatementFromContext(context[0])).not.toContain(githubToken);
const result = await pi.report({
status: 'achieved',
summary: `Validated ${anthropicKey}`,
evidence: [`Authorization: Bearer ${bearerToken}`, `DATABASE_URL=${databaseUrl}`],
nextStep: `password=${password}`,
});
await pi.goal('status');
const persisted = JSON.stringify(latestGoalStateData(pi));
const displayed = pi.notifications.at(-1)?.message ?? '';
const toolOutput = JSON.stringify(result);
for (const secret of [githubToken, anthropicKey, bearerToken, databaseUrl, password]) {
expect(persisted).not.toContain(secret);
expect(displayed).not.toContain(secret);
expect(toolOutput).not.toContain(secret);
}
expect(persisted).toContain('[REDACTED-SECRET]');
});
it('preserves ordinary typed fields that resemble sensitive assignment names', async () => {
const typedFields = 'token: string, password: boolean, secret: false';
const pi = new FakePi();
await pi.goal(`set Preserve TypeScript fields: ${typedFields}`);
await pi.report({
status: 'continue',
summary: `Schema still contains ${typedFields}`,
evidence: [`interface Config { ${typedFields} }`],
nextStep: `Keep ${typedFields} unchanged`,
});
expect(stateField(pi, 'statement')).toContain(typedFields);
expect(JSON.stringify(stateField(pi, 'lastReport'))).toContain(typedFields);
expect(JSON.stringify(latestGoalStateData(pi))).not.toContain('[REDACTED-SECRET]');
});
it('stops autonomous continuation when the max-turn limit is reached', async () => {
vi.stubEnv('MOSAIC_GOAL_MAX_TURNS', '2');
const pi = new FakePi();
await pi.goal('set Bound this run');
pi.sentMessages.length = 0;
await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] });
await pi.emit('turn_end', { turnIndex: 1, message: {}, toolResults: [] });
expect(stateField(pi, 'phase')).toBe('exhausted');
expect(pi.abortCount).toBe(1);
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
});
it('resets the no-progress sequence when a continuation report changes', async () => {
const pi = new FakePi();
await pi.goal('set Track changing progress');
await pi.report({
status: 'continue',
summary: 'First checkpoint',
evidence: ['file A changed'],
nextStep: 'Run focused tests',
});
await pi.report({
status: 'continue',
summary: 'Second checkpoint',
evidence: ['focused tests passed'],
nextStep: 'Review the diff',
});
expect(stateField(pi, 'phase')).toBe('active');
expect(stateField(pi, 'noProgressReports')).toBe(1);
await pi.goal('status');
expect(pi.notifications.at(-1)?.message).toContain('Next step: Review the diff');
expect(pi.notifications.at(-1)?.message).toContain('focused tests passed');
});
it('stops after a bounded number of identical no-progress reports', async () => {
vi.stubEnv('MOSAIC_GOAL_MAX_NO_PROGRESS', '2');
const pi = new FakePi();
await pi.goal('set Detect stalled work');
const report = {
status: 'continue',
summary: 'No change yet',
evidence: ['same observation'],
nextStep: 'Try again',
};
await pi.report(report);
await pi.report(report);
expect(stateField(pi, 'phase')).toBe('exhausted');
expect(stateField(pi, 'noProgressReports')).toBe(2);
pi.sentMessages.length = 0;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
});
it('rejects a goal report mixed with another tool result in the same turn', async () => {
const pi = new FakePi();
await pi.goal('set Require a sole final report');
await pi.report({
status: 'achieved',
summary: 'Premature mixed claim',
evidence: ['one observation'],
});
await pi.emit('turn_end', {
turnIndex: 0,
message: {},
toolResults: [{ toolName: 'mosaic_goal_report' }, { toolName: 'read' }],
});
expect(stateField(pi, 'phase')).toBe('active');
expect(stateField(pi, 'verificationPasses')).toBe(0);
expect(stateField(pi, 'lastCheckOutcome')).toBe('mixed-goal-report-rejected');
expect(pi.notifications.at(-1)?.level).toBe('warning');
});
it('marks blocked reports terminal until the operator resumes', async () => {
const pi = new FakePi();
await pi.goal('set Stop on a real blocker');
await pi.report({
status: 'blocked',
summary: 'Missing required access',
evidence: ['provider returned 403'],
});
expect(stateField(pi, 'phase')).toBe('blocked');
pi.sentMessages.length = 0;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(0);
await pi.goal('resume');
expect(stateField(pi, 'phase')).toBe('active');
expect(stateField(pi, 'turnCount')).toBe(0);
expect(pi.sentMessages).toHaveLength(1);
});
});
describe('Mosaic Pi goal compaction and restoration', () => {
it('resets provisional verification and defers manual-compaction continuation until idle', async () => {
vi.useFakeTimers();
const pi = new FakePi();
await pi.goal('set Survive compaction');
await pi.report({
status: 'achieved',
summary: 'Initial claim',
evidence: ['focused test passed'],
});
expect(stateField(pi, 'phase')).toBe('verifying');
pi.sentMessages.length = 0;
await pi.emit('session_compact', { reason: 'manual', willRetry: false });
expect(stateField(pi, 'phase')).toBe('active');
expect(stateField(pi, 'verificationPasses')).toBe(0);
expect(stateField(pi, 'compactionCount')).toBe(1);
expect(stateField(pi, 'lastCheckSource')).toBe('compact');
expect(pi.sentMessages).toHaveLength(0);
await vi.runAllTimersAsync();
expect(pi.sentMessages).toHaveLength(1);
expect(pi.sentMessages[0]?.message.content).toContain('compaction');
});
it('does not re-enter an active automatic compaction and relies on the settled backstop', async () => {
vi.useFakeTimers();
const pi = new FakePi();
await pi.goal('set Avoid compaction races');
pi.sentMessages.length = 0;
pi.idle = false;
await pi.emit('session_compact', { reason: 'threshold', willRetry: false });
await vi.runAllTimersAsync();
expect(pi.sentMessages).toHaveLength(0);
pi.idle = true;
await pi.emit('agent_settled');
expect(pi.sentMessages).toHaveLength(1);
});
it('restores branch-specific state on session start and tree navigation', async () => {
vi.useFakeTimers();
const source = new FakePi();
await source.goal('set Restore this exact branch goal');
const activeState = latestGoalStateData(source);
const restored = new FakePi([
{ type: 'custom', customType: 'mosaic-goal-state', data: activeState },
]);
await restored.emit('session_start', { reason: 'resume' });
expect(restored.statuses.get('mosaic-goal')).toContain('active');
const context = await restored.emit('context', { messages: [] });
expect(activeGoalStatementFromContext(context[0])).toContain('Restore this exact branch goal');
await restored.goal('pause');
const pausedState = latestGoalStateData(restored);
restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: pausedState }];
await restored.emit('session_tree', {});
await vi.runAllTimersAsync();
expect(stateField(restored, 'phase')).toBe('paused');
expect(restored.sentMessages).toHaveLength(0);
});
it('restores only fully valid persisted states and ignores malformed entries', async () => {
vi.useFakeTimers();
const source = new FakePi();
await source.goal('set Validate persisted branch state');
await source.report({
status: 'continue',
summary: 'Valid report',
evidence: ['valid evidence'],
nextStep: 'Continue validation',
});
const valid = latestGoalStateData(source);
const validRestore = new FakePi([
{ type: 'custom', customType: 'mosaic-goal-state', data: valid },
]);
await validRestore.emit('session_start', { reason: 'resume' });
expect(stateField(validRestore, 'statement')).toBe('Validate persisted branch state');
await validRestore.emit('session_shutdown', { reason: 'reload' });
const validReport = latestGoalStateData(source)['lastReport'];
if (!isRecord(validReport)) throw new Error('Expected a valid persisted report fixture');
const integerFields = [
'turnCount',
'reportCount',
'verificationPasses',
'requiredVerificationPasses',
'noProgressReports',
'maxTurns',
'maxNoProgressReports',
'compactionCount',
];
const corruptions: Array<(state: Record<string, unknown>) => unknown> = [
(): unknown => null,
(state): unknown => ({ ...state, version: 99 }),
(state): unknown => ({ ...state, goalId: '' }),
(state): unknown => ({ ...state, statement: '' }),
(state): unknown => ({ ...state, statement: 'x'.repeat(8_001) }),
(state): unknown => ({ ...state, phase: 'unknown' }),
(state): unknown => ({ ...state, lastCheckSource: 'unknown' }),
(state): unknown => ({ ...state, startedAt: 4 }),
(state): unknown => ({ ...state, lastCheckAt: 4 }),
(state): unknown => ({ ...state, lastReport: null }),
(state): unknown => ({ ...state, lastProgressFingerprint: 4 }),
(state): unknown => ({ ...state, stopReason: 4 }),
...integerFields.map((field: string) => (state: Record<string, unknown>): unknown => ({
...state,
[field]: -1,
})),
];
corruptions.push(
(state: Record<string, unknown>): unknown => ({ ...state, maxTurns: 501 }),
(state: Record<string, unknown>): unknown => ({ ...state, maxNoProgressReports: 101 }),
(state: Record<string, unknown>): unknown => ({ ...state, requiredVerificationPasses: 3 }),
);
const reportCorruptions: Array<Record<string, unknown>> = [
{ ...validReport, status: 'bad' },
{ ...validReport, summary: '' },
{ ...validReport, evidence: 'bad' },
{ ...validReport, evidence: [4] },
{ ...validReport, fingerprint: '' },
{ ...validReport, reportedAt: '' },
{ ...validReport, nextStep: 4 },
];
for (const corruptReport of reportCorruptions) {
corruptions.push((state: Record<string, unknown>): unknown => ({
...state,
lastReport: corruptReport,
}));
}
for (const corrupt of corruptions) {
const candidate = corrupt(structuredClone(valid));
const restored = new FakePi([
{ type: 'custom', customType: 'mosaic-goal-state', data: candidate },
]);
await restored.emit('session_start', { reason: 'resume' });
expect(restored.statuses.get('mosaic-goal')).toBeUndefined();
expect(await restored.emit('context', { messages: [] })).toEqual([undefined]);
}
const failClosed = new FakePi([
{ type: 'custom', customType: 'mosaic-goal-state', data: valid },
{ type: 'custom', customType: 'mosaic-goal-state', data: { ...valid, version: 99 } },
]);
await failClosed.emit('session_start', { reason: 'resume' });
expect(failClosed.statuses.get('mosaic-goal')).toBeUndefined();
});
it('fails closed instead of reusing credential-bearing legacy branch state', async () => {
const source = new FakePi();
await source.goal('set Build a valid restore fixture');
const cleanState = latestGoalStateData(source);
const legacyState = structuredClone(cleanState);
legacyState['statement'] = `Legacy secret ghp_${'z'.repeat(32)}`;
const restored = new FakePi([
{ type: 'custom', customType: 'mosaic-goal-state', data: legacyState },
{ type: 'custom', customType: 'mosaic-goal-state', data: cleanState },
]);
await restored.emit('session_start', { reason: 'resume' });
expect(restored.statuses.get('mosaic-goal')).toBeUndefined();
expect(await restored.emit('context', { messages: [] })).toEqual([undefined]);
expect(restored.notifications.at(-1)?.level).toBe('warning');
expect(restored.notifications.at(-1)?.message).toContain('was not restored');
expect(restored.entries).toHaveLength(0);
});
it('schedules an active tree-restored goal and preserves terminal state through compaction', async () => {
vi.useFakeTimers();
const source = new FakePi();
await source.goal('set Restore active tree work');
const active = latestGoalStateData(source);
const restored = new FakePi();
restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: active }];
await restored.emit('session_tree', {});
await vi.runAllTimersAsync();
expect(restored.sentMessages).toHaveLength(1);
expect(restored.sentMessages[0]?.message.content).toContain('tree navigation');
await restored.goal('cancel');
await restored.emit('session_compact', { reason: 'manual', willRetry: false });
expect(stateField(restored, 'phase')).toBe('cancelled');
expect(stateField(restored, 'compactionCount')).toBe(1);
});
it('cancels deferred continuation when the session shuts down', async () => {
vi.useFakeTimers();
const pi = new FakePi();
await pi.goal('set Do not leak a stale timer');
pi.sentMessages.length = 0;
await pi.emit('session_compact', { reason: 'manual', willRetry: false });
await pi.emit('session_shutdown', { reason: 'reload' });
await vi.runAllTimersAsync();
expect(pi.sentMessages).toHaveLength(0);
});
});
@@ -0,0 +1,28 @@
[
"packages/mosaic/framework/tools/wake/test-wake-digest-quarantine.sh:fixture_line=\"$(has_match -F \"\\\"id\\\":\\\"$enum_id\\\"\" \"$self\" | has_match -F '\"observed_seq\":5' | head -n1)\"",
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:src_seq=\"$(jq -r 'select(.locators.kind == \"repo\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:enum_seq=\"$(jq -r 'select(.locators.reconciled == true) | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:if ! sed -n \"${ln}p\" \"$f\" | grep -Eq 'has_match|count_lines'; then",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outA\" | grep -q 'mini-a: OK' &&",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outB\" | grep -q 'mini-b: OK' &&",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:check C1 1 \"rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff \"$TMP/expected-c1\" \"$TMP/got-c1\" 2>&1 | head -n 10 | tr '\\n' ' ')\"",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: FAILED' &&",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" &&",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ABORT\" &&",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"$site\" &&",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"grep exit 2\" &&",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'WAKE-ASSERT ARMED'; then",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'REACHED-PAST-INIT' &&",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&",
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'all invariants passed' &&",
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:diff \"$TMP/expected.txt\" \"$TMP/static.txt\" | head -n 20 | sed 's/^/ /'",
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:if printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$s\")\"; then",
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" ||",
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit\" ||",
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$f\")\" || rc_sent=$?"
]
+47
View File
@@ -8,6 +8,7 @@ import test from 'node:test';
const ROOT = new URL('../', import.meta.url);
const EXPECTED_BASELINE_SITES = 26;
const EXPECTED_TEST_BASELINE_SITES = 22;
const EXPECTED_WAKE_BASELINE_SITES = 26;
const TARGETS = [
'tools/matrix-presence-harness/run.sh',
'tools/e2e-install-test.sh',
@@ -37,6 +38,10 @@ const TARGETS = [
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh',
'packages/mosaic/framework/tools/tmux/test-send-message-socket.sh',
'packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh',
'packages/mosaic/framework/tools/wake/test-wake-digest-quarantine.sh',
'packages/mosaic/framework/tools/wake/test-wake-preimage.sh',
'packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh',
'packages/mosaic/framework/tools/wake/validate-973/validate-973.sh',
];
// These statuses are explicitly non-load-bearing or unreachable at designed input.
@@ -106,6 +111,14 @@ test('the registered test baseline denominator is exactly 22 unsafe sites', asyn
);
});
test('the registered wake baseline denominator is exactly 26 unsafe sites', async () => {
await assertBaselineFixture(
'scripts/fixtures/pipefail-early-exit-wake-baseline.json',
EXPECTED_WAKE_BASELINE_SITES,
25,
);
});
test('load-bearing pipefail paths do not pipe into early-exiting consumers', async () => {
assert.deepEqual(scan(await currentSources()), []);
});
@@ -138,6 +151,40 @@ test('gateway verify capability preserves the complete help-probe truth table',
}
});
test('wake JSONL selectors take the first match across the complete input stream', async () => {
const source = await readFile(
new URL('packages/mosaic/framework/tools/wake/test-wake-preimage.sh', ROOT),
'utf8',
);
assert.equal((source.match(/jq -nr 'first\(inputs \| select\(/g) ?? []).length, 4);
const directory = await mkdtemp(path.join(tmpdir(), 'wake-jsonl-first-'));
const input = path.join(directory, 'pending.jsonl');
const filter = 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty';
try {
await writeFile(
input,
'{"locators":{"kind":"repo"},"observed_seq":1}\n' +
'{"locators":{"kind":"preimage"},"observed_seq":4}\n' +
'{"locators":{"kind":"preimage"},"observed_seq":9}\n',
);
let result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '4\n');
await writeFile(input, '{"locators":{"kind":"repo"},"observed_seq":1}\n');
result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '');
await writeFile(input, '{invalid json}\n');
result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
assert.notEqual(result.status, 0);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test('board-history preserves non-git data-dir as a non-detectable result', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'reflect-board-non-git-'));
try {