Compare commits

..
Author SHA1 Message Date
Jason WoltjeandClaude Opus 4.8 de0adb9207 test(lease): #1124 regression uses node pane command (real field topology per scooby)
ci/woodpecker/pr/ci Pipeline failed
The mosaic wrapper makes pane_current_command=node (RUNTIME_ACCEPTABLE_COMMANDS.claude=['claude','node']); the walk matters precisely in that no-shell-wrapper case. Match reality.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
2026-08-08 16:52:12 -05:00
Jason WoltjeandClaude Opus 4.8 ff50cbdb61 fix(lease): resolve lease session id from the claude child, not the tmux pane pid (#1124)
ci/woodpecker/pr/ci Pipeline was canceled
The launcher runs the runtime as a spawnSync CHILD of node(mosaic) (deliberate,
per launch.ts:99 — parent survives to propagate signals), so
MOSAIC_LEASE_SESSION_ID lives on the claude child, not the pane's root pid. The
transport read only pane.pid's /proc/environ and returned RESOLVE_FAILED for
every real 'mosaic claude' seat. Now BFS the pane's process subtree (bounded,
injectable children-reader) and read the first descendant that carries a valid
lease id; fail-closed if none. Unit tests now exercise the real walk (pane=node
without lease -> child=claude with lease) rather than mocking the resolution.

Found by scooby greenfield E2E on fomo-lin with proc-level evidence.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
2026-08-08 16:50:37 -05:00
Jason Woltje adb1d7f80e test(lease): align mutator carve-out acceptance
ci/woodpecker/pr/ci Pipeline failed
2026-08-08 16:05:04 -05:00
Jason Woltje a57933a4c4 fix(mosaic): bound promotion transport delivery 2026-08-08 16:05:04 -05:00
Jason Woltje 9d0822570c feat(mosaic): add correlated lease promotion CLI 2026-08-08 16:05:04 -05:00
Jason Woltje a5e88a8d97 feat(lease): add single-turn Claude promotion trigger 2026-08-08 16:05:04 -05:00
Jason WoltjeandClaude Fable 5 eccf9e9a7d fix(lease): raise lease TTL 300s -> 3600s
MAX_LEASE_TTL_SECONDS (daemon cap+default) and DEFAULT_TTL_SECONDS
(lease_promote client) both move to 3600. The 5-minute TTL made
gated-by-default sessions unusable (re-promotion mid-task); 1 hour
matches a working session. Full test:framework-shell RC=0.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
2026-08-08 16:05:04 -05:00
Jason Woltje cc84bbe734 fix(lease): ignore benign observer idle replies 2026-08-08 16:05:04 -05:00
Jason Woltje 353296d3cf fix(lease): constrain read-only tool carve-outs 2026-08-08 16:05:04 -05:00
Jason Woltje 7bada88e4b test(lease): assert pi carve-out capability 2026-08-08 16:05:04 -05:00
Jason Woltje cec20461f0 test(lease): distinguish pi probe timeouts 2026-08-08 16:05:04 -05:00
Jason Woltje f4cae29d0d test(lease): enforce read-only tool invariant 2026-08-08 16:05:04 -05:00
Jason Woltje a96fb8f425 docs: measure pi tool registry 2026-08-08 16:05:04 -05:00
Jason WoltjeandClaude Fable 5 7fad4d9ac2 test: close W-0R review findings — assert the omission notice, skip chmod simulations under root
The independent W-0R review of 3592b92e passed but left two PLAUSIBLE
findings: the stderr notice for a legitimately-omitted operator source was
claimed and never asserted (a silent omission is the original defect in
miniature), and the chmod 0o000 unreadable simulations fail spuriously when
euid==0 (CAP_DAC_OVERRIDE). Falsifier for the new assertion: deleting the
notice block turns the suite red (failures=3); restoring returns green.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EHYXhcCQsL3J1Lnm7EraGq
2026-08-08 16:05:04 -05:00
Jason WoltjeandClaude Opus 5 79e18343ca fix(lease): refuse an incomplete law binding instead of silently shrinking it
build_construction skipped any normative source it could not read
(`except OSError: continue`) and promoted whatever remained. That is not a
degraded binding, it is a forged smaller one: the broker recomputes h_source /
h_payload from the fragments it is SENT (daemon.py:602-616), so an omitted
fragment is internally consistent and PAYLOAD_BINDING_MISMATCH cannot fire. A
partial law promotes exactly like a complete one and nothing downstream can tell
the difference.

Measured before this change, against a seeded home: with only USER.md readable,
the client produced a one-fragment construction with promotion=True. Removing
CONSTITUTION.md, STANDARDS.md or the runtime contract likewise promoted.

The classification mirrors the framework's own file ownership rather than
inventing one:

  * CONSTITUTION.md / AGENTS.md / STANDARDS.md are framework-owned and
    reconciled every upgrade (install.sh FRAMEWORK_OWNED,
    config/file-adapter.ts FRAMEWORK_OWNED_FILES), as is the per-runtime
    RUNTIME.md. Absent => IncompleteBinding. A deployment missing one is broken,
    not minimal.
  * SOUL.md / USER.md are deliberately not seeded by install.sh ("generated by
    `mosaic init`") and TOOLS.md is seeded on first install only, so their
    absence is legitimate. It is reported on stderr, never silent.

Unreadable is handled separately from absent for EVERY source, optional ones
included: a file that will not open is not a file that was never configured, and
collapsing the two is what let a permission change quietly shrink the law.

Also corrects this module's own docstring, which asserted that a VERIFIED lease
means "this agent is running THIS law". It does not. Both sides of the broker's
comparison originate in this client, so it detects corruption in transit and
nothing else. That overstatement is where the belief spread from; the stronger
claim needs the broker re-reading on-disk sources against a manifest the agent
cannot rewrite.

Test: promotion_binding_unittest.py, enumerated in test:framework-shell (the
enumeration guard's population is *test*.sh and does not cover Python, so an
unenumerated test here would simply never run). Falsifier executed: defeating the
guard while leaving the module API intact turns the suite red (12 failures);
restoring it returns green.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EHYXhcCQsL3J1Lnm7EraGq
2026-08-08 16:05:04 -05:00
Jason Woltje 9e59016171 Revert "feat(pi): wire lazy lease promotion into the mutator gate"
This reverts 939f2e04. Keeping the revert rather than dropping the commit,
because the failed attempt is the most useful record on this branch.

The wiring worked mechanically — verified with a live model on sb-it-1-dt: the
receipt was emitted verbatim as a whole message, and the broker token was minted
AND consumed, so observe_receipt and promote_lease both succeeded and the lease
reached VERIFIED.

It failed as a DESIGN, for reasons that are properties of the protocol rather
than of this wiring:

  * It puts control-plane traffic in the user-facing conversation channel. An
    operator asking "what model are you?" received a receipt string instead of an
    answer — the model tried a tool, was blocked, complied with the receipt
    instruction, and in one-shot mode that text turn BECAME the reply. Observed
    twice, non-deterministically.
  * The lease TTL is hard-capped at 300s (MAX_LEASE_TTL_SECONDS; ttl_seconds >
    cap raises INVALID_LEASE_TTL). Measured: allowed at T+0, LEASE_EXPIRED at
    T+310. So the visible cost recurs every five minutes of mutator activity.
  * Model compliance is not guaranteed — one run retried the command instead of
    emitting the receipt.

Any model emission is user-visible, so this is not fixable by better wiring; it
needs a design answer about how promotion is triggered and paid for. That is
under adversarial review (docs/scratchpads/lease-remediation/07-liveness-design-brief.md
in the operator's repo). Promotion triggering will return on its own branch once
that lands.

What remains here is independently sound and unblocked: harness-home isolation,
the immutable launch record, the skills relocation, the promotion client itself
(steps 1/4/5), and the #1087 prefix guard.
2026-08-08 16:05:04 -05:00
Jason Woltje a221b82537 fix(sync-skills): guard the pre-existing prune against an empty prefix (#1087)
prune_stale_links_in_target compared "$resolved" == "$canonical_real/"* while
length-checking only $resolved. If $canonical_real were ever empty the pattern
collapses to == "/"* and matches every absolute path.

The failure is precisely inverted, which is what makes it worth fixing rather
than noting: is_mosaic_skill_name already `continue`s for names that ARE current
mosaic skills, so an empty prefix would delete exactly the FOREIGN symlinks in
every target directory and preserve the mosaic ones. On this host that is 4 base
installs, including codex's own .system entry.

Reported by mos-claude as #1087 after I introduced the same guard in the new
legacy-cleanup path in the previous commit and walked past this instance thirty
lines away. Same defect class, same file, one function apart.

$canonical_real is populated by readlink -f after a mkdir -p, so an empty value
requires readlink to fail — unlikely, but the consequence is deleting operator
symlinks across every harness, which is not a risk worth carrying for one test.
2026-08-08 16:05:04 -05:00
Jason Woltje 603f91b242 feat(pi): wire lazy lease promotion into the mutator gate
Completes the promotion path: the client landed in the previous commit, but
nothing drove step 2 — the model emitting the receipt. This wires it.

LAZY, not at session start. Promotion costs an entire model turn, because the
receipt must be the whole message (hmac.compare_digest, "not a transcript
substring"). Minting at session start would collide with the Constitution's
first-response mode declaration — the two cannot share a message, so requiring
both would be unsatisfiable. Deferring to the first DENIED MUTATOR means the
mode declaration happens first and the receipt gets its own later turn, so no
governance change is needed. A read-only session never pays for promotion at all.

Mechanism: on a MUTATOR_UNVERIFIED denial the tool_call hook mints a challenge
and returns the receipt in the block `reason`, which pi feeds back to the model
as the tool result — the existing injection path already used by
lease-lifecycle.ts. The model emits the receipt as its next message, message_end
ships it to the observer, and the extension then calls observe_receipt +
promote_lease.

Only MUTATOR_UNVERIFIED triggers minting. Other denials (GATE_UNAVAILABLE,
STALE_GENERATION, LEASE_EXPIRED, ANCESTRY_MISMATCH) describe conditions a
receipt cannot fix, and begin_verification revokes before it mints, so minting
there would thrash the broker.

Completion is gated on an EXACT text match against the minted receipt. This is
load-bearing, not defensive: message_end also fires for the message that
CONTAINED the blocked tool call — one turn BEFORE the model answers. An earlier
version completed there, so observe_receipt compared against the wrong text,
failed, and burned the challenge before the model ever emitted it. Matching the
text mirrors the broker's own compare_digest semantics and waits for the right
turn. Confirmed by instrumenting message_end and watching it fire with
pending=yes one message too early.

It never posts the receipt itself. receipt-observer-client.py accepts any
string, so self-posting would satisfy the broker while proving nothing — the
whole point is that a live model echoes a challenge it was given.

Bounded by MAX_PROMOTION_ATTEMPTS: model compliance is not guaranteed (observed
a run where the model retried the command instead of emitting the receipt), so
a non-complying model degrades to today's behaviour — denied mutators — rather
than looping.

Verified with a live model on sb-it-1-dt: receipt emitted verbatim as a whole
message, and the broker token was minted AND consumed, i.e. observe_receipt and
promote_lease both succeeded and the lease reached VERIFIED.

Known limitation: under `pi -p`, the receipt is a text-only turn, which ends the
one-shot loop — so promotion completes but the blocked tool is not retried in
that same invocation. Interactive and durable fleet sessions continue and retry
normally.
2026-08-08 16:05:04 -05:00
Jason Woltje 7984c8a3c8 feat(lease-broker): add the missing promotion client
The enforcement half of the lease broker ships and denies; the promotion half
has no production caller anywhere in the package. Verified across 0.0.48, 0.0.49
and 0.0.50-next.2207: begin_verification / observe_receipt / promote_lease are
invoked only by broker-test-client.ts, the acceptance spec, unit tests, and two
probes under docs/.

Consequence: no lease on any host can reach VERIFIED, so mutator-gate denies
every mutator with MUTATOR_UNVERIFIED via a gate that nothing shipped can
satisfy. Runtimes that enforce the gate in-process (pi, via mosaic-extension's
tool_call hook) are bricked for mutators; runtimes whose gate is wired through a
settings hook escape only when that hook is absent — i.e. by being ungated.

This adds the client. It implements protocol steps 1, 4 and 5:

  1. begin_verification  -> mint a challenge, return the exact receipt text
  2. the MODEL emits that text verbatim as its entire latest message
  3. the runtime adapter ships that message to the observer socket
  4. observe_receipt      -> PENDING_PROMOTION
  5. promote_lease        -> VERIFIED

Step 2 is deliberately NOT implemented here, and that is the point.
is_verbatim_receipt uses hmac.compare_digest against the exact minted string —
explicitly "not a transcript substring" — which makes promotion a LIVENESS
PROOF: it requires a live model that received the challenge in its context and
echoed it exactly.

receipt-observer-client.py will post ANY string as the latest assistant message.
A promotion client that posted its own receipt would satisfy the broker while
proving nothing — a gate-disabler indistinguishable from a working fix unless
someone specifically looks. Emitting the receipt therefore belongs to the runtime
adapter, where a real model turn happens. A local diagnostic that posts its own
receipt exists in the operator's repo and is deliberately NOT shipped here.

The construction binds the exact normative source bytes, so a VERIFIED lease
means "this agent is running THIS law", not merely "this session id is known".
h_source/h_payload are derived by importing the framework's own
normative_fragments.build_payload rather than reimplementing it: the broker
derives them the same way and any divergence yields PAYLOAD_BINDING_MISMATCH.
There must be exactly one implementation.

session_identity() prefers the generation FILE over the env var, matching
lease_generation.py. Sending a generation higher than the broker's would revoke
the session's own authority (daemon.py:342-344), so it never guesses.

Verified end-to-end on sb-it-1-dt under a real lease-gated anchor: a mutator
denied rc=2 MUTATOR_UNVERIFIED, then begin -> observe -> promote -> VERIFIED,
then the same mutator allowed rc=0. Negative controls pass: a fresh session is
still denied, and an unrelated session still reads UNVERIFIED — promotion is
per-session and does not leak.

Still open: adapter wiring for step 2. Lazy promotion on first mutator attempt
avoids colliding with the Constitution's first-response mode declaration, since
compare_digest requires the receipt to be the WHOLE message.
2026-08-08 16:05:04 -05:00
Jason Woltje 5c61d1f1a5 feat(launch): isolate harness homes and record immutable launch provenance
Mosaic wrote into the operator's harness base installs — ~/.claude,
~/.pi/agent, ~/.codex, ~/.config/opencode — for settings, instructions, and a
102-symlink skill farm per harness. Any experiment with hooks or gating
therefore mutated the operator's own tooling, and a broken framework change
could take out the very harness needed to repair it.

Harness home isolation
----------------------
Each runtime now reads config from a dedicated mosaic-owned home via the
harness's own config-dir variable:

  claude    CLAUDE_CONFIG_DIR     ~/.config/mosaic/.claude
  pi        PI_CODING_AGENT_DIR   ~/.config/mosaic/.pi     (replaces ~/.pi/agent)
  codex     CODEX_HOME            ~/.config/mosaic/.codex
  opencode  XDG_CONFIG_HOME       ~/.config/mosaic/.opencode

These paths are manifest-UNKNOWN, so rule 3 (#791) resolves them to operator
ownership and a keep-mode upgrade can neither overwrite nor prune them.
A bare `claude` / `pi` keeps its own config AND auth, making it a structural
break-glass rather than one depending on restoring a file under pressure.

opencode is blunter than the rest: it has no dedicated variable and follows XDG,
so isolation also relocates XDG lookups for anything it spawns. Documented in
place.

mosaic-sync-skills now links into those homes and cleans the legacy farms it
previously planted in base installs. Ownership is proven by RESOLUTION, not by
name — only symlinks resolving inside the canonical/local skills dirs are
removed, mirroring the refusal already in commands/skill.js. Verified against a
real install: codex's own .system directory survived while its 102 mosaic links
were removed. Both resolution prefixes are length-checked first; an empty prefix
would make "$resolved" == "$prefix/"* match every absolute path and delete
foreign symlinks.

Immutable launch record
-----------------------
Every launch now appends one record to fleet/run/sessions/events.ndjson before
exec. Mandatory, mechanical, no model involvement.

pi rewrites its own argv to a bare `pi`, so /proc/<pid>/cmdline destroys the
launch evidence — that has already produced a confident wrong diagnosis ("this
agent bypassed the launcher"), disproved only by the parent's argv and only
because the parent had not yet exited. A record written before exec is the only
place this survives.

The path is the #797 Runtime Session Ledger, already operator-classified and
already covered by test-upgrade-manifest-guard.sh, which seeds it and proves a
populated ledger survives keep-mode upgrades — but nothing shipped ever wrote
it. This implements it in the shape that guard already asserts (0600 files under
a 0700 dir).

`mosaic` writes session.launch; launch-runtime.py appends lease.register with
the broker session id and activation capability. They correlate by an explicit
MOSAIC_LAUNCH_ID, never by pid: execRuntime uses spawnSync, so the runtime is a
child with a different pid.

Records normative fragment digests (CONSTITUTION/AGENTS/SOUL/USER/STANDARDS/
TOOLS/RUNTIME) — the same set the broker hashes for promotion, so drift is
mechanically detectable rather than a matter of judgement.

Credential-safe: env is captured as PRESENT NAMES ONLY, and argv values over
256 bytes become a sha256 + length rather than being inlined.

Also fixes CLI_VERSION resolution: '@mosaicstack/mosaic/package.json' is not in
the package exports map and always throws ERR_PACKAGE_PATH_NOT_EXPORTED.
resolveTool() uses that same failing specifier, which is why its documented
preference for bundled tools over the deployed ~/.config/mosaic copy has never
once applied — noted in place, not fixed here.

Verified on sb-it-1-dt: isolated homes written and base installs byte-identical
for all four harnesses; 408 legacy symlinks removed with 1 foreign entry
preserved; launch records paired across the spawn boundary. typecheck shows zero
errors in launch.ts (the @mosaicstack/types failures are pre-existing and
reproduce on a pristine origin/main worktree).
2026-08-08 16:05:04 -05:00
be-coder-08andJason Woltje 5f36e4eba6 feat(pr-merge): preserve linked authors in squash messages (#1066)
Co-authored-by: be-coder-08 <[email protected]>
2026-08-08 16:05:04 -05:00
be-coder-08andJason Woltje 93f6a9567e fix(ci): remove upgrade rollback signal race (#1060)
Co-authored-by: be-coder-08 <[email protected]>
2026-08-08 16:05:04 -05:00
be-coder-08andJason Woltje ebf5d65a53 test(ci): make queue guard harness deterministic (#1062)
Co-authored-by: be-coder-08 <[email protected]>
2026-08-08 16:05:04 -05:00
mos-dt-0 4df478cdd1 Merge pull request 'chore(sync): merge main → next (B1) — resolve 8 conflicts, restore next current' (#1041) from sync/b1-main-into-next into next
ci/woodpecker/push/publish Pipeline failed
chore(sync): merge main → next (B1) — restore next current, resolve 8 conflicts (#1041)

Brings next current with main incl the RM-03 guard fix (482/5); preserves next's 10 in-flight commits. Closes #1040.
2026-08-03 10:17:06 +00:00
coder-mos2 b8844e1ff0 fix(sync): close local queue semantic merge gap
ci/woodpecker/pr/ci Pipeline was successful
2026-08-02 23:16:09 -05:00
coder-mos2 906ad8dc30 wip(sync): merge main into next with combined resolutions 2026-08-02 23:11:22 -05:00
jason.woltje 193331544d fix(wizard): honor MOSAIC_GATEWAY_SKIP_NPM_INSTALL — unblock install.sh --dev gateway testing (#698)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-10 01:30:10 +00:00
jason.woltje 495f73bfdb fix(wizard): avoid rerunning completed setup steps (#692)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 18:44:35 +00:00
jason.woltje b96cc7982a fix(wizard): report gateway failures before success summary (#691)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 18:14:40 +00:00
jason.woltje 0883fb91ec fix(wizard): resolve skills sync script path (#690)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 17:35:19 +00:00
jason.woltje 56787fabf1 fix(gateway): disable Redis consumers on local tier (#689)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 17:17:24 +00:00
jason.woltjeandClaude Opus 4.8 940ae3cc41 feat(installer): prefer npm next lane (#688)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
--next now prefers a fast npm @next install (CLI + gateway from the Gitea registry) and falls back to source build at next if the dist-tag is unavailable. Registry lane gated to non-dev, non-explicit-ref next installs; CLI/gateway prerelease versions must share a pipeline suffix. Adds tools/install-next-lane.test.sh (wired into CI). PR-event CI 1635 fully green + review-of-record APPROVE (functional install test, head 2fd7cfc3).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 07:14:24 +00:00
jason.woltjeandClaude Opus 4.8 c25a551c28 ci(#462): add durable next publish pipeline (#687)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Durable @next integration-line publish: on next pushes, compute <patch+1>-next.<pipeline#> prerelease versions (in-CI, uncommitted) and publish @mosaicstack/* under the next dist-tag; gateway image sha-only on next. Strict guardrails: next-only, never writes latest, never tags from next; main path unchanged. PR-event CI 1631 fully green + review-of-record APPROVE (head b1a887a2). Guardrails independently verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 05:45:09 +00:00
jason.woltjeandClaude Opus 4.8 94d6538061 feat(installer): add next integration lane (#686)
ci/woodpecker/push/ci Pipeline was successful
Add --next installer flag (build-from-source at the next integration branch; MOSAIC_NEXT=1 env equiv; explicit --ref wins). Three-lane install docs (stable @latest / --next prerelease / --dev source) + @next dist-tag pipeline design doc. Green PR-event CI 1626 + review-of-record APPROVE (head 3a5c12a5).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 05:14:32 +00:00
jason.woltjeandClaude Opus 4.8 a3c1ab923c test(#462): add federation M3 integration coverage (#685)
ci/woodpecker/push/ci Pipeline was successful
FED-M3-10 integration tests for the federation M3 verbs (list/get/scope). Test-infra + docs only; green PR-event CI 1623 (all steps incl ci-postgres).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 04:14:56 +00:00
jason.woltjeandClaude Opus 4.8 838701bde2 feat(#462): add federation get verb (#683)
ci/woodpecker/push/ci Pipeline was successful
FED-M3-06 get verb. Trust boundary mirrors M3-05 AND-intersect (note returned only when owned by subject AND on an authorized mission). Reviewed (review-of-record APPROVE, head 80a259b2) + green PR-event CI 1620.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 03:44:54 +00:00
96 changed files with 7064 additions and 1164 deletions
-4
View File
@@ -46,10 +46,6 @@ 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
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
# and sandboxes a throwaway git repo, so it resolves no real credentials and
# joins CI directly rather than the exclusions file.
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
+104 -5
View File
@@ -1,5 +1,5 @@
# Build, publish npm packages, and push Docker images
# Runs only on main branch push/tag
# Runs on main for stable publishes and on next for integration-line prereleases/images
variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
@@ -23,9 +23,21 @@ variables:
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main]
- branch: [main, next]
event: [push, manual, tag]
steps:
@@ -103,6 +115,84 @@ steps:
depends_on:
- build
publish-next-npm:
image: *node_image
# Durable @next integration-line publish. Runs only on next; never writes
# the latest dist-tag and never commits the computed prerelease versions.
when:
- event: [push, manual]
branch: next
environment:
NPM_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
commands:
- *enable_pnpm
- |
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
exit 1
fi
if [ -z "$CI_PIPELINE_NUMBER" ]; then
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
exit 1
fi
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const roots = ['apps', 'packages', 'plugins'];
const updated = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) updatePackage(packagePath);
walk(fullPath);
}
}
}
function updatePackage(packagePath) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
}
const [, major, minor, patch] = stableMatch;
const oldVersion = manifest.version;
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
}
for (const root of roots) walk(root);
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
for (const line of updated) console.log('[publish-next] ' + line);
NODE
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
exit 1
fi
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
depends_on:
- build
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
# image: *node_image
@@ -134,8 +224,17 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next gateway publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
@@ -146,7 +245,7 @@ steps:
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
@@ -172,7 +271,7 @@ steps:
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
+13 -1
View File
@@ -30,6 +30,16 @@ This installs both components:
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
After install, the wizard runs automatically or you can invoke it manually:
```bash
@@ -361,7 +371,9 @@ The CLI also performs a background update check on every invocation (cached for
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --next # Prerelease lane: source build from next
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
```
@@ -0,0 +1,519 @@
/**
* Federation M3 single-gateway integration tests (FED-M3-10).
*
* Covers MILESTONES.md M3 acceptance:
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
* - #7: max_rows_per_query caps list results.
*
* Strategy:
* - Real PostgreSQL via @mosaicstack/db.
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
*
* Run:
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
* src/__tests__/integration/federation-m3-list.integration.test.ts
*/
import 'reflect-metadata';
import * as crypto from 'node:crypto';
import type { ExecutionContext } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { FastifyReply, FastifyRequest } from 'fastify';
import {
and,
createDb,
eq,
federationGrants,
federationPeers,
inArray,
missionTasks,
missions,
projects,
tasks,
teamMembers,
teams,
type Db,
type DbHandle,
users,
} from '@mosaicstack/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
import { FederationScopeService } from '../../federation/server/scope.service.js';
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
import { ListController } from '../../federation/server/verbs/list.controller.js';
import {
makeMosaicIssuedCert,
makeSelfSignedCert,
} from '../../federation/__tests__/helpers/test-cert.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
interface TestIds {
readonly subjectUserId: string;
readonly otherUserId: string;
readonly peerId: string;
readonly revokedPeerId: string;
readonly activeGrantId: string;
readonly revokedGrantId: string;
readonly subjectProjectId: string;
readonly subjectMissionId: string;
readonly otherProjectId: string;
readonly teamId: string;
readonly unauthorizedTeamId: string;
readonly teamProjectId: string;
readonly taskIds: readonly string[];
readonly excludedTaskIds: readonly string[];
readonly subjectNoteId: string;
readonly otherUserNoteId: string;
}
function pemToDer(pem: string): Buffer {
return Buffer.from(
pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, ''),
'base64',
);
}
function makeFederationRequest(certPem: string): FastifyRequest {
return {
raw: {
socket: {
getPeerCertificate: () => ({
raw: pemToDer(certPem),
serialNumber: CERT_SERIAL_HEX,
}),
},
},
} as unknown as FastifyRequest;
}
function makeGuardContext(request: FastifyRequest): {
readonly context: ExecutionContext;
readonly sent: { statusCode?: number; payload?: unknown };
} {
const sent: { statusCode?: number; payload?: unknown } = {};
const reply = {
status: (statusCode: number) => {
sent.statusCode = statusCode;
return {
header: () => ({
send: (payload: unknown) => {
sent.payload = payload;
},
}),
};
},
} as unknown as FastifyReply;
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => reply,
}),
} as unknown as ExecutionContext;
return { context, sent };
}
async function insertUser(db: Db, id: string, label: string): Promise<void> {
await db.insert(users).values({
id,
name: `${RUN_ID}-${label}`,
email: `${RUN_ID}-${label}@federation-test.invalid`,
emailVerified: false,
});
}
async function seedFixtures(db: Db): Promise<TestIds> {
const subjectUserId = `${RUN_ID}-subject`;
const otherUserId = `${RUN_ID}-other`;
const peerId = crypto.randomUUID();
const revokedPeerId = crypto.randomUUID();
const activeGrantId = crypto.randomUUID();
const revokedGrantId = crypto.randomUUID();
const subjectProjectId = crypto.randomUUID();
const subjectMissionId = crypto.randomUUID();
const otherProjectId = crypto.randomUUID();
const teamId = crypto.randomUUID();
const unauthorizedTeamId = crypto.randomUUID();
const teamProjectId = crypto.randomUUID();
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
const subjectNoteId = crypto.randomUUID();
const otherUserNoteId = crypto.randomUUID();
await insertUser(db, subjectUserId, 'subject');
await insertUser(db, otherUserId, 'other');
await db.insert(teams).values([
{
id: teamId,
name: `${RUN_ID} allowed team`,
slug: `${RUN_ID}-allowed-team`,
ownerId: subjectUserId,
managerId: subjectUserId,
},
{
id: unauthorizedTeamId,
name: `${RUN_ID} unauthorized team`,
slug: `${RUN_ID}-unauthorized-team`,
ownerId: otherUserId,
managerId: otherUserId,
},
]);
await db.insert(teamMembers).values([
{ teamId, userId: subjectUserId, role: 'member' },
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
]);
await db.insert(projects).values([
{
id: subjectProjectId,
name: `${RUN_ID} subject personal project`,
ownerType: 'user',
ownerId: subjectUserId,
},
{
id: otherProjectId,
name: `${RUN_ID} other personal project`,
ownerType: 'user',
ownerId: otherUserId,
},
{
id: teamProjectId,
name: `${RUN_ID} unauthorized team project`,
ownerType: 'team',
teamId: unauthorizedTeamId,
},
]);
await db.insert(missions).values({
id: subjectMissionId,
name: `${RUN_ID} subject mission`,
projectId: subjectProjectId,
userId: subjectUserId,
});
await db.insert(tasks).values([
{
id: taskIds[0],
title: `${RUN_ID} visible task 1`,
missionId: subjectMissionId,
createdAt: new Date('2026-06-25T03:00:00.000Z'),
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
},
{
id: taskIds[1],
title: `${RUN_ID} visible task 2`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T02:00:00.000Z'),
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
},
{
id: taskIds[2],
title: `${RUN_ID} visible task 3`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T01:00:00.000Z'),
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
},
{
id: excludedTaskIds[0],
title: `${RUN_ID} other user task`,
projectId: otherProjectId,
createdAt: new Date('2026-06-25T04:00:00.000Z'),
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
},
{
id: excludedTaskIds[1],
title: `${RUN_ID} unauthorized team task`,
projectId: teamProjectId,
createdAt: new Date('2026-06-25T05:00:00.000Z'),
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
},
]);
await db.insert(missionTasks).values([
{
id: subjectNoteId,
missionId: subjectMissionId,
userId: subjectUserId,
notes: `${RUN_ID} subject visible note`,
createdAt: new Date('2026-06-25T03:30:00.000Z'),
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
},
{
id: otherUserNoteId,
missionId: subjectMissionId,
userId: otherUserId,
notes: `${RUN_ID} other user note on subject mission`,
createdAt: new Date('2026-06-25T04:30:00.000Z'),
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
},
]);
await db.insert(federationPeers).values([
{
id: peerId,
commonName: `${RUN_ID}-active-peer`,
displayName: `${RUN_ID} Active Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: CERT_SERIAL_HEX,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
{
id: revokedPeerId,
commonName: `${RUN_ID}-revoked-peer`,
displayName: `${RUN_ID} Revoked Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
]);
await db.insert(federationGrants).values([
{
id: activeGrantId,
peerId,
subjectUserId,
status: 'active',
scope: {
resources: ['tasks', 'notes'],
excluded_resources: [],
filters: {
tasks: { include_personal: true, include_teams: [] },
notes: { include_personal: true, include_teams: [] },
},
max_rows_per_query: 2,
},
},
{
id: revokedGrantId,
peerId,
subjectUserId,
status: 'revoked',
revokedAt: new Date(),
revokedReason: `${RUN_ID} revoked grant fixture`,
scope: {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 2,
},
},
]);
return {
subjectUserId,
otherUserId,
peerId,
revokedPeerId,
activeGrantId,
revokedGrantId,
subjectProjectId,
subjectMissionId,
otherProjectId,
teamId,
unauthorizedTeamId,
teamProjectId,
taskIds,
excludedTaskIds,
subjectNoteId,
otherUserNoteId,
};
}
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
if (!ids) {
return;
}
await db
.delete(missionTasks)
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
.catch(() => {});
await db
.delete(tasks)
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
.catch(() => {});
await db
.delete(missions)
.where(eq(missions.id, ids.subjectMissionId))
.catch(() => {});
await db
.delete(projects)
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
.catch(() => {});
await db
.delete(teamMembers)
.where(
and(
eq(teamMembers.userId, ids.subjectUserId),
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
),
)
.catch(() => {});
await db
.delete(teams)
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
.catch(() => {});
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
.catch(() => {});
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
.catch(() => {});
await db
.delete(users)
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
.catch(() => {});
}
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
let handle: DbHandle;
let db: Db;
let moduleRef: TestingModule;
let guard: FederationAuthGuard;
let listController: ListController;
let ids: TestIds | undefined;
beforeAll(async () => {
handle = createDb(PG_URL);
db = handle.db;
ids = await seedFixtures(db);
moduleRef = await Test.createTestingModule({
controllers: [ListController],
providers: [
{ provide: DB, useValue: db },
GrantsService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
}).compile();
guard = moduleRef.get(FederationAuthGuard);
listController = moduleRef.get(ListController);
}, 30_000);
afterAll(async () => {
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
});
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
const malformedOidCert = await makeSelfSignedCert();
const request = makeFederationRequest(malformedOidCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(401);
expect(sent.payload).toMatchObject({
error: {
code: 'unauthorized',
message: expect.stringContaining('missing required OID'),
},
});
expect(request.federationContext).toBeUndefined();
});
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
expect(ids).toBeDefined();
const revokedCert = await makeMosaicIssuedCert({
grantId: ids!.revokedGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(revokedCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(403);
expect(sent.payload).toMatchObject({
error: {
code: 'forbidden',
message: 'Federation access denied',
},
});
expect(request.federationContext).toBeUndefined();
});
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('tasks', request, { limit: 100 });
const returnedIds = response.items.map((item) => item['id']);
expect(response.items).toHaveLength(2);
expect(response._truncated).toBe(true);
expect(response.nextCursor).toEqual(expect.any(String));
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
expect(returnedIds).not.toContain(ids!.taskIds[2]);
for (const excludedId of ids!.excludedTaskIds) {
expect(returnedIds).not.toContain(excludedId);
}
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('excludes another user mission task notes on the same authorized mission', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('notes', request, { limit: 10 });
const returnedIds = response.items.map((item) => item['id']);
expect(returnedIds).toEqual([ids!.subjectNoteId]);
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('fails closed for unsupported list resources', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is not supported',
},
},
status: 403,
});
});
});
@@ -1,9 +1,11 @@
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { AdminGuard } from './admin.guard.js';
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
@@ -14,6 +16,9 @@ export class AdminHealthController {
@Inject(DB) private readonly db: Db,
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(ProviderService) private readonly providerService: ProviderService,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {}
@Get()
@@ -55,6 +60,14 @@ export class AdminHealthController {
}
private async checkCache(): Promise<ServiceStatusDto> {
// On Local tier there is no Redis. The cache is intentionally absent, which
// is a healthy state for this tier — report 'ok' rather than opening a new
// ioredis connection on every admin health check (which would spam
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
// signals "no cache backend to measure" for this tier.
if (this.mosaicConfig?.queue?.type === 'local') {
return { status: 'ok', latencyMs: 0 };
}
const start = Date.now();
const handle = createQueue();
try {
@@ -72,13 +72,13 @@ const mockChatGateway = {
broadcastSessionInfo: vi.fn(),
};
function buildService(): CommandExecutorService {
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
return new CommandExecutorService(
mockRegistry as never,
mockAgentService as never,
mockSystemOverride as never,
mockSessionGC as never,
mockRedis as never,
redis as never,
mockBrain as never,
null,
mockChatGateway as never,
@@ -131,6 +131,22 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(ttl).toBe(300);
});
it('/provider login remains available without Redis on the local tier', async () => {
const localService = buildService(null);
const payload: SlashCommandPayload = {
command: 'provider',
args: 'login anthropic',
conversationId,
};
const result = await localService.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).not.toContain('token=');
expect(result.data).toEqual({ provider: 'anthropic' });
expect(mockRedis.set).not.toHaveBeenCalled();
});
// /provider with no args — returns usage
it('/provider with no args returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'provider', conversationId };
@@ -23,7 +23,10 @@ export class CommandExecutorService {
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
@Optional()
@Inject(COMMANDS_REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(BRAIN) private readonly brain: Brain,
@Optional()
@Inject(forwardRef(() => ReloadService))
@@ -443,6 +446,7 @@ export class CommandExecutorService {
byte.toString(16).padStart(2, '0'),
).join('');
const key = `mosaic:auth:poll:${tokenHash}`;
if (this.redis) {
// Persist only a short-lived token digest. The raw token is delivered only by
// the authenticated dashboard flow, never in chat output or command metadata.
await this.redis.set(
@@ -451,6 +455,7 @@ export class CommandExecutorService {
'EX',
300,
);
}
return {
command: 'provider',
success: true,
+15 -5
View File
@@ -1,5 +1,7 @@
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js';
@@ -16,13 +18,17 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
providers: [
{
provide: COMMANDS_QUEUE_HANDLE,
useFactory: (): QueueHandle => {
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
// On Local tier there is no Redis — skip the ioredis connection.
// CommandExecutorService falls back to no-cache for /provider login on local.
if (config?.queue?.type === 'local') return null;
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: COMMANDS_REDIS,
useFactory: (handle: QueueHandle) => handle.redis,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
inject: [COMMANDS_QUEUE_HANDLE],
},
CommandRegistryService,
@@ -38,9 +44,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
],
})
export class CommandsModule implements OnApplicationShutdown {
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
constructor(
@Optional()
@Inject(COMMANDS_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> {
await this.handle.close().catch(() => {});
await this.handle?.close().catch(() => {});
}
}
@@ -245,21 +245,9 @@ describe('EnrollmentService.createToken', () => {
const after = Date.now();
const expiresMs = new Date(result.expiresAt).getTime();
// The property under test is CLAMPING: a 9999s request must come back as 900s.
// The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below
// only has to exceed CI scheduling jitter — it does not need to be tight to keep
// the assertion discriminating. A 5s allowance consumes 0.05% of that margin and
// an unclamped result still misses by three orders of magnitude.
//
// It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A
// wall-clock budget sized to a fast machine is a flake, not a tighter test.
const CI_JITTER_MS = 5_000;
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS);
// Should be at most 900s from now
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
// Explicitly pin the clamp itself, independent of any timing allowance:
// unclamped (9999s) would exceed this by ~9_099_000 ms.
expect(expiresMs - before).toBeLessThan(1_000_000);
});
});
@@ -5,6 +5,8 @@ import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GetController } from './server/verbs/get.controller.js';
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
import { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
@@ -12,7 +14,13 @@ import { ListController } from './server/verbs/list.controller.js';
import { FederationListQueryService } from './server/verbs/list-query.service.js';
@Module({
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
controllers: [
EnrollmentController,
FederationController,
CapabilitiesController,
ListController,
GetController,
],
providers: [
AdminGuard,
CaService,
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
FederationGetQueryService,
],
exports: [
CaService,
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
FederationGetQueryService,
],
})
export class FederationModule {}
@@ -0,0 +1,348 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
createPgliteDb,
missionTasks,
missions,
projects,
runPgliteMigrations,
teams,
users,
type Db,
type DbHandle,
} from '@mosaicstack/db';
import type { FederationScopeQueryFilter } from '../../scope.service.js';
import { FederationGetQueryService } from '../get-query.service.js';
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
resource: 'credentials',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: [],
limit: 1,
maxRowsPerQuery: 25,
};
const SUBJECT_USER_ID = 'fed-m3-06-subject';
const OTHER_USER_ID = 'fed-m3-06-other';
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
let dbHandle: DbHandle | undefined;
function makeService() {
return new FederationGetQueryService({} as Db);
}
function makeDbService() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
return new FederationGetQueryService(dbHandle.db);
}
async function seedNotesFixture() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
await dbHandle.db.insert(users).values([
{
id: SUBJECT_USER_ID,
name: 'Federation Subject',
email: `${SUBJECT_USER_ID}@example.test`,
emailVerified: false,
},
{
id: OTHER_USER_ID,
name: 'Federation Other',
email: `${OTHER_USER_ID}@example.test`,
emailVerified: false,
},
]);
await dbHandle.db.insert(teams).values([
{
id: TEAM_ID,
name: 'FED-M3-06 Team',
slug: 'fed-m3-06-team',
ownerId: SUBJECT_USER_ID,
managerId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_TEAM_ID,
name: 'FED-M3-06 Unauthorized Team',
slug: 'fed-m3-06-unauthorized-team',
ownerId: OTHER_USER_ID,
managerId: OTHER_USER_ID,
},
]);
await dbHandle.db.insert(projects).values([
{
id: PERSONAL_PROJECT_ID,
name: 'FED-M3-06 Personal Project',
ownerId: SUBJECT_USER_ID,
ownerType: 'user',
},
{
id: TEAM_PROJECT_ID,
name: 'FED-M3-06 Team Project',
teamId: TEAM_ID,
ownerType: 'team',
},
{
id: UNAUTHORIZED_PROJECT_ID,
name: 'FED-M3-06 Unauthorized Project',
teamId: UNAUTHORIZED_TEAM_ID,
ownerType: 'team',
},
]);
await dbHandle.db.insert(missions).values([
{
id: PERSONAL_MISSION_ID,
name: 'FED-M3-06 Personal Mission',
projectId: PERSONAL_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: TEAM_MISSION_ID,
name: 'FED-M3-06 Team Mission',
projectId: TEAM_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_MISSION_ID,
name: 'FED-M3-06 Unauthorized Mission',
projectId: UNAUTHORIZED_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
]);
await dbHandle.db.insert(missionTasks).values([
{
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on team mission',
createdAt: new Date('2026-06-24T03:00:00.000Z'),
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
},
{
id: OTHER_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: OTHER_USER_ID,
notes: 'other user note on team mission',
createdAt: new Date('2026-06-24T02:00:00.000Z'),
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
},
{
id: SUBJECT_PERSONAL_NOTE_ID,
missionId: PERSONAL_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on personal mission',
createdAt: new Date('2026-06-24T01:00:00.000Z'),
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
},
{
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
missionId: UNAUTHORIZED_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note outside grant-visible missions',
createdAt: new Date('2026-06-24T04:00:00.000Z'),
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
},
]);
}
describe('FederationGetQueryService', () => {
beforeAll(async () => {
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
await runPgliteMigrations(dbHandle);
await seedNotesFixture();
});
afterAll(async () => {
await dbHandle?.close();
dbHandle = undefined;
});
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
}),
).resolves.toMatchObject({
allowed: false,
reason: 'credentials federation get access is not implemented in M3',
});
});
it('allows personal memory reads without requiring team lookup', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'memory',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
});
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
const service = makeService();
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
(
service as unknown as {
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
}
).listSubjectTeamIds = listSubjectTeamIds;
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'tasks',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
});
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
});
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
const service = makeService();
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
status: 'denied',
reason: 'credentials federation get is not implemented',
});
});
it('fails closed for unsupported resources instead of returning undefined', async () => {
const service = makeService();
await expect(
service.get({
filter: {
...CREDENTIAL_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
},
id: 'row-1',
}),
).resolves.toEqual({
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
});
});
it('does not leak another user mission task note through team-scoped get reads', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: OTHER_TEAM_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('does not return subject notes from missions outside the grant-visible project set', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_TEAM_NOTE_ID,
}),
).resolves.toMatchObject({
status: 'found',
item: {
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
content: 'subject note on team mission',
},
});
});
it('does not return subject personal notes when includePersonal is false', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_PERSONAL_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
});
@@ -0,0 +1,207 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationAuthGuard } from '../../federation-auth.guard.js';
import type {
FederationScopeEvaluationResult,
FederationScopeQueryFilter,
} from '../../scope.service.js';
import { GetController } from '../get.controller.js';
import type { FederationGetQueryResult } from '../get-query.service.js';
const FEDERATION_CONTEXT = {
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
scope: { resources: ['tasks'], max_rows_per_query: 25 },
};
const TASK_FILTER: FederationScopeQueryFilter = {
resource: 'tasks',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: ['team-1'],
limit: 1,
maxRowsPerQuery: 25,
};
function makeRequest(): FastifyRequest {
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
}
function allowedScope(
filter: FederationScopeQueryFilter = TASK_FILTER,
): FederationScopeEvaluationResult {
return { allowed: true, filter };
}
function makeController(opts?: {
scopeResult?: FederationScopeEvaluationResult;
queryResult?: FederationGetQueryResult;
}) {
const scope = {
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
};
const query = {
evaluateReadAccess: vi.fn(),
get: vi.fn().mockResolvedValue(
opts?.queryResult ?? {
status: 'found',
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
},
},
),
};
return {
controller: new GetController(scope as never, query as never),
scope,
query,
};
}
describe('GetController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
});
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
const { controller, scope, query } = makeController();
const response = await controller.get('tasks', 'task-1', makeRequest());
expect(scope.evaluateAccess).toHaveBeenCalledWith({
context: FEDERATION_CONTEXT,
resource: 'tasks',
requestedLimit: 1,
nativeRbac: query,
});
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
expect(response).toEqual({
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
_source: 'local',
},
});
});
it('returns a federation error envelope when auth guard context is missing', async () => {
const { controller, scope, query } = makeController();
await expect(
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
).rejects.toMatchObject({
response: {
error: {
code: 'unauthorized',
message: 'Federation context missing',
},
},
status: 401,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).not.toHaveBeenCalled();
});
it('returns a federation error envelope when scope evaluation denies access', async () => {
const { controller, query } = makeController({
scopeResult: {
allowed: false,
deny: {
code: 'resource_excluded',
stage: 'resource_exclusion',
statusCode: 403,
message: 'Requested federation resource is explicitly excluded by grant scope',
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
},
},
});
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is explicitly excluded by grant scope',
},
},
status: 403,
});
expect(query.get).not.toHaveBeenCalled();
});
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
const { controller } = makeController({ queryResult: { status: 'not_found' } });
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'not_found' } },
status: 404,
});
});
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
const { controller } = makeController({
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
});
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Task is outside the federated scope',
},
},
status: 403,
});
});
it('fails closed when the query layer denies an unsupported resource', async () => {
const unsupportedFilter: FederationScopeQueryFilter = {
...TASK_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
};
const { controller } = makeController({
scopeResult: allowedScope(unsupportedFilter),
queryResult: {
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
},
});
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Unsupported federation get resource: unknown-resource',
},
},
status: 403,
});
});
it('rejects empty ids before evaluating scope', async () => {
const { controller, scope, query } = makeController();
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,311 @@
/**
* Federation get query layer (FED-M3-06).
*
* Read-only DB adapter used by GetController after FederationAuthGuard and
* FederationScopeService have established the subject user, allowed resource,
* native-RBAC intersection, and row cap. Audit writes are intentionally
* deferred to M4.
*/
import { Inject, Injectable } from '@nestjs/common';
import {
and,
eq,
inArray,
insights,
or,
missionTasks,
missions,
preferences,
projects,
tasks,
teamMembers,
type Db,
} from '@mosaicstack/db';
import { DB } from '../../../database/database.module.js';
import type {
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeQueryFilter,
} from '../scope.service.js';
export interface FederationGetQueryRequest {
readonly filter: FederationScopeQueryFilter;
readonly id: string;
}
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
readonly status: 'found';
readonly item: T;
}
export interface FederationGetQueryNotFoundResult {
readonly status: 'not_found';
}
export interface FederationGetQueryDeniedResult {
readonly status: 'denied';
readonly reason: string;
}
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
| FederationGetQueryFoundResult<T>
| FederationGetQueryNotFoundResult
| FederationGetQueryDeniedResult;
type RowObject = Record<string, unknown>;
function firstRow<T>(rows: T[]): T | undefined {
return rows[0];
}
function rowBelongsToAccessibleProjectOrMission(
row: { projectId?: string | null; missionId?: string | null },
projectIds: readonly string[],
missionIds: readonly string[],
): boolean {
return (
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
);
}
@Injectable()
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
constructor(@Inject(DB) private readonly db: Db) {}
async evaluateReadAccess(
request: FederationNativeRbacRequest,
): Promise<FederationNativeRbacResult> {
if (request.resource === 'credentials' || request.resource === 'api_keys') {
return {
allowed: false,
reason: `${request.resource} federation get access is not implemented in M3`,
details: { resource: request.resource },
};
}
if (request.resource === 'memory') {
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
}
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
return { allowed: true, access: { includePersonal: true, teamIds } };
}
async get<T extends RowObject = RowObject>(
request: FederationGetQueryRequest,
): Promise<FederationGetQueryResult<T>> {
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
}
private async getByResource(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
switch (filter.resource) {
case 'tasks':
return this.getTask(filter, id);
case 'notes':
return this.getNote(filter, id);
case 'memory':
return this.getMemory(filter, id);
case 'credentials':
case 'api_keys':
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
default:
return {
status: 'denied',
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
};
}
}
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
const rows = await this.db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, subjectUserId));
return rows.map((row) => row.teamId);
}
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
const clauses = [];
if (filter.includePersonal) {
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
}
if (filter.teamIds.length > 0) {
// Project team ownership follows TeamsService.canAccessProject: team-owned
// rows are authorized through projects.teamId, while ownerId remains the
// user who created/bootstrapped the project.
clauses.push(
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
);
}
if (clauses.length === 0) {
return [];
}
const rows = await this.db
.select({ id: projects.id })
.from(projects)
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
return rows.map((row) => row.id);
}
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
if (projectIds.length === 0) {
return [];
}
const rows = await this.db
.select({ id: missions.id })
.from(missions)
.where(inArray(missions.projectId, [...projectIds]));
return rows.map((row) => row.id);
}
private async getTask(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
await this.db
.select({
id: tasks.id,
title: tasks.title,
description: tasks.description,
status: tasks.status,
priority: tasks.priority,
projectId: tasks.projectId,
missionId: tasks.missionId,
assignee: tasks.assignee,
tags: tasks.tags,
dueDate: tasks.dueDate,
metadata: tasks.metadata,
createdAt: tasks.createdAt,
updatedAt: tasks.updatedAt,
})
.from(tasks)
.where(eq(tasks.id, id))
.limit(1),
);
if (!row) {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
return { status: 'denied', reason: 'Task is outside the federated scope' };
}
return { status: 'found', item: row as RowObject };
}
private async getNote(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
await this.db
.select({
id: missionTasks.id,
missionId: missionTasks.missionId,
taskId: missionTasks.taskId,
userId: missionTasks.userId,
status: missionTasks.status,
content: missionTasks.notes,
createdAt: missionTasks.createdAt,
updatedAt: missionTasks.updatedAt,
})
.from(missionTasks)
.where(eq(missionTasks.id, id))
.limit(1),
);
if (!row || row.content === null || row.content === '') {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
// mission_tasks rows are user-scoped even when the mission belongs to a team.
// Scope-visible missions must intersect with subject ownership; team scope
// narrows mission IDs but never widens note reads to another user's rows.
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
return { status: 'denied', reason: 'Note is outside the federated scope' };
}
const item = { ...row } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
private async getMemory(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const [insightRow, preferenceRow] = await Promise.all([
this.db
.select({
id: insights.id,
userId: insights.userId,
kind: insights.source,
content: insights.content,
category: insights.category,
relevanceScore: insights.relevanceScore,
metadata: insights.metadata,
createdAt: insights.createdAt,
updatedAt: insights.updatedAt,
})
.from(insights)
.where(eq(insights.id, id))
.limit(1)
.then(firstRow),
this.db
.select({
id: preferences.id,
userId: preferences.userId,
kind: preferences.category,
key: preferences.key,
value: preferences.value,
source: preferences.source,
mutable: preferences.mutable,
createdAt: preferences.createdAt,
updatedAt: preferences.updatedAt,
})
.from(preferences)
.where(eq(preferences.id, id))
.limit(1)
.then(firstRow),
]);
const candidates = [insightRow, preferenceRow].filter(
(row): row is NonNullable<typeof row> => row !== undefined,
);
if (candidates.length === 0) {
return { status: 'not_found' };
}
if (!filter.includePersonal) {
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
}
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
if (!accessible) {
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
}
const item = { ...accessible } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
}
@@ -0,0 +1,100 @@
/**
* Federation get verb (FED-M3-06).
*
* POST /api/federation/v1/get/:resource/:id
*
* Pipeline: FederationAuthGuard attaches the active grant context, then
* FederationScopeService enforces grant scope + native RBAC intersection, then
* the read-only query layer fetches one local row and tags it with `_source`.
* Read audit-log writes are deferred to M4; this controller does not persist
* request or response bodies.
*/
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FederationInvalidRequestError,
FederationNotFoundError,
FederationScopeViolationError,
FederationUnauthorizedError,
SOURCE_LOCAL,
type FederationGetResponse,
type SourceTag,
} from '@mosaicstack/types';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
import { FederationScopeService } from '../scope.service.js';
import { FederationGetQueryService } from './get-query.service.js';
type FederatedRow = Record<string, unknown> & SourceTag;
function scopeDenyToHttpException(deny: {
readonly statusCode: 400 | 403;
readonly message: string;
}): HttpException {
const ErrorClass =
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
}
@Controller('api/federation/v1/get')
@UseGuards(FederationAuthGuard)
export class GetController {
constructor(
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
) {}
@Post(':resource/:id')
async get(
@Param('resource') resource: string,
@Param('id') id: string,
@Req() request: FastifyRequest,
): Promise<FederationGetResponse<FederatedRow>> {
if (!request.federationContext) {
throw new HttpException(
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
401,
);
}
if (id.trim().length === 0) {
throw new HttpException(
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
400,
);
}
const scopeResult = await this.scope.evaluateAccess({
context: request.federationContext,
resource,
requestedLimit: 1,
nativeRbac: this.query,
});
if (!scopeResult.allowed) {
throw scopeDenyToHttpException(scopeResult.deny);
}
const result = await this.query.get({ filter: scopeResult.filter, id });
if (result.status === 'not_found') {
throw new HttpException(
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
404,
);
}
if (result.status === 'denied') {
throw new HttpException(
new FederationScopeViolationError(result.reason, {
resource,
id,
grantId: request.federationContext.grantId,
peerId: request.federationContext.peerId,
subjectUserId: request.federationContext.subjectUserId,
}).toEnvelope(),
403,
);
}
return { item: { ...result.item, _source: SOURCE_LOCAL } };
}
}
+15 -5
View File
@@ -1,5 +1,7 @@
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { SessionGCService } from './session-gc.service.js';
import { REDIS } from './gc.tokens.js';
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
providers: [
{
provide: GC_QUEUE_HANDLE,
useFactory: (): QueueHandle => {
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
// On Local tier there is no Redis — skip the ioredis connection entirely.
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
if (config?.queue?.type === 'local') return null;
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: REDIS,
useFactory: (handle: QueueHandle) => handle.redis,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
inject: [GC_QUEUE_HANDLE],
},
SessionGCService,
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
exports: [SessionGCService],
})
export class GCModule implements OnApplicationShutdown {
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
constructor(
@Optional()
@Inject(GC_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> {
await this.handle.close().catch(() => {});
await this.handle?.close().catch(() => {});
}
}
@@ -119,6 +119,19 @@ describe('SessionGCService', () => {
).resolves.toEqual({ allowed: true });
});
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
const result = await localService.collect('local-session');
expect(result.sessionId).toBe('local-session');
expect(result.cleaned.valkeyKeys).toBeUndefined();
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
'local-session',
expect.any(Date),
);
});
it('collect() returns sessionId in result', async () => {
const result = await service.collect('test-session-id');
expect(result.sessionId).toBe('test-session-id');
+10 -3
View File
@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Optional } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -21,7 +21,10 @@ function escapeRedisGlobLiteral(value: string): string {
@Injectable()
export class SessionGCService {
constructor(
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
@Optional()
@Inject(REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(LOG_SERVICE) private readonly logService: LogService,
) {}
@@ -29,8 +32,10 @@ export class SessionGCService {
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
* KEYS is avoided because it blocks the Valkey event loop for the full scan
* duration, which can cause latency spikes under production key volumes.
* Returns an empty population on the Local tier where Redis is disabled.
*/
private async scanKeys(pattern: string): Promise<string[]> {
if (!this.redis) return [];
const collected: string[] = [];
let cursor = '0';
do {
@@ -47,13 +52,15 @@ export class SessionGCService {
async collect(sessionId: string): Promise<GCResult> {
const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
if (this.redis) {
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
}
// 2. PG: demote hot-tier agent logs for this session only.
const cutoff = new Date();
+9 -3
View File
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
@Injectable()
export class CronService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CronService.name);
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
constructor(
@Inject(SummarizationService) private readonly summarization: SummarizationService,
@@ -26,6 +26,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
) {}
async onModuleInit(): Promise<void> {
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
if (!this.queueService.isEnabled()) {
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
return;
}
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
@@ -39,7 +45,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
await this.summarization.runSummarization();
});
this.registeredWorkers.push(summarizationWorker);
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
// M6-005: Tier management repeatable job
await this.queueService.addRepeatableJob(
@@ -51,7 +57,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
await this.summarization.runTierManagement();
});
this.registeredWorkers.push(tierWorker);
if (tierWorker) this.registeredWorkers.push(tierWorker);
// Retire any repeatable global GC schedule created by older deployments.
// Session cleanup is now triggered only by an authorized session lifecycle operation.
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
import { SystemOverrideService } from './system-override.service.js';
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
describe('SystemOverrideService local tier', () => {
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
const service = new SystemOverrideService(localConfig);
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
await service.set('shared-session', 'first override', firstScope);
await service.set('shared-session', 'second override', secondScope);
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
await service.clear('shared-session', firstScope);
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
});
});
@@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
`${scope.tenantId}:${scope.userId}:${sessionId}`;
@@ -15,16 +17,45 @@ interface OverrideFragment {
addedAt: number;
}
@Injectable()
export class SystemOverrideService {
private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle;
interface LocalOverrideEntry {
condensed: string;
fragments: OverrideFragment[];
}
constructor() {
this.handle = createQueue();
@Injectable()
export class SystemOverrideService implements OnApplicationShutdown {
private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle | null;
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
private readonly localStore = new Map<string, LocalOverrideEntry>();
constructor(
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
}
async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {});
}
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) {
const key = scopedSessionId(sessionId, scope);
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
entry.fragments.push({ text: override, addedAt: Date.now() });
entry.condensed = await this.condenseOverrides(
entry.fragments.map((fragment) => fragment.text),
);
this.localStore.set(key, entry);
this.logger.debug(
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
);
return;
}
// Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
const fragments: OverrideFragment[] = existing
@@ -54,10 +85,14 @@ export class SystemOverrideService {
}
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
if (!this.handle) {
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
}
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
}
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) return;
const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
@@ -65,6 +100,11 @@ export class SystemOverrideService {
}
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) {
this.localStore.delete(scopedSessionId(sessionId, scope));
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
return;
}
await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId, scope),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
import { QueueService } from './queue.service.js';
const localConfig = {
queue: { type: 'local' },
} as MosaicConfig;
describe('QueueService local tier', () => {
it('disables BullMQ and treats queue operations as local no-ops', async () => {
const service = new QueueService(null, localConfig);
expect(service.isEnabled()).toBe(false);
expect(service.getQueue('mosaic-test')).toBeNull();
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
await expect(
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
).resolves.toBeUndefined();
await expect(service.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0);
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
await expect(service.listJobs()).resolves.toEqual([]);
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
});
});
+63 -4
View File
@@ -8,7 +8,9 @@ import {
} from '@nestjs/common';
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
import type { LogService } from '@mosaicstack/log';
import type { MosaicConfig } from '@mosaicstack/config';
import { LOG_SERVICE } from '../log/log.tokens.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import type { JobDto, JobStatus } from './queue-admin.dto.js';
// ---------------------------------------------------------------------------
@@ -108,22 +110,43 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
private readonly connection: ConnectionOptions;
private readonly queues = new Map<string, Queue<MosaicJobData>>();
private readonly workers = new Map<string, Worker<MosaicJobData>>();
/** False on Local tier — BullMQ/Redis operations become no-ops. */
private readonly enabled: boolean;
constructor(
@Optional()
@Inject(LOG_SERVICE)
private readonly logService: LogService | null,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
this.connection = getConnection();
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
this.connection = this.enabled
? getConnection()
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
}
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
isEnabled(): boolean {
return this.enabled;
}
onModuleInit(): void {
if (this.enabled) {
this.logger.log('QueueService initialised (BullMQ)');
} else {
this.logger.log(
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
);
}
}
async onModuleDestroy(): Promise<void> {
if (this.enabled) {
await this.closeAll();
}
}
// -------------------------------------------------------------------------
// Queue helpers
@@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Get or create a BullMQ Queue for the given queue name.
* Returns null on Local tier where BullMQ is disabled.
*/
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
if (!this.enabled) return null;
let queue = this.queues.get(name) as Queue<T> | undefined;
if (!queue) {
queue = new Queue<T>(name, { connection: this.connection });
@@ -144,6 +169,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Add a BullMQ repeatable job (cron-style).
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
* No-op on Local tier.
*/
async addRepeatableJob<T extends MosaicJobData>(
queueName: string,
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
data: T,
cronExpression: string,
): Promise<void> {
const queue = this.getQueue<T>(queueName);
if (!this.enabled) {
this.logger.debug(
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return;
}
const queue = this.getQueue<T>(queueName)!;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (queue as Queue<any>).add(jobName, data, {
repeat: { pattern: cronExpression },
@@ -167,7 +199,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* safe retirement of previously registered system-wide jobs.
*/
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
if (!this.enabled) {
this.logger.debug(
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return 0;
}
const queue = this.getQueue(queueName);
if (!queue) return 0;
const jobs = await queue.getRepeatableJobs();
const matchingJobs = jobs.filter((job) => job.name === jobName);
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
@@ -182,8 +221,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Register a Worker for the given queue name with error handling and
* exponential backoff.
* Returns null on Local tier where BullMQ is disabled.
*/
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
registerWorker<T extends MosaicJobData>(
queueName: string,
handler: JobHandler<T>,
): Worker<T> | null {
if (!this.enabled) {
this.logger.debug(
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
);
return null;
}
const worker = new Worker<T>(
queueName,
async (job) => {
@@ -240,8 +289,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Return queue health statistics for all managed queues.
* Returns an empty healthy result on Local tier.
*/
async getHealthStatus(): Promise<QueueHealthStatus> {
if (!this.enabled) {
return { queues: {}, healthy: true };
}
const queues: QueueHealthStatus['queues'] = {};
let healthy = true;
@@ -272,8 +325,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* List jobs across all managed queues, optionally filtered by status.
* BullMQ jobs are fetched by state type from each queue.
* Returns empty array on Local tier.
*/
async listJobs(status?: JobStatus): Promise<JobDto[]> {
if (!this.enabled) return [];
const jobs: JobDto[] = [];
const states: JobStatus[] = status
? [status]
@@ -300,8 +355,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
* job IDs are not globally unique — they are scoped to their queue.
* Returns an error on Local tier.
*/
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const sep = compositeId.lastIndexOf('__');
if (sep === -1) {
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
@@ -333,6 +390,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Pause a queue by name.
*/
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.pause();
@@ -344,6 +402,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Resume a paused queue by name.
*/
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.resume();
-62
View File
@@ -146,68 +146,6 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
### Fleet git identity launch propagation (#1043)
#### Problem and objective
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
estates where that refusal is not active. The objective is to make Git identity a deterministic,
roster-derived part of the generated launch projection and prove it reaches the launched process.
#### Normative requirements
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
tmux launch.
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
against the launched process environment. Source-text/string-presence assertions are insufficient.
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
removes Git-identity pane propagation and makes the behavioral test fail.
#### Acceptance criteria
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
environment projection, including the roster-derived Git identity.
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
independent review, and the required RED/green/R7 evidence are recorded before push.
### Framework shell assertion portability (#1098)
#### Problem and objective
The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when
`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after
the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a
nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and
make failures carry the observed records needed for diagnosis.
#### Normative requirements
1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from
the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status
can override a successful match.
2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries
SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI
utility implementation.
3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records
before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only.
4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order
controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review.
#### Acceptance criteria
1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former
`grep -q` pipeline returns nonzero from an upstream SIGPIPE.
2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and
emit the indexed observed argv.
3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main
Woodpecker pipeline is terminal green before #1098 closes.
---
## Exact Cross-Harness Fleet Communications Contract (#766)
@@ -0,0 +1,63 @@
# npm `@next` prerelease lane
Status: **IMPLEMENTED**
## Current behavior
`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch.
The lane is fast-by-default:
1. Install framework files from the `next` source archive.
2. Resolve the Gitea npm registry `next` dist-tag for the globally installed packages:
```bash
npm view @mosaicstack/gateway@next version
npm view @mosaicstack/mosaic@next version
```
3. Require both resolved versions to share the same `next.<pipeline>` suffix, then install the exact resolved versions.
4. If either `@next` package is missing, unreachable, mismatched, or fails to install, fall back to the source-build path at `next`.
`--next` never hard-fails solely because the prerelease npm dist-tag is unavailable.
## Published packages
The `next` publish pipeline publishes non-private `@mosaicstack/*` packages to the Mosaic Gitea npm registry:
```text
https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
```
Observed `next` dist-tags after enabling the pipeline:
```text
@mosaicstack/mosaic@next -> 0.0.49-next.1633
@mosaicstack/gateway@next -> 0.0.7-next.1633
```
The gateway also publishes a Docker image as `gateway:sha-<short>` on `next` merges. The installer fast path uses the npm gateway package when available; the Docker image is for deployed gateway/runtime harness flows.
## Explicit source lanes
Source builds remain available and are still the authority for explicit ref validation:
- `--dev` always builds from source.
- `--ref <ref>` / `MOSAIC_REF=<ref>` wins over `--next` and uses the source path for that exact ref.
## Pipeline shape
1. Trigger on `next` merges.
2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`<target-stable>-next.<CI_PIPELINE_NUMBER>`).
3. Build and publish non-private packages in CI.
4. Publish to the Mosaic Gitea npm registry with dist-tag `next`.
5. Keep `latest` untouched; only main/release promotion can update `latest`.
6. Publish gateway Docker images from `next` as `gateway:sha-<short>` only.
## Guardrails
- `@next` is mutable prerelease convenience, not a deployment pin.
- Stable installs continue to use `@latest`.
- Contributor validation remains available through `--dev --ref <branch>`.
- Pipeline output traces every prerelease package back to the source commit on `next`.
- The installer falls back to source rather than hard-failing on prerelease registry issues.
+4 -7
View File
@@ -6,8 +6,8 @@ Generated environment files are rebuildable projections, not an operator-editabl
## Launch chain
| Layer | Responsibility |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
@@ -24,7 +24,6 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
```dotenv
MOSAIC_AGENT_NAME=<roster name>
MOSAIC_GIT_IDENTITY=<roster name>
MOSAIC_AGENT_CLASS=<roster class>
MOSAIC_AGENT_RUNTIME=<roster runtime>
MOSAIC_AGENT_MODEL=<roster model hint>
@@ -34,10 +33,8 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
MOSAIC_TMUX_SOCKET=<roster socket or empty>
```
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
split runtime and repository identity authority. The generated launch contract supports `claude`,
`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or
modifies generated, local, or quarantine state.
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
it has no generated-launch adapter and cannot be added through this path.
@@ -3,12 +3,11 @@
The launcher consumes validated data, not shell configuration.
1. Read and validate the canonical roster.
2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
2. Render deterministic <name>.env.generated data from that roster.
3. Parse optional <name>.env.local through a strict allowlist.
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
5. Reject a Git identity that is unsafe or differs from the generated agent name.
6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
7. Target only the exact configured tmux socket and roster session after ownership checks.
5. Derive the runtime command from validated runtime/model/reasoning data.
6. Target only the exact configured tmux socket and roster session after ownership checks.
## File precedence and ownership
@@ -35,7 +35,6 @@ values, credential material, or command text.
```dotenv
MOSAIC_AGENT_NAME=<roster name>
MOSAIC_GIT_IDENTITY=<roster name>
MOSAIC_AGENT_CLASS=<roster class>
MOSAIC_AGENT_RUNTIME=<roster runtime>
MOSAIC_AGENT_MODEL=<roster model hint>
@@ -45,9 +44,8 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
MOSAIC_TMUX_SOCKET=<roster socket or empty>
```
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
this projection path.
+11
View File
@@ -195,6 +195,17 @@ pnpm format:check && pnpm typecheck && pnpm lint
A pre-push hook enforces this mechanically.
### CI Publish Channels
Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate:
| Source | npm packages | Gateway image |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-<short>` plus `gateway:latest` on `main`, and the release tag on tag events |
| `next` push/manual | CI-computed prereleases, `<target-stable>-next.<CI_PIPELINE_NUMBER>`, published with `npm publish --tag next` | `gateway:sha-<short>` only |
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
---
## Adding New Agent Tools
+12 -2
View File
@@ -175,8 +175,18 @@ Or use the direct URL:
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
non-interactive use:
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
Install lanes:
| Lane | Command | Source |
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
Flags for non-interactive use:
```bash
--yes # Accept all defaults
-229
View File
@@ -1,229 +0,0 @@
# #1043 — Fleet pane git-identity propagation
## Objective
Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison.
## Tracking
- External issue: `mosaicstack/stack#1043`
- Branch: `fix/1043-pane-git-identity`
- Coordinator: `tl-mosaic`
- `docs/TASKS.md`: read-only by project worker contract; not modified.
## Constraints
- RED-first bug reproducer is mandatory.
- R7 delete-the-subject mutation must turn the behavioral test red.
- Assert launched-process environment, not source text.
- One push only; do not poll CI after push.
- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence.
- Do not modify a live host launcher or obtain/copy another credential.
- Self-post the PR, verify provider attribution, then stop.
- Final status wording: `believed-fixed, pending jarvis validation`.
## Scope inventory
Re-derived against `origin/main` at `85d2108e`:
- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts`
- Roster projection producers:
- `packages/mosaic/src/commands/fleet.ts`
- `packages/mosaic/src/fleet/fleet-reconciler.ts`
- `packages/mosaic/src/fleet/fleet-agent-crud.ts`
- `packages/mosaic/src/fleet/v1-v2-migration.ts`
- Contract and producer tests discovered by repository search.
- Generated-environment operator/developer docs and their executable documentation contract test.
Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior.
## Plan
1. Add the process-environment set-comparison regression first and record RED.
2. Add roster-derived `MOSAIC_GIT_IDENTITY=<agent name>` to the complete generated projection contract.
3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment.
4. Update affected projection tests and generated-environment docs.
5. Run focused and baseline gates.
6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN.
7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling.
## Budget
No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor.
## Evidence log
### TDD and mutation evidence
- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity.
- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`.
- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
- R7 restoration: restored that launch entry; the same test returned green.
- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy.
### Situational and focused tests
- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity.
- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green.
- Focused Mosaic Vitest set: 6 files, 311 tests — green.
- `bash -n` on changed shell files — green.
- `git diff --check` — green.
### Baseline gates
- `pnpm typecheck` — 45/45 tasks green.
- `pnpm lint` — 25/25 tasks green.
- `pnpm format:check` — green.
- `pnpm test:checkout` — green.
- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase.
- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates:
1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr;
2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`).
No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter.
### Independent review
- Codex code review first pass: request changes for missing shell rejection-path coverage.
- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call.
- Codex code re-review: **approve**, no findings, confidence 0.88.
- Codex security review: risk `none`, no findings, confidence 0.97.
### Acceptance criteria mapping
| Acceptance criterion | Evidence |
| --- | --- |
| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above |
| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests |
| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly |
### Documentation checklist
- PRD updated with #1043 requirements and acceptance criteria.
- Fleet launch runbook, generated-env concept, and generated-env reference updated.
- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies.
- `docs/TASKS.md` remains unmodified per its single-writer project contract.
## Round 2 — PR #1073 review 97 remediation
### Review blocker
The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression.
### RED-first and canonical wiring
1. Removed the suite's signed exclusion before adding a CI execution path.
2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18.
3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain.
4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition.
### Workflow-level R7
- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`.
- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain.
- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort.
- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical.
### Workflow population
- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`).
- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses.
- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change.
### Independent remediation review
- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install.
- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged.
- Codex re-review: **APPROVE**, confidence 0.93, no findings.
### Vitest denominator reconciliation
The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees:
- generated environment boundary: 24
- fleet documentation: 23
- Tess service profile: 6
- fleet regen command: 27
- fleet agent CRUD command: 22
- fleet command: 209
- total: **311**
Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic.
## Round 3 — Alpine stale-marker portability
### Objective and plan
- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion.
- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review.
- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling.
- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes.
### RED-first evidence
Before the fix, the canonical CI image command
`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
exited 1 at the stale-marker setup with exact BusyBox output
`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location.
### Root cause and fix
The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata.
### Structural pattern
This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening.
### GREEN and quality evidence
- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`.
- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces.
- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed.
- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN.
- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings.
- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings.
### Re-derived inventory and denominators
- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization.
- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path.
- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`).
- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets.
## Round 4 — bound stale-marker observation
### Objective and plan
- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window.
- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale.
- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image.
- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling.
- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged.
### Root cause and bounded observation
The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion.
The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed.
### Two-mutant RED / restored GREEN
All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`:
1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`.
2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls.
3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`.
### Re-derived inventory
- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty.
- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path.
- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds.
- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation.
## Residual risk
- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events.
- Canonical CI result is pending and will not be polled by this seat.
@@ -1,97 +0,0 @@
# #1098 — Framework shell portability / red main
## Objective
Restore terminal-green `main` by making the `test-start-agent-session.sh` clean-environment assertion semantic and portable without removing either newly enumerated framework-shell suite.
## Scope
- Tracking issue: `mosaicstack/stack#1098`
- Branch: `fix/framework-shell-portability`
- Base: `origin/main` at `4fa2768962702d53e16e8b67ee6ad52ebcb0910e`
- Primary file: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
- Requirements source: `docs/PRD.md` § Framework shell assertion portability (#1098)
- Out of scope: deployed files under `~/.config/mosaic`, pnpm-store cleanup, checkout deletion, and changes to the launchers `/usr/bin/env -i` behavior.
## Acceptance criteria
1. The test inspects the captured NUL-delimited tmux argv semantically and accepts an adjacent `/usr/bin/env`, `-i` pair regardless of trailing payload size or pipe scheduling.
2. Missing `/usr/bin/env`, missing `-i`, and non-adjacent `-i` remain failures.
3. Failure output includes the observed argv records with stable indexes and shell escaping; it exposes no credentials because this fixture supplies only generated non-secret launch data.
4. The focused suite passes on the dev host and in the repository CI image; the blocking PR/main pipeline returns terminal green.
5. Independent review passes; PR is squash-merged and #1098 is closed only after merged-main CI is terminal green.
## Budget
- ASSUMPTION: 30K-token working budget; rationale: one shell-test defect plus full PR/CI lifecycle.
- Auto-reduction: focused shell and package gates first; rely on canonical Woodpecker for the full monorepo suite rather than duplicating a dependency install under constrained `/home`.
- Disk baseline before clone/build: `/home` 7.1G free (99% used), `/tmp` 2.4G free (92% used).
## Investigation
### First-hand CI evidence
- Public log: `GET https://ci.mosaicstack.dev/api/repos/47/logs/2269/53041`
- Decoded 1,436 entries (11 null `data` entries treated as empty log rows), 190,756 bytes.
- Failure: `FAIL: pane command did not clear its environment` immediately after the expected pane-PID warning.
- BusyBox primitives, complete assertion pipeline, real CI image, stale/current image digests, Turbo cache masking, gateway failure, and heartbeat-sidecar concurrent writing were independently excluded.
### Root cause
The assertion ends in:
```bash
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i'
```
The script has `set -o pipefail`. `grep -q` exits as soon as it finds the valid `-i` record. Upstream `tail`/`printf` can then receive SIGPIPE, making the aggregate pipeline nonzero even though grep returned 0 and the semantic property is true. This depends on payload size, pipe capacity, and scheduling, explaining a local/image pass with a CI failure.
Discriminating stress control with `/usr/bin/env` followed immediately by `-i`:
- 8,192-byte trailing payload: `printf=0 tail=0 grep=0`, aggregate 0.
- 16,384-byte trailing payload: `printf=0 tail=141 grep=0`, aggregate 141.
- 32,768+ bytes: `printf=141 tail=141 grep=0`, aggregate 141.
- A full-reading `grep -xF` control remained 0 for every payload.
This is a third branch omitted by the earlier present-vs-corrupted split: the pair can be present and intact while `pipefail` reports an upstream SIGPIPE.
## TDD plan
1. RED: preserve the one-off stress reproducer above and add an automated large-argv semantic regression that fails under the current pipeline implementation.
2. GREEN: parse the authoritative NUL-delimited capture into a Bash array and search for an adjacent `/usr/bin/env`, `-i` pair without a short-circuit pipeline.
3. Add negative controls for missing, detached, and reversed tokens.
4. On failure, print indexed `%q` argv records before returning nonzero.
5. Run focused suite, mutation controls, shell syntax/format checks, then repository baseline gates feasible without dependency installation.
6. Independent review, queue guard, push, PR, CI, coordinator merge authorization, squash merge, merged-main CI, issue close.
## Progress
- [x] Checkout created and based on `origin/main` `4fa27689`.
- [x] CI log decoded directly.
- [x] Root-cause stress control reproduced semantic match + aggregate pipeline failure.
- [x] RED evidence: intact `/usr/bin/env`, `-i` fixture produced component statuses `0/141/0` and aggregate 141 under the former `grep -q` pipeline; full-reading semantic control stayed 0.
- [x] GREEN implementation: direct NUL-argv adjacency parser, indexed diagnostics, and full-reading scalar predicates replace all load-bearing early-exit pipelines in this test.
- [x] Baseline/situational tests:
- focused launcher suite: PASS on GNU host and cached Alpine CI image;
- paired `test-fleet-units.sh`: PASS;
- enumeration guard: PASS (`population=53`, `enumerated=36`, `excluded=18`), 14/14 mutation needles;
- `bash -n`, ShellCheck, `git diff --check`: PASS;
- static denominator after change: zero load-bearing `grep -q`/`head`/`-m1` pipeline candidates in `test-start-agent-session.sh`;
- delete-the-subject mutation removing production `-i`: RED with 78 indexed argv records, byte count, and explicit boundary failure.
- [x] Independent review:
- first Codex review: request changes — negative fixtures did not each assert diagnostics;
- remediation: centralized predicate + diagnostic wrapper and exercised all four negative fixtures;
- second Codex review: APPROVE, 0 blockers/should-fix/suggestions;
- Codex security review: risk none, 0 findings.
- [ ] PR CI, formal fleet review, merge, merged-main CI, issue closure.
## Documentation disposition
- Updated canonical `docs/PRD.md` with FSP requirements and acceptance criteria.
- This is an internal test/reliability change with no API, user workflow, deployment, navigation, or publishing-surface change; no user/admin/API/sitemap update is required.
- `docs/TASKS.md` remains unchanged because the project contract makes it orchestrator-only.
## Risks
- The CI failure did not print its captured argv, so the exact CI payload is unavailable. The stress control proves the assertion is non-portable and can emit the exact false verdict; branch CI is the canonical confirmation that replacing it resolves pipeline 2269s failure class.
- Printing fixture argv is safe only while this tests projection remains non-secret. The diagnostic must stay scoped to the test capture and shell-escaped.
@@ -0,0 +1,38 @@
# Scratchpad — FED-M3-06 get verb
## Objective
Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads.
## Scope
- `apps/gateway/src/federation/server/verbs/get.controller.ts`
- `apps/gateway/src/federation/server/verbs/get-query.service.ts`
- Unit coverage for controller pipeline + query service RBAC guardrails
- Register controller/service in `FederationModule`
## Plan
1. Mirror the list verb pipeline: `FederationAuthGuard``FederationScopeService` → read-only query service.
2. Return one `_source: "local"` tagged item on success.
3. Return federation error envelopes:
- `404 not_found` when the resource id does not exist.
- `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection.
- `400 invalid_request` for malformed ids/scope requests.
4. Keep read audit persistence deferred to M4; no body or response persistence in M3.
## Verification Evidence
- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services.
- Review-change coverage added for comment 15971:
- get note access now requires subject ownership AND authorized mission intersection.
- missing federation context returns structured `401 unauthorized` envelope.
- unsupported get resources fail closed with structured denial.
- PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion.
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes).
- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes).
- `pnpm build` — pass (23 successful tasks before review changes).
- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes).
- `pnpm lint` — pass (23 successful tasks; re-run after review changes).
- `pnpm format:check` — pass (re-run after review changes).
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.
@@ -0,0 +1,82 @@
# B1 / @next Durable Publish Pipeline — Design
## Objective
Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds.
Every merge to `next` publishes:
1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`.
2. **Gateway container image** tagged only as `gateway:sha-<short>`.
The existing stable release behavior remains isolated to `main` / tags.
## Registry verification
Target registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`.
Pre-implementation checks:
- `npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json` returned a dist-tags object (`latest: 0.0.48`).
- `npm view @mosaicstack/mosaic@latest version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` resolved `0.0.48`.
- `@next` currently returns 404 because no `next` dist-tag exists yet; this is expected before the first next prerelease publish.
Pipeline design includes a post-publish verification that `npm view @mosaicstack/mosaic@next version` resolves to the exact CI-computed prerelease version. If Gitea fails to honor the `next` dist-tag, the pipeline fails closed.
## Version scheme
The prerelease version is computed at publish time only; no `package.json` version changes are committed.
For each non-private `@mosaicstack/*` package:
```text
<target-stable>-next.<CI_PIPELINE_NUMBER>
```
Where:
- `CI_PIPELINE_NUMBER` is Woodpecker's monotonic pipeline number.
- `target-stable` is the package's current committed stable version with the patch component incremented.
- Example: `@mosaicstack/mosaic` `0.0.48` publishes as `0.0.49-next.1626`.
- Example: `@mosaicstack/gateway` `0.0.6` publishes as `0.0.7-next.1626`.
Rationale:
- npm semver sorts `0.0.49-next.1627` above `0.0.49-next.1626`.
- The prerelease does not overtake the future stable `0.0.49`.
- The monotonic pipeline number avoids conflicts across repeated `next` merges.
## Branch and tag guardrails
| Pipeline path | Branch/event | Publishes | Forbidden |
| --------------------- | ------------------------------ | ------------------------------------------------------- | ---------------------- |
| stable npm publish | `main` push/manual or tag | package versions already committed in package manifests | `@next` dist-tag |
| next npm publish | `next` push/manual only | CI-computed prereleases with `--tag next` | `latest` dist-tag |
| gateway image | `main` push/manual or tag | `sha-<short>` + `latest` on main + tag on tag events | next prerelease npm |
| gateway image | `next` push/manual only | `sha-<short>` only | `latest` |
| appservice/web images | `main` push/manual or tag only | existing stable image behavior | next image publication |
The pipeline has explicit branch checks inside the publish commands as a second fail-closed layer beyond Woodpecker `when` clauses.
## Implementation plan
1. Widen `.woodpecker/publish.yml` top-level `when` to include `next` so the publish pipeline runs on next merges.
2. Keep existing `publish-npm` on `main` / tags only.
3. Add `publish-next-npm` for `next` push/manual only:
- configure Gitea npm auth from existing `gitea_token` secret as `NPM_TOKEN`;
- preflight registry dist-tag metadata;
- compute prerelease versions in CI by temporarily editing package manifests in the workspace;
- run `pnpm publish ... --tag next` against non-private `@mosaicstack/*` packages;
- verify `@mosaicstack/mosaic@next` resolves to the computed version.
4. Split image `when` anchors:
- `image_build_when` includes `next` and is used by `build-gateway`;
- `main_image_build_when` keeps appservice/web on main/tags only.
5. Keep gateway next image destinations to `sha-<short>` only; no `latest` on next.
## Risk controls
- Auth/registry failures are fatal.
- No manual image build/push path is introduced.
- No production `latest` tags are touched from `next`.
- No `@latest` npm dist-tags are touched from `next`.
- All changes live in CI config and docs; no runtime source behavior changes.
+34
View File
@@ -0,0 +1,34 @@
# B2 — Fresh-install skills sync path
## Problem
Greenfield wizard on `next` reported:
```text
Skills sync script not found at ~/.config/mosaic/bin/mosaic-sync-skills
Skills: install failed
```
## Diagnosis
The framework install migration removed the legacy `~/.config/mosaic/bin/` directory and now installs framework helper scripts under:
```text
~/.config/mosaic/tools/_scripts/
```
`packages/mosaic/src/stages/finalize.ts` still resolved wizard helper scripts from `mosaicHome/bin`, so wizard-selected skills failed even though `mosaic-sync-skills` was present in the current framework layout.
## Fix
- Resolve framework helper scripts through `tools/_scripts/<name>` first.
- Keep a legacy `bin/<name>` fallback for pre-migration installs.
- Point missing-script warnings at the current `tools/_scripts` layout.
- Update the finalize skills test fixture to model the fresh framework layout.
- Update framework README examples from legacy `bin/` helper paths to `tools/_scripts/`.
## Verification
- Unit: `pnpm --filter @mosaicstack/mosaic test -- finalize-skills`
- Gates: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build`
- Fresh path: ran `packages/mosaic/framework/install.sh` with a temp `MOSAIC_HOME` and `MOSAIC_SYNC_ONLY=1`; verified `tools/_scripts/mosaic-sync-skills` exists, legacy `bin/mosaic-sync-skills` does not, and the script installs a selected fake `lint` skill into Mosaic + Pi runtime skill directories.
@@ -0,0 +1,36 @@
# B3 — Wizard completion ordering
## Problem
The wizard printed the success summary / `Mosaic is ready.` during `finalizeStage`, before the gateway configuration stage had completed its daemon health check. If the gateway health gate later failed, the user could see a success claim followed by a gateway failure.
## Diagnosis
`finalizeStage` handled both mutation work and terminal success messaging. Wizard paths then ran `gatewayConfigStage` and `gatewayBootstrapStage` afterward:
1. finalize writes config, links runtime assets, syncs skills, runs doctor;
2. finalize prints `Installation Summary` + `Mosaic is ready.`;
3. gateway config starts/waits for daemon health;
4. gateway bootstrap runs.
The summary needed to be deferred until after the gateway readiness gates.
## Fix
- `finalizeStage` now returns a `showSummary()` callback and supports `deferSummary`.
- Wizard/quick-start paths call finalize with `deferSummary: true`.
- `showSummary()` is called only after gateway config reports ready and bootstrap completes, or immediately when the caller explicitly skips gateway setup.
- If gateway health/config reports not ready, the wizard returns/aborts without printing the success summary.
- Folded in adjacent runtime install hint fix for Pi: `curl -fsSL https://pi.dev/install.sh | sh`.
## Verification
- Added unified-wizard coverage for summary-after-health and no-summary-on-health-failure.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard finalize-skills`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: one low finding on the requested Pi `curl | sh` install hint; no security finding in the wizard completion-ordering change.
+36
View File
@@ -0,0 +1,36 @@
# B4 — Wizard step deduplication
## Problem
Greenfield wizard testing showed completed wizard steps could be executed again after the menu marked them `[done]`. In practice this made the Providers/API-key flow and Skills flow appear twice in one wizard run.
There was a second related API-key duplication path: when the Providers step was completed with no key, `gatewayConfigStage` still prompted for `ANTHROPIC_API_KEY` during Finish because it only skipped the gateway API-key prompt when `providerKey` was non-empty.
## Diagnosis
- `runMenuLoop` labeled completed sections with `[done]`, but still dispatched the selected step again if the user selected that row.
- Quick Start ran Providers and Skills but did not mark those sections complete in `completedSections`.
- `runFinishPath`/`quickStartPath` defaulted `providerType` to `none` for gateway config, which made it impossible for `gatewayConfigStage` to distinguish:
- provider step completed and user intentionally skipped the key, vs.
- provider step was never run.
## Fix
- Added a shared menu section key helper and a completed-step guard in `runMenuLoop`.
- Completed menu steps now log a skip message instead of re-running their stage.
- Quick Start marks Providers and Skills complete after running them.
- Finish/Quick Start now pass `state.providerType` as-is to gateway config instead of defaulting to `none`.
- `gatewayConfigStage` treats `providerType: 'none'` as an explicit completed provider setup with no key and skips the second gateway API-key prompt.
## Verification
- Added unified wizard regression coverage asserting repeated Providers/Skills menu selections only execute each stage once.
- Added gateway config coverage asserting `providerType: 'none'` does not prompt for a gateway API key and writes no API key env var.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard gateway-config`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: no findings.
@@ -0,0 +1,60 @@
# FED-M3-10 — Federation M3 Integration Tests
## Objective
Add single-gateway gateway integration tests for M3 acceptance #6 and #7.
## Branch / base
- Branch: `feat/federation-m3-integration`
- Base: `origin/next` (`838701bd` after M3-06/#683 merge)
- PR base when unblocked: `next`
## Scope
- Real PostgreSQL via `@mosaicstack/db`.
- Mocked TLS context / Fastify request shim for `FederationAuthGuard`.
- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`.
- Gated by `FEDERATED_INTEGRATION=1`.
- No federation harness dependency.
## Fixture notes
Aligned with the B2 seed design vocabulary:
- `tasks` visibility uses personal `projects` + `missions` chain.
- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission.
- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result.
- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers.
## Current implementation
Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering:
1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope.
2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope.
3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks.
4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded.
5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope.
## Verification
- `pnpm --filter @mosaicstack/types build` — PASS.
- `pnpm --filter @mosaicstack/db build` — PASS.
- `pnpm --filter @mosaicstack/storage build` — PASS.
- `pnpm --filter @mosaicstack/brain build` — PASS.
- `pnpm --filter @mosaicstack/queue build` — PASS.
- `pnpm --filter @mosaicstack/config build` — PASS.
- `pnpm --filter @mosaicstack/auth build` — PASS.
- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped).
- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`.
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS; approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS; risk level none, no findings.
## Push / PR
- #683 landed in `next`; branch rebased onto `origin/next` before push.
- CI is serialized; run queue guard before push.
@@ -0,0 +1,40 @@
# Installer `--next` fast npm lane — 2026-06-25
## Scope
Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback.
## Registry reality check
Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
Verified before implementation:
- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`.
- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`.
- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`.
- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`.
Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-<short>` remains the deployment/harness artifact; the npm gateway package is valid for the installer global package path.
## Behavior
- `--next` with no explicit ref:
1. framework archive from `next`;
2. resolve `@mosaicstack/gateway@next` and `@mosaicstack/mosaic@next`;
3. require both resolved versions to share the same `next.<pipeline>` suffix;
4. install the exact resolved package versions;
5. set `MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1` so wizard does not overwrite the prerelease gateway;
6. if either package is missing/unreachable/mismatched/fails, fall back to existing source build at `next`.
- `--dev` remains pure source build.
- explicit `--ref` / `MOSAIC_REF` still wins over `--next` and uses the source path for that exact ref.
## Install detail
The installer writes the scoped npmrc mapping (`@mosaicstack:registry=...`) and then runs npm install without overriding npm's default registry. Passing `--registry=<gitea>` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior.
## Verification notes
- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning.
- Wired the installer harness into `pnpm test` via `pnpm run test:installer`.
- Real temp-prefix fast install succeeded with `@mosaicstack/[email protected]` and `@mosaicstack/[email protected]`.
@@ -0,0 +1,35 @@
# Scratchpad — installer `--next` lane
## Objective
Add a prerelease installer lane for the permanent `next` integration branch.
## Scope
- `tools/install.sh`
- README/install documentation
- Follow-up design note for future npm `@next` prerelease publishing
## Plan
1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`.
2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`.
3. Update installer source display/help text.
4. Document three lanes:
- stable npm `@latest`
- prerelease `--next`
- contributor `--dev --ref X`
5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight.
## Verification
- `bash -n tools/install.sh` — pass.
- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass.
- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`.
- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins.
- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins.
- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path).
- `pnpm typecheck` — pass (41 successful tasks).
- `pnpm lint` — pass (23 successful tasks).
- `pnpm format:check` — pass.
- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure.
@@ -0,0 +1,93 @@
# W-B — Measure Pi's real tool registry
- **Task / internal ref:** W-B from the lease-remediation orchestrator brief (no matching `docs/TASKS.md` row; workers do not modify that file)
- **Objective:** identify the exact tool names emitted as `event.toolName` by the installed Pi runtime and compare them with the broker's Pi read-only carve-out.
- **Scope:** measurement and report only; no broker or runtime source changes. W-C is out of scope.
- **Budget:** no explicit token cap; constrained to this scratchpad and one local commit.
- **Installed runtime:** `@earendil-works/pi-coding-agent` / `pi` `0.84.1`.
## Method
I created a throwaway extension at `/tmp/measure-pi-tool-registry.ts` (not in the worktree). On `session_start` it recorded `pi.getAllTools()` and `pi.getActiveTools()`; on every `tool_call` it appended the exact `event.toolName`. I then launched an isolated, ephemeral Pi session with all built-ins explicitly selected:
```text
PI_OFFLINE=1 pi --mode print --no-session --no-approve \
--no-context-files --no-skills --no-prompt-templates --no-extensions \
-e /tmp/measure-pi-tool-registry.ts \
--tools read,bash,edit,write,grep,find,ls <deterministic probe prompt>
```
The prompt exercised file read, content search, file search, directory listing, shell execution, file write, and file edit. Pi exited `0`; every selected tool produced one `tool_call`. The write/edit control artifact ended with exact content `after`, proving the mutating calls executed in order.
This runtime observation was cross-checked against the installed distribution's canonical registry at `dist/core/tools/index.js:17`, which declares the same seven names. The gate consumes the measured field directly at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:368`.
## Exact distinct built-in set
The installed Pi built-in registry is exactly:
```text
{bash, edit, find, grep, ls, read, write}
```
| Tool | Runtime registry observation | `tool_call` observation | Installed definition |
| --- | --- | --- | --- |
| `read` | `<builtin:read>` | observed once | `dist/core/tools/read.js:138` |
| `bash` | `<builtin:bash>` | observed once | `dist/core/tools/bash.js:231` |
| `edit` | `<builtin:edit>` | observed once | `dist/core/tools/edit.js:170` |
| `write` | `<builtin:write>` | observed once | `dist/core/tools/write.js:138` |
| `grep` | `<builtin:grep>` | observed once | `dist/core/tools/grep.js:79` |
| `find` | `<builtin:find>` | observed once | `dist/core/tools/find.js:79` |
| `ls` | `<builtin:ls>` | observed once | `dist/core/tools/ls.js:61` |
The raw distinct `event.toolName` result was:
```json
["bash", "edit", "find", "grep", "ls", "read", "write"]
```
Pi registers all seven, but its default active set is only `read`, `bash`, `edit`, and `write` (`dist/core/sdk.js:132`). The probe explicitly activated all seven so the three search/list tools could be observed at the hook.
## Positive control
The known `read` tool was the control. The method surfaced it twice:
1. `pi.getAllTools()` returned `read` with source path `<builtin:read>`.
2. Reading `/tmp/pi-registry-probe/seed.txt`, which contained `CONTROL_TOKEN`, produced one hook record with `event.toolName === "read"`.
The control was therefore positive; the seven-name result is measured, not an empty-probe inference.
## Carve-out comparison and collision result
The broker currently declares `{"read", "grep", "find", "ls"}` at `packages/mosaic/framework/tools/lease-broker/daemon.py:54`.
- `read`: real built-in.
- `grep`: real built-in.
- `find`: real built-in.
- `ls`: real built-in.
All four carve-out names are exact, case-sensitive Pi tool names.
The general execution/writing tool names are `bash`, `edit`, and `write`. Their intersection with the carve-out is empty:
```text
{bash, edit, write} ∩ {read, grep, find, ls} = ∅
```
Therefore no general shell-exec or file-mutating Pi tool shares a name with a carve-out entry. `grep` and `find` may invoke constrained search helpers internally, but neither exposes an arbitrary command interface; the arbitrary command tool is distinctly named `bash`.
The Mosaic extension separately registers the non-built-in custom tool `mosaic_context_recover` at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:379`; the broker handles that identity through its dedicated recovery exemption rather than the read-only set (`daemon.py:722`). Unknown or third-party custom tools are not part of Pi's built-in seven-name registry and remain outside the carve-out.
## Verification evidence
- `pi --version``0.84.1`.
- Isolated probe exit → `0`.
- Runtime `getAllTools()` count → `7`, all with `sourceInfo.source === "builtin"`.
- Distinct hook names → `bash`, `edit`, `find`, `grep`, `ls`, `read`, `write`.
- Hook counts → exactly one call for each of the seven names.
- Mutation artifact after `write` then `edit` → exact content `after`.
- Installed registry source → `allToolNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"])`.
## Risks / limitations
- The probe deliberately disabled all other extensions, so extension-defined third-party tools were excluded from the built-in registry measurement. The production gate still receives those names and treats names outside the broker carve-out as mutating/fail-closed.
- Explicit `--tools` activation was required to exercise `grep`, `find`, and `ls`; this does not imply they are active in Pi's default four-tool configuration.
+2 -1
View File
@@ -10,7 +10,8 @@
"clean:generated": "node scripts/clean-generated.mjs",
"typecheck": "pnpm preflight && turbo run typecheck",
"test:checkout": "node --test scripts/*.test.mjs",
"test": "pnpm test:checkout && turbo run test",
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
"test:installer": "bash tools/install-next-lane.test.sh",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "node scripts/install-hooks.mjs"
@@ -11,9 +11,37 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { createConfigService } from '../../src/config/config-service.js';
import type { SelectOption } from '../../src/prompter/interface.js';
import type { MenuSection, WizardState } from '../../src/types.js';
const gatewayConfigMock = vi.fn();
const gatewayBootstrapMock = vi.fn();
const providerSetupMock = vi.fn();
const skillsSelectMock = vi.fn();
class SequencedMenuPrompter extends HeadlessPrompter {
constructor(
answers: Record<string, string | boolean | string[]>,
private readonly menuChoices: string[],
) {
super(answers);
}
override async select<T>(opts: {
message: string;
options: SelectOption<T>[];
initialValue?: T;
}): Promise<T> {
if (opts.message === 'What would you like to configure?') {
const next = this.menuChoices.shift();
if (!next) throw new Error('No queued menu choice left');
const match = opts.options.find((o) => String(o.value) === next);
if (!match) throw new Error(`Queued menu choice not available: ${next}`);
return match.value;
}
return super.select(opts);
}
}
vi.mock('../../src/stages/gateway-config.js', () => ({
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
@@ -23,6 +51,14 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
}));
vi.mock('../../src/stages/provider-setup.js', () => ({
providerSetupStage: (...args: unknown[]) => providerSetupMock(...args),
}));
vi.mock('../../src/stages/skills-select.js', () => ({
skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args),
}));
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
import { runWizard } from '../../src/wizard.js';
@@ -44,6 +80,16 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
}
gatewayConfigMock.mockReset();
gatewayBootstrapMock.mockReset();
providerSetupMock.mockReset();
skillsSelectMock.mockReset();
providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.providerType = 'none';
state.completedSections?.add('providers' satisfies MenuSection);
});
skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.selectedSkills = [];
state.completedSections?.add('skills' satisfies MenuSection);
});
// Pretend we're on an interactive TTY so the wizard's headless-abort
// branch does not call `process.exit(1)` during these tests.
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
@@ -98,8 +144,12 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
});
it('does not invoke bootstrap when config stage reports not ready', async () => {
gatewayConfigMock.mockResolvedValue({ ready: false });
it('prints the success summary only after gateway health succeeds', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
p.log('Gateway is healthy.');
return { ready: true, host: 'localhost', port: 14242 };
});
gatewayBootstrapMock.mockResolvedValue({ completed: true });
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
@@ -118,6 +168,43 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
skipGatewayNpmInstall: true,
});
const logs = prompter.getLogs();
const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.'));
const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary'));
const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.'));
expect(healthIndex).toBeGreaterThanOrEqual(0);
expect(summaryIndex).toBeGreaterThan(healthIndex);
expect(readyIndex).toBeGreaterThan(summaryIndex);
});
it('does not claim success when gateway health reports not ready', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
p.warn('Gateway did not become healthy within 30 seconds.');
return { ready: false };
});
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGatewayNpmInstall: true,
});
const logs = prompter.getLogs();
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
});
@@ -143,4 +230,34 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(gatewayConfigMock).not.toHaveBeenCalled();
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
});
it('does not re-run completed provider or skills menu steps', async () => {
const prompter = new SequencedMenuPrompter(
{
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
},
['providers', 'providers', 'skills', 'skills', 'finish'],
);
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGateway: true,
});
expect(providerSetupMock).toHaveBeenCalledTimes(1);
expect(skillsSelectMock).toHaveBeenCalledTimes(1);
expect(prompter.getLogs()).toEqual(
expect.arrayContaining([
expect.stringContaining('Providers [done] is already complete; skipping.'),
expect.stringContaining('Skills [done] is already complete; skipping.'),
]),
);
});
});
+18 -5
View File
@@ -43,6 +43,16 @@ The installer:
- Runs a health audit
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are validating a branch before release | Build-from-source at the requested git ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` wins and uses the source path.
## First Run
After install, open a new terminal (or `source ~/.bashrc`) and run:
@@ -108,8 +118,8 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
├── STANDARDS.md ← Machine-wide standards
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.)
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links)
├── runtime/ ← Runtime adapters + runtime-specific references
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
│ ├── codex/ ← instructions.md, RUNTIME.md
@@ -174,7 +184,9 @@ The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --next # Prerelease lane: npm @next, source fallback
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
@@ -185,6 +197,7 @@ The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/ski
```bash
mosaic sync # Full canonical catalog sync
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
mosaic skill list # Show registered, missing, dangling, and foreign entries
mosaic skill register <name> # Register or repair one canonical Claude link
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
@@ -198,7 +211,7 @@ M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosai
```bash
mosaic doctor # Standard audit
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
```
## MCP Registration
@@ -209,8 +222,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it
To verify or re-register manually:
```bash
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check
```
### Claude Code MCP Registration
@@ -0,0 +1 @@
I invoked this registered command to authorize lease promotion; follow the local seat broker's injected receipt confirmation instruction exactly.
@@ -32,6 +32,18 @@
]
}
],
"UserPromptSubmit": [
{
"matcher": "^/mosaic-promote$",
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
"timeout": 15
}
]
}
],
"PreToolUse": [
{
"matcher": ".*",
@@ -81,8 +93,8 @@
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
"timeout": 3
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
"timeout": 15
},
{
"type": "command",
@@ -112,7 +112,6 @@ EOF
chmod 700 "$AGENT_HOME/fleet/agents"
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
MOSAIC_AGENT_NAME=$AGENT_NAME
MOSAIC_GIT_IDENTITY=$AGENT_NAME
MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=
@@ -153,7 +153,24 @@ if [[ $link_only -eq 1 ]]; then
exit 0
fi
# Skills are linked into the MOSAIC-OWNED harness homes, never a base install.
# Paths mirror the config-dir env vars the launcher injects (HARNESS_HOME_ENV in
# commands/launch.js):
# claude CLAUDE_CONFIG_DIR -> <home>/skills
# pi PI_CODING_AGENT_DIR -> <home>/skills (replaces ~/.pi/agent)
# codex CODEX_HOME -> <home>/skills
# opencode XDG_CONFIG_HOME -> <home>/opencode/skills (XDG adds a level)
link_targets=(
"$MOSAIC_HOME/.claude/skills"
"$MOSAIC_HOME/.codex/skills"
"$MOSAIC_HOME/.opencode/opencode/skills"
"$MOSAIC_HOME/.pi/skills"
)
# Pre-isolation installs planted the same symlink farm directly in the operator's
# base installs. Those are now orphaned: the launcher no longer reads them, but
# they persist and make a "clean" base install look mosaic-managed.
legacy_link_targets=(
"$HOME/.claude/skills"
"$HOME/.codex/skills"
"$HOME/.config/opencode/skills"
@@ -245,13 +262,72 @@ prune_stale_links_in_target() {
# -m resolves lexical dangling targets too. If resolution fails, ownership
# is unproven and the link must be preserved.
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
# $canonical_real must be length-checked BEFORE use as a prefix: if it were
# ever empty, "$resolved" == "$canonical_real/"* collapses to == "/"* and
# matches every absolute path. Combined with the is_mosaic_skill_name skip
# above, that inverts the function precisely — it would delete exactly the
# FOREIGN symlinks and keep the mosaic ones. (#1087, reported by mos-claude.)
if [[ -n "$resolved" && -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
rm -f "$link_path"
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
fi
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
}
# Remove mosaic-owned symlinks left in a base install by a pre-isolation sync.
#
# Ownership is proven by RESOLUTION, not by name: only links resolving inside the
# canonical or local skills dirs are removed. Anything else — a real directory, a
# link elsewhere, an unresolvable link — is left untouched. This mirrors the
# refusal in commands/skill.js ("only symlinks pointing inside the Mosaic skills
# directory are managed") and preserves e.g. codex's own `.system` dir.
#
# The directory itself is kept: mosaic-doctor warns when ~/.pi/agent/skills is
# missing, and an empty dir is the correct end state, not an absent one.
cleanup_legacy_target() {
local target_dir="$1"
local removed=0 kept=0
[[ -d "$target_dir" ]] || return 0
while IFS= read -r -d '' link_path; do
local resolved owned=0
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
# Guard the empty-prefix trap: an unset *_real would make "$resolved" == "/"*
# match every absolute path and delete foreign links.
if [[ -n "$resolved" ]]; then
if [[ -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
owned=1
elif [[ -n "$local_real" && "$resolved" == "$local_real/"* ]]; then
owned=1
fi
fi
if [[ $owned -eq 1 ]]; then
rm -f "$link_path"
removed=$((removed + 1))
else
kept=$((kept + 1))
fi
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
if [[ $removed -gt 0 ]]; then
echo "[mosaic-skills] Legacy cleanup: removed $removed mosaic symlink(s) from $target_dir (preserved $kept foreign)"
fi
}
for legacy in "${legacy_link_targets[@]}"; do
# Skip anything that is also a current target, so isolation can never
# self-destruct if the two lists ever overlap.
skip=0
for target in "${link_targets[@]}"; do
[[ "$legacy" == "$target" ]] && skip=1
done
[[ $skip -eq 1 ]] && continue
cleanup_legacy_target "$legacy"
done
for target in "${link_targets[@]}"; do
mkdir -p "$target"
@@ -97,7 +97,7 @@ is_sensitive_key() {
is_generated_key() {
case "$1" in
MOSAIC_AGENT_NAME|MOSAIC_GIT_IDENTITY|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
*) return 1 ;;
esac
}
@@ -114,7 +114,6 @@ validate_generated_value() {
local value="$2"
case "$key" in
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
MOSAIC_GIT_IDENTITY) safe_agent_name "$value" || fail_env unsafe-git-identity "$key" "$value" ;;
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
MOSAIC_AGENT_RUNTIME)
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
@@ -176,7 +175,7 @@ load_environment_file() {
load_environment_file "$GENERATED_ENV" generated
for required_key in \
MOSAIC_AGENT_NAME MOSAIC_GIT_IDENTITY MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
done
@@ -184,15 +183,12 @@ load_environment_file "$LOCAL_ENV" local
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
[ "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}" = "$AGENT_NAME" ] || \
fail_env git-identity-mismatch MOSAIC_GIT_IDENTITY "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}"
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
MOSAIC_GIT_IDENTITY=${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
@@ -347,7 +343,6 @@ LAUNCH_ENV=(
"PATH=$PANE_PATH"
"MOSAIC_HOME=$MOSAIC_HOME"
"MOSAIC_AGENT_NAME=$AGENT_NAME"
"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
@@ -14,82 +14,6 @@ fail() {
exit 1
}
pane_command_clears_environment() {
local calls_file="$1"
local -a argv=()
local index
mapfile -d '' -t argv < "$calls_file"
for ((index = 0; index + 1 < ${#argv[@]}; index++)); do
if [ "${argv[$index]}" = /usr/bin/env ] && [ "${argv[$((index + 1))]}" = -i ]; then
return 0
fi
done
return 1
}
print_pane_argv() {
local calls_file="$1"
local -a argv=()
local bytes index
mapfile -d '' -t argv < "$calls_file"
bytes=$(wc -c < "$calls_file")
printf 'observed pane argv: records=%s bytes=%s\n' "${#argv[@]}" "$bytes" >&2
for ((index = 0; index < ${#argv[@]}; index++)); do
printf ' [%03d] %q\n' "$index" "${argv[$index]}" >&2
done
}
check_pane_environment_boundary() {
local calls_file="$1"
if pane_command_clears_environment "$calls_file"; then
return 0
fi
print_pane_argv "$calls_file"
return 1
}
contains_literal() {
grep -F -- "$2" <<< "$1" >/dev/null
}
contains_line() {
grep -xF -- "$2" <<< "$1" >/dev/null
}
# Portability regression: inspect the authoritative NUL-delimited argv instead
# of piping a newline reconstruction through `grep -q` under pipefail. The old
# pipeline could report failure after a successful match when an upstream
# producer received SIGPIPE. A large trailing argument keeps that failure class
# covered without making stream size part of the semantic contract.
PORTABILITY_CALLS="$ROOT/portability-calls"
printf -v PORTABILITY_PADDING '%*s' 32768 ''
PORTABILITY_PADDING=${PORTABILITY_PADDING// /x}
printf '%s\0' /usr/bin/env -i "$PORTABILITY_PADDING" > "$PORTABILITY_CALLS"
pane_command_clears_environment "$PORTABILITY_CALLS" || \
fail "valid large pane argv was rejected by the environment-boundary assertion"
assert_pane_boundary_rejected() {
local case_name="$1"
local expected_records="$2"
local diagnostic
if diagnostic=$(check_pane_environment_boundary "$PORTABILITY_CALLS" 2>&1); then
fail "pane boundary accepted invalid $case_name fixture"
fi
contains_literal "$diagnostic" "records=$expected_records bytes=" || \
fail "pane argv diagnostic omitted counts for $case_name fixture"
contains_literal "$diagnostic" '[000]' || \
fail "pane argv diagnostic omitted indexed arguments for $case_name fixture"
}
printf '%s\0' tmux -i > "$PORTABILITY_CALLS"
assert_pane_boundary_rejected missing-env 2
printf '%s\0' /usr/bin/env HOME=/untrusted > "$PORTABILITY_CALLS"
assert_pane_boundary_rejected missing-i 2
printf '%s\0' /usr/bin/env HOME=/untrusted -i > "$PORTABILITY_CALLS"
assert_pane_boundary_rejected non-adjacent-i 3
printf '%s\0' -i /usr/bin/env > "$PORTABILITY_CALLS"
assert_pane_boundary_rejected reversed-boundary 2
cat > "$FAKE_BIN/tmux" <<'SHIM'
#!/usr/bin/env bash
set -euo pipefail
@@ -138,19 +62,6 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
SHIM
chmod +x "$FAKE_BIN/mosaic"
# Freeze numeric epoch reads only when a test arm supplies an observation bound.
# Formatting reads still use the real BusyBox/POSIX date implementation.
cat > "$FAKE_BIN/date" <<'SHIM'
#!/usr/bin/env bash
set -euo pipefail
if [ -n "${MOSAIC_TEST_FIXED_EPOCH:-}" ] && [ "${1:-}" = '+%s' ]; then
printf '%s\n' "$MOSAIC_TEST_FIXED_EPOCH"
exit 0
fi
exec /bin/date "$@"
SHIM
chmod +x "$FAKE_BIN/date"
write_generated() {
local home="$1"
local agent="$2"
@@ -160,7 +71,6 @@ write_generated() {
chmod 600 "$home/fleet/run/holder-owner"
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
MOSAIC_AGENT_NAME=$agent
MOSAIC_GIT_IDENTITY=$agent
MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
@@ -178,7 +88,6 @@ run_start() {
local agent="$2"
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
MOSAIC_TEST_FIXED_EPOCH="${MOSAIC_TEST_FIXED_EPOCH:-}" \
MOSAIC_TEST_HOME="$home" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$home" "$START" "$agent"
@@ -191,55 +100,19 @@ AGENT_VALID="coder0"
write_generated "$HOME_VALID" "$AGENT_VALID"
run_start "$HOME_VALID" "$AGENT_VALID"
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
contains_literal "$valid_args" new-session || fail "valid generated projection did not reach tmux"
contains_literal "$valid_args" mosaic || fail "fixed mosaic launcher command missing"
contains_literal "$valid_args" yolo || fail "fixed yolo launcher command missing"
contains_literal "$valid_args" pi || fail "roster runtime missing"
if contains_literal "$valid_args" 'bash -c'; then
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
if echo "$valid_args" | grep -qF 'bash -c'; then
fail "launcher constructed a shell command payload"
fi
# The pane must start through an absolute clean-environment boundary. Its
# runtime command remains an argv vector, but no holder/session environment
# control variable can pass through the pane command.
check_pane_environment_boundary "$TMUX_CALLS" || \
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
# Git identity is generated authority, not an optional or independently mutable
# local value. Each invalid form must fail before fake tmux receives a call.
assert_git_identity_rejected() {
local case_name="$1"
local expected_code="$2"
local home="$ROOT/git-identity-$case_name"
local agent="coder-git-identity-$case_name"
local generated="$home/fleet/agents/$agent.env.generated"
write_generated "$home" "$agent"
case "$case_name" in
missing) grep -v '^MOSAIC_GIT_IDENTITY=' "$generated" > "$generated.next" && mv "$generated.next" "$generated" ;;
unsafe) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=bad/identity|' "$generated" ;;
mismatch) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=other-agent|' "$generated" ;;
local-shadow)
printf 'MOSAIC_GIT_IDENTITY=%s\n' "$agent" > "$home/fleet/agents/$agent.env.local"
chmod 600 "$home/fleet/agents/$agent.env.local"
;;
*) fail "unknown Git identity rejection case: $case_name" ;;
esac
chmod 600 "$generated"
: > "$TMUX_CALLS"
if output=$(run_start "$home" "$agent" 2>&1); then
fail "Git identity case $case_name was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before Git identity $case_name rejection"
contains_literal "$output" "code=$expected_code" || \
fail "Git identity $case_name diagnostic omitted code $expected_code"
}
assert_git_identity_rejected missing missing-key
assert_git_identity_rejected unsafe unsafe-git-identity
assert_git_identity_rejected mismatch git-identity-mismatch
assert_git_identity_rejected local-shadow generated-key-shadow
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
# The generated-file parent is a security boundary too: even a private regular
# file is untrusted if its parent can be replaced or written by another user.
@@ -252,7 +125,7 @@ if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
fail "generated file under a world-writable parent was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
contains_literal "$output" 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
: > "$TMUX_CALLS"
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
@@ -263,7 +136,7 @@ if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
fail "generated file under a symlinked parent was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
contains_literal "$output" 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
# symlink or group/world-writable ancestor must fail before environment parsing,
@@ -301,8 +174,8 @@ assert_managed_ancestor_rejected() {
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
contains_literal "$output" 'code=unsafe-' || fail "managed ancestor diagnostic missing"
if contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND'; then
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
fail "environment parsing ran before $hazard $ancestor rejection"
fi
}
@@ -323,9 +196,9 @@ if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
fail "generated-key shadow was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
contains_literal "$output" 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
contains_literal "$output" 'sha256=' || fail "shadow diagnostic omitted hash"
if contains_literal "$output" codex; then
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
if echo "$output" | grep -qF 'codex'; then
fail "shadow diagnostic leaked value"
fi
@@ -341,9 +214,9 @@ if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
fail "arbitrary command override was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
contains_literal "$output" 'sha256=' || fail "command diagnostic omitted hash"
if contains_literal "$output" "$COMMAND_VALUE"; then
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
fail "command diagnostic leaked command value"
fi
@@ -357,7 +230,7 @@ if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
fail "world-readable local input was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
contains_literal "$output" 'code=unsafe-permissions' || fail "permission diagnostic missing"
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing"
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
# computed PATH only. The pane command itself must not carry loader, shell
@@ -387,35 +260,25 @@ PATH="$PANE_STALE_PATH" \
MOSAIC_TEST_EXECUTE_PANE=1 \
"$START" coder-pane-boundary
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
contains_line "$pane_args" "HOME=$PANE_TRUSTED_HOME" || \
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
fail "pane did not restore trusted HOME"
contains_literal "$pane_args" "HOME=$PANE_STALE_HOME" && \
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
fail "pane inherited stale HOME"
contains_literal "$pane_args" "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
contains_literal "$pane_args" "$blocked" && fail "pane inherited $blocked"
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
done
check_pane_environment_boundary "$TMUX_CALLS" || \
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
fail "pane command did not clear its environment"
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
# Exercise the repository launcher at $START, not the independently installed
# host copy. Set-compare every declared generated projection entry with the
# launched process environment so a newly declared identity cannot be omitted
# by a hand-maintained per-variable assertion.
declared_generated_environment=$(sort "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.generated")
missing_or_changed_generated_environment=$(comm -23 \
<(printf '%s\n' "$declared_generated_environment") \
<(printf '%s\n' "$pane_environment" | sort))
if [ -n "$missing_or_changed_generated_environment" ]; then
missing_or_changed_keys=$(printf '%s\n' "$missing_or_changed_generated_environment" | cut -d= -f1 | paste -sd, -)
fail "runtime pane omitted or changed generated environment keys: $missing_or_changed_keys"
fi
contains_line "$pane_environment" "HOME=$PANE_TRUSTED_HOME" || \
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
fail "runtime pane did not receive trusted HOME"
contains_literal "$pane_environment" "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
done
write_interaction_generated() {
@@ -427,7 +290,6 @@ write_interaction_generated() {
chmod 600 "$home/fleet/run/holder-owner"
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
MOSAIC_AGENT_NAME=$agent
MOSAIC_GIT_IDENTITY=$agent
MOSAIC_AGENT_CLASS=operator-interaction
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
@@ -490,11 +352,7 @@ write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
touch -t 200001010000.00 "$STALE_HB.native"
# Hold the sidecar's observation epoch constant: assertion runtime must not age
# a fresh-marker mutant into the stale state that this fixture must distinguish.
STALE_OBSERVATION_EPOCH=$(date +%s)
MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \
touch -d '10 seconds ago' "$STALE_HB.native"
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
wait_for_sidecar_status "$STALE_HB"
@@ -516,22 +374,22 @@ if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed
fail "interaction wrapper accepted malformed generated data"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
contains_literal "$output" 'code=unknown-key' || fail "interaction did not use shared strict parser first"
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first"
# A syntactically valid but policy-incompatible projection reaches the pinned
# interaction policy check only after strict parsing and never starts tmux.
: > "$TMUX_CALLS"
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
fail "interaction wrapper accepted a policy-incompatible projection"
fi
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
contains_literal "$interaction_policy_args" new-session && \
echo "$interaction_policy_args" | grep -qF 'new-session' && \
fail "interaction pinned-policy rejection created a tmux session"
contains_literal "$output" 'operator interaction service requires runtime pi' || \
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
fail "interaction pinned-policy check did not follow strict parsing"
# Exact stop derives the socket exclusively from the validated generated
@@ -544,10 +402,10 @@ HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
contains_line "$stop_args" mosaic-test || fail "exact stop did not use the validated generated socket"
contains_line "$stop_args" kill-session || fail "exact stop did not request session termination"
contains_line "$stop_args" '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
if contains_literal "$stop_args" ambient-socket; then
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
if echo "$stop_args" | grep -qF 'ambient-socket'; then
fail "exact stop trusted an ambient socket"
fi
@@ -5,10 +5,7 @@
detect_platform() {
local remote_url
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
# kills the CALLER before the -z check below can run, so the error message that is
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
remote_url=$(git remote get-url origin 2>/dev/null) || true
remote_url=$(git remote get-url origin 2>/dev/null)
if [[ -z "$remote_url" ]]; then
echo "error: not a git repository or no origin remote" >&2
@@ -42,10 +39,7 @@ detect_platform() {
get_repo_info() {
local remote_url
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
# kills the CALLER before the -z check below can run, so the error message that is
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
remote_url=$(git remote get-url origin 2>/dev/null) || true
remote_url=$(git remote get-url origin 2>/dev/null)
if [[ -z "$remote_url" ]]; then
echo "error: not a git repository or no origin remote" >&2
@@ -246,21 +240,6 @@ PY
} >&2
}
# Explain tea's most misleading failure. `user does not exist [uid: 0, name: ]` reads
# as a missing account; it almost always means a REVOKED OR STALE TOKEN. `tea login`
# keeps its OWN COPY of the token, so rotating the credential store does not update it.
# Diagnostic only -- stderr, no control flow, no exit.
explain_tea_user_does_not_exist() {
cat >&2 <<'MSG'
NOTE: `user does not exist [uid: 0, name: ]` from tea usually means a REVOKED OR STALE TOKEN,
not a missing account. A `tea login` stores its OWN COPY of the token; rotating the
credential store does NOT update it.
CHECK: the login's cached copy (`tea login list` -- read the FULL table, never `| head`),
then re-register that login against the current token.
DO NOT probe capability with a mutating request; a POST is the action, not a check.
MSG
}
get_gitea_login_for_host() {
local host="${1:-}"
local login
@@ -91,32 +91,13 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login || true)
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
if [[ -n "$COMMENT" ]]; then
# `tea issue comment` is NOT a subcommand -- tea 0.11.x lists only
# list/create/edit/reopen/close under `tea issue`. Comments are the
# TOP-LEVEL `tea comment`, which takes the same --repo/--login flags.
# The old call therefore always failed, was unchecked, and the script
# closed the issue anyway, losing the record of WHY.
#
# Use `tea comment` rather than the API helper so the comment and the
# close are made by the SAME principal ($GITEA_LOGIN_NAME). Routing the
# comment through the token-authenticated helper here would attribute the
# comment to the token holder and the close to the tea login -- two
# principals for one operation.
tea comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || {
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
exit 1
}
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
fi
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
else
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
if [[ -n "$COMMENT" ]]; then
# Fail closed here too: an unchecked comment lets the issue close without its
# audit trail, which is the same defect as the tea path above.
gitea_issue_comment_api || {
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
exit 1
}
gitea_issue_comment_api
fi
gitea_issue_close_api
fi
@@ -156,7 +156,6 @@ case "$PLATFORM" in
exit 0
fi
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
fi
gitea_issue_create_api
;;
@@ -71,7 +71,6 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
exit 0
fi
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
fi
gitea_issue_view_api
else
@@ -219,7 +219,6 @@ case "$PLATFORM" in
exit 0
fi
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
gitea_pr_create_api
;;
*)
@@ -1,58 +0,0 @@
#!/bin/bash
# Regression: detect_platform / get_repo_info must FAIL LOUDLY outside a git repo,
# not kill the caller silently.
#
# Both functions already contained the right error path:
# if [[ -z "$remote_url" ]]; then echo "error: not a git repository..." >&2; return 1; fi
# but under `set -e` -- which every wrapper in this directory uses -- the preceding
# assignment `remote_url=$(git remote get-url origin 2>/dev/null)` returns git's 128
# outside a repo and terminates the CALLER first. The message was unreachable.
#
# Observed cost: pr-review.sh invoked from a non-repo cwd exits 128 with NO stdout and
# NO stderr, even when -r/--repo and -H/--host are supplied -- the flags documented as
# "skips git-remote inference". Two reviewer seats hit this and correctly reported
# `blocked` with no diagnostic to report.
#
# The control that matters is the LOUD one: asserting "rc != 0" passes on the broken
# build too, because 128 is also non-zero. The test must assert the MESSAGE.
set -uo pipefail
fail=0
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
run_outside() { # $1=function name -> "rc:sawmessage"
local fn="$1" out rc
out=$( cd "$TMP" && bash -c "set -e; source '$HERE/detect-platform.sh'; $fn" 2>&1 ); rc=$?
printf '%s:%s' "$rc" "$(grep -qi 'not a git repository' <<<"$out" && echo yes || echo no)"
}
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
# $TMP must not be inside a git repo. Do not SKIP on failure: be-coder-07 showed the
# original SKIP exited 0, so pointing TMPDIR beneath a git worktree made this test PASS
# against unchanged main. A skip that exits 0 is indistinguishable from a pass.
# GIT_CEILING_DIRECTORIES stops git walking above $TMP, making the condition hold
# regardless of where TMPDIR lives, rather than merely detecting when it does not.
# GIT_CEILING_DIRECTORIES is matched against the PHYSICAL path -- a symlinked TMPDIR
# (/tmp is commonly one) makes the logical path never match, and the ceiling silently
# does nothing. Resolve it before exporting.
TMP="$(cd "$TMP" && pwd -P)"
export GIT_CEILING_DIRECTORIES="$TMP"
if ( cd "$TMP" && git rev-parse --git-dir >/dev/null 2>&1 ); then
echo " FAIL scratch dir is inside a git repo even with GIT_CEILING_DIRECTORIES set;"
echo " the outside-a-repo precondition cannot be established -- refusing to report a result"
exit 1
fi
echo "== outside a git repo: rc=1 AND the diagnostic is emitted =="
check "detect_platform" "$(run_outside detect_platform)" "1:yes"
check "get_repo_info" "$(run_outside get_repo_info)" "1:yes"
echo "== inside a git repo the functions still work =="
git init -q "$TMP/repo" 2>/dev/null
git -C "$TMP/repo" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git 2>/dev/null
out=$( cd "$TMP/repo" && bash -c "set -e; source '$HERE/detect-platform.sh'; detect_platform" 2>&1 ); rc=$?
if [ "$rc" -eq 0 ] && grep -qi 'gitea' <<<"$out"; then echo " PASS detect_platform in-repo (rc=0, $out)"
else echo " FAIL detect_platform in-repo: rc=$rc out=$out"; fail=1; fi
[ "$fail" -eq 0 ] && echo "OK detect-platform fails loudly outside a repo" || echo "FAILED"
exit "$fail"
@@ -1,64 +0,0 @@
#!/bin/bash
# Regression: the tea-failure diagnostic must be STATUS-NEUTRAL.
#
# Found by be-coder-08 reviewing PR #1086. At all three call sites the diagnostic is emitted
# immediately BEFORE the Gitea API fallback. Written as the last command of an && list:
# declare -F explain_... >/dev/null && explain_...
# under `set -e` a FAILING diagnostic exits and the fallback never runs -- a diagnostic that
# suppresses the recovery path it exists to explain. It misbehaves ONLY when the helper is
# PRESENT, so the helper-absent path (pre-#1086 behaviour) keeps working and reads as a
# passing control.
#
# TWO DEFECTS IN THE FIRST VERSION OF THIS TEST, both found by be-coder-08:
# 1. `out=$( ... ) 2>"$errto"` applies the redirection to the ASSIGNMENT, not to the
# command substitution, so the probe's stderr was never actually pointed at /dev/full
# and the /dev/full rows proved nothing. Verified: `out=$(echo x >&2) 2>/dev/full`
# leaks to the terminal and returns 0; the redirect must be INSIDE the substitution.
# 2. `eval "$CONSTRUCT"` changes `set -e` semantics for a bare && list, so the probe did
# not exercise the construct as the shipped file executes it. It now writes the line
# into a real script and runs it -- same parse, same set -e rules, no eval.
# The construct is still LIFTED FROM THE SHIPPED FILE: retyping the fixed form makes the
# probe pass on a build whose real call sites still carry the bare && form.
set -uo pipefail
fail=0
GIT_DIR_UNDER_TEST="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
probe() { # $1=present|absent $2=stderr target $3=source file -> "rc:fallback"
local helper="$1" errto="$2" src="$3" construct script out rc
construct=$(grep -m1 'explain_tea_user_does_not_exist' "$GIT_DIR_UNDER_TEST/$src" | sed 's/^[[:space:]]*//')
[ -n "$construct" ] || { printf 'no-construct:no'; return; }
script="$TMP/probe.sh"
{
echo '#!/bin/bash'
echo 'set -e'
echo 'explain_tea_user_does_not_exist() { echo "diagnostic" >&2; }'
[ "$helper" = absent ] && echo 'unset -f explain_tea_user_does_not_exist'
echo "$construct" # the shipped line, parsed by a real shell
echo 'echo FALLBACK_REACHED'
} > "$script"
# redirect INSIDE the substitution so the subshell's stderr really is $errto
out=$( bash "$script" 2>"$errto" ); rc=$?
printf '%s:%s' "$rc" "$(grep -q FALLBACK_REACHED <<<"$out" && echo yes || echo no)"
}
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
echo "== diagnostic must not alter exit status or skip the fallback =="
# /dev/full makes every stderr write fail -- the real-world shape is a closed or full fd.
for src in pr-create.sh issue-view.sh issue-create.sh; do
check "$src stderr OK / helper present" "$(probe present /dev/null "$src")" "0:yes"
check "$src stderr OK / helper absent " "$(probe absent /dev/null "$src")" "0:yes"
check "$src stderr FAILING / helper present" "$(probe present /dev/full "$src")" "0:yes"
check "$src stderr FAILING / helper absent " "$(probe absent /dev/full "$src")" "0:yes"
done
echo "== all three call sites use the status-neutral form =="
for f in pr-create.sh issue-view.sh issue-create.sh; do
p="$GIT_DIR_UNDER_TEST/$f"
grep -q '{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true' "$p" \
&& echo " PASS $f guarded" || { echo " FAIL $f: diagnostic is not status-neutral"; fail=1; }
done
[ "$fail" -eq 0 ] && echo "OK diagnostic is status-neutral" || echo "FAILED"
exit "$fail"
@@ -1,150 +0,0 @@
#!/usr/bin/env bash
# Regression: issue-close.sh must NOT close an issue when the closing comment could not
# be posted, and comment+close must be made by ONE principal.
#
# Guards two defects fixed together (see #1081):
# 1. `tea issue comment` is not a subcommand -- tea exposes comments as the TOP-LEVEL
# `tea comment`. The old call always failed, was unchecked, and the issue closed
# anyway, losing the record of WHY it was closed.
# 2. Routing the comment through the token-authenticated API helper while the close
# used --login would attribute one operation to two principals.
#
# SAFETY (rev-974, #1085 review 130): this test previously ran under `set -uo pipefail`
# with unchecked mkdir/redirect/cd, then prepended a possibly-nonexistent $MOCK_BIN to
# PATH -- while `git remote add origin` names the REAL repository. Forcing setup failure
# with an unwritable AGENT_WORK_ROOT made it `git init` in its CALLER's directory and
# invoke the real, provider-mutating issue-close.sh. Setup now fails closed, and both
# `tea` and `curl` are asserted to resolve INSIDE $MOCK_BIN before any target run.
set -euo pipefail
# NOTE: with `set -e`, `grep -q X && fail "..."` is a trap -- the ABSENT case (grep rc=1,
# which is the PASSING case for a must-not-appear assertion) is the last command of an &&
# list and silently terminates the script with no message. Every must-not-appear check
# below is therefore an if-block. This is the same set -e + &&-list defect be-coder-08
# found in #1086, reintroduced here by adding `set -e` for the sandbox-safety fix.
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
SANDBOX="$WORK_ROOT/issue-close-fail-closed-test-$$"
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
cleanup() { rm -rf "$SANDBOX"; }
trap cleanup EXIT
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="$SCRIPT_DIR/issue-close.sh"
[ -f "$TARGET" ] || { echo "FAIL: issue-close.sh not found beside this test"; exit 1; }
fail() { echo "FAIL: $*"; exit 1; }
# Every setup step is checked. Under `set -e` these abort; the explicit || fail keeps the
# reason legible instead of a bare non-zero exit.
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
git init -q || fail "setup: git init failed"
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
export PATH="$MOCK_BIN:$PATH" CALLS
export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
method=GET; url=""
while [ $# -gt 0 ]; do
case "$1" in
-X) method="$2"; shift 2 ;;
http*|https*) url="$1"; shift ;;
*) shift ;;
esac
done
printf 'curl %s %s\n' "$method" "$url" >> "$CALLS"
[ "${MOCK_CURL_FAIL:-}" = "1" ] && [ "$method" = "POST" ] && exit 22
exit 0
EOF
chmod +x "$MOCK_BIN/curl"
mk_tea() { # $1 = exit code for a comment attempt; $2 = login list (empty => no login)
local rc="$1" login="${2-}"
cat > "$MOCK_BIN/tea" <<EOF
#!/bin/bash
printf 'tea %s\n' "\$*" >> "$CALLS"
if [[ "\$*" == *"login list"* ]]; then
printf '%s\n' '${login}'; exit 0
fi
# Fail ANY comment attempt -- both the correct top-level \`tea comment\` and the broken
# \`tea issue comment\` -- so an unfixed script exercises the DEFECT rather than tripping
# a setup assertion.
if [[ "\$1" == "comment" || ( "\$1" == "issue" && "\$2" == "comment" ) ]]; then exit $rc; fi
exit 0
EOF
chmod +x "$MOCK_BIN/tea"
}
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
# The mocks must be the ones that run. Without this, a failed setup silently falls through
# to the real tea/curl and the "test" mutates the real provider.
assert_mocked() {
local w
for w in tea curl; do
p=$(command -v "$w" || true)
[ -n "$p" ] || fail "SAFETY: $w does not resolve at all"
case "$p" in
"$MOCK_BIN"/*) : ;;
*) fail "SAFETY: $w resolves to $p, OUTSIDE the sandbox -- refusing to invoke the target" ;;
esac
done
}
run_target() { # never let a target failure abort the test; we assert on rc
# Call sites MUST use `rc=0; run_target ... || rc=$?` -- a bare `run_target ...; rc=$?`
# lets the non-zero RETURN trip set -e in the CALLER before rc is ever read.
set +e; bash "$TARGET" "$@" >/dev/null 2>&1; local rc=$?; set -e; return $rc
}
# ── tea path ────────────────────────────────────────────────────────────────────────
# 1. NEGATIVE (the regression): comment fails => must NOT close, must exit non-zero
mk_tea 1 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
rc=0; run_target -i 42 -c "closing note" || rc=$?
grep -qE 'tea (issue )?comment' "$CALLS" || fail "no comment attempt -- setup did not reach the tea branch"
if grep -q 'tea issue close' "$CALLS"; then fail "ISSUE CLOSED AFTER THE COMMENT FAILED -- the regression"; fi
[ "$rc" -ne 0 ] || fail "comment failed but issue-close exited 0 -- FAIL-OPEN"
# 2. POSITIVE: comment succeeds => close proceeds, exit 0
mk_tea 0 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
rc=0; run_target -i 42 -c "closing note" || rc=$?
[ "$rc" -eq 0 ] || fail "comment succeeded but issue-close exited $rc"
grep -q 'tea issue close' "$CALLS" || fail "issue not closed even though the comment succeeded"
# 3. must use top-level `tea comment`, never `tea issue comment`
if grep -q 'tea issue comment' "$CALLS"; then fail "used 'tea issue comment' -- not a valid subcommand"; fi
# 4. ONE PRINCIPAL: comment and close must carry the SAME --login
c=$(grep -m1 '^tea comment' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
k=$(grep -m1 '^tea issue close' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
[ -n "$c" ] || fail "comment carried no --login"
[ "$c" = "$k" ] || fail "MIXED PRINCIPALS: comment=$c close=$k"
# ── no-login / API fallback path ────────────────────────────────────────────────────
# rev-974: the delta also adds fail-closed behaviour to this branch, and the suite never
# reached it -- replacing the whole fallback contract with an unconditional close still
# passed. These assert the POSTCONDITION (which HTTP calls happened, in what order),
# not merely that a command ran.
# 5. no login + comment FAILS => POST attempted, NO PATCH, non-zero
mk_tea 0 ""; : > "$CALLS"; assert_mocked
rc=0; MOCK_CURL_FAIL=1 run_target -i 42 -c "closing note" || rc=$?
grep -q 'curl POST' "$CALLS" || fail "API path: no comment POST attempted"
if grep -q 'curl PATCH' "$CALLS"; then fail "API path: ISSUE CLOSED (PATCH) AFTER THE COMMENT POST FAILED"; fi
[ "$rc" -ne 0 ] || fail "API path: comment failed but exited 0 -- FAIL-OPEN"
# 6. no login + comment SUCCEEDS => POST strictly BEFORE PATCH, exit 0
mk_tea 0 ""; : > "$CALLS"; assert_mocked
rc=0; run_target -i 42 -c "closing note" || rc=$?
[ "$rc" -eq 0 ] || fail "API path: comment succeeded but exited $rc"
order=$(grep -oE 'curl (POST|PATCH)' "$CALLS" | awk '{print $2}' | paste -sd, -)
[ "$order" = "POST,PATCH" ] || fail "API path: expected POST,PATCH -- got '${order:-<none>}'"
# 7. no login + NO comment => PATCH only, never a POST
mk_tea 0 ""; : > "$CALLS"; assert_mocked
rc=0; run_target -i 42 || rc=$?
[ "$rc" -eq 0 ] || fail "API path: no-comment close exited $rc"
if grep -q 'curl POST' "$CALLS"; then fail "API path: posted a comment when none was requested"; fi
grep -q 'curl PATCH' "$CALLS" || fail "API path: issue not closed when no comment was requested"
echo "issue-close.sh fail-closed + single-principal regression passed"
@@ -39,7 +39,7 @@ MAX_FRAME: Final = 64 * 1024
MAX_STATE: Final = 4 * 1024 * 1024
MAX_PENDING_TOKENS: Final = 256
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
MAX_LEASE_TTL_SECONDS: Final = 300
MAX_LEASE_TTL_SECONDS: Final = 3600
STATE_VERSION: Final = 1
READ_DEADLINE_SECONDS: Final = 1.0
HANDLE_QUEUE_TIMEOUT_SECONDS: Final = 1.0
@@ -50,8 +50,8 @@ LEASE_PENDING: Final = "PENDING_VERIFICATION"
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
LEASE_VERIFIED: Final = "VERIFIED"
READ_ONLY_TOOLS: Final = {
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
"pi": frozenset({"read", "grep", "find", "ls"}),
"claude": frozenset({"Read", "Grep", "Glob"}),
"pi": frozenset({"read", "ls"}),
}
RECOVERY_TOOL: Final = "mosaic_context_recover"
@@ -8,7 +8,9 @@ import json
import os
import socket
import sys
import time
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime, timezone
from pathlib import Path
from typing import Final
@@ -53,6 +55,48 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
return value
def _self_starttime() -> str | None:
"""Field 22 of our own /proc stat — the anchor starttime the broker records.
Read past the comm field's parens, since a process name may contain them.
"""
try:
raw = Path(f"/proc/{os.getpid()}/stat").read_text()
return raw.rsplit(")", 1)[1].split()[19]
except (OSError, IndexError, ValueError):
return None
def _append_launch_record(environ: Mapping[str, str], record: dict[str, object]) -> None:
"""Append one NDJSON event to the #797 Runtime Session Ledger.
`fleet/run/sessions/` is operator-classified in framework-manifest.txt and is
already covered by test-upgrade-manifest-guard.sh, so an upgrade can neither
overwrite nor prune it. Files 0600 under a 0700 dir, matching what that guard
asserts.
Never raises: a launch must not be denied over bookkeeping. But it also never
fails silently a missing record is exactly the kind of gap that made the
2026-08-06 MUTATOR_UNVERIFIED investigation cost a day.
"""
try:
mosaic_home = environ.get("MOSAIC_HOME") or str(Path.home() / ".config" / "mosaic")
directory = Path(mosaic_home) / "fleet" / "run" / "sessions"
directory.mkdir(parents=True, exist_ok=True)
os.chmod(directory, 0o700)
framed = {
"seq": time.time_ns() // 1_000_000,
"ts": datetime.now(timezone.utc).isoformat(),
**record,
}
path = directory / "events.ndjson"
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
with os.fdopen(descriptor, "w") as handle:
handle.write(json.dumps(framed, separators=(",", ":")) + "\n")
except (OSError, ValueError, TypeError) as error:
print(f"[mosaic] WARNING: launch record not written: {error}", file=sys.stderr)
def main(
argv: Sequence[str] | None = None,
*,
@@ -94,8 +138,9 @@ def main(
# silent pass and never folded into the generic registration-failure
# branch.
try:
activation_capability = probe_activation_capability(source_environment)
assert_activation_capability_matches(
probe_activation_capability(source_environment),
activation_capability,
expected_activation_capability,
)
except VersionCouplingError as version_error:
@@ -128,6 +173,32 @@ def main(
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
return 1
# Immutable launch record, half two. `mosaic` wrote `session.launch` with the
# config/provenance it knows; only this process knows the broker session id
# and the activation capability it just asserted. os.execvpe preserves the
# PID, so this PID is BOTH the anchor pid and the join key back to that
# record. Never fatal — bookkeeping must not deny a launch — but never
# silent either.
_append_launch_record(
source_environment,
{
"kind": "lease.register",
# Joins back to `mosaic`'s session.launch record. NOT pid: execRuntime()
# spawns rather than execs, so this process is a CHILD of mosaic with a
# different pid. This pid IS the broker anchor pid (os.execvpe below
# preserves it), which is a separate and still-useful fact.
"launch_id": source_environment.get("MOSAIC_LAUNCH_ID"),
"pid": os.getpid(),
"runtime": arguments.runtime,
"session_id": session_id,
"runtime_generation": generation,
"generation_file": str(generation_file),
"anchor_starttime": _self_starttime(),
"activation_capability": activation_capability,
"command": Path(command[0]).name,
},
)
environment = dict(source_environment)
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""Lease promotion client — the half the enforcement toolkit never shipped.
The enforcement half (``daemon.py`` + ``mutator-gate.py``) ships and denies. The
promotion half has no production caller anywhere in the package: as of 0.0.48,
0.0.49 and 0.0.50-next.2207, ``begin_verification`` / ``observe_receipt`` /
``promote_lease`` are invoked only by ``broker-test-client.ts``, the acceptance
spec, unit tests, and two probes under ``docs/``. Consequence: **no lease on any
host can reach VERIFIED**, so every mutator is denied ``MUTATOR_UNVERIFIED`` by a
gate nothing can satisfy.
THE PROTOCOL (``daemon.py:578-754``)
------------------------------------
1. ``begin_verification`` broker revokes, mints a challenge, and returns the
exact ``receipt`` text the MODEL must emit
2. *the model emits that text verbatim as its ENTIRE latest message*
3. the runtime adapter ships that message to the daemon-owned observer socket
4. ``observe_receipt`` -> ``PENDING_PROMOTION``
5. ``promote_lease`` -> ``VERIFIED``
THIS MODULE IMPLEMENTS 1, 4 AND 5 NEVER 2
-------------------------------------------
Step 2 is the security property, not a formality. ``is_verbatim_receipt`` uses
``hmac.compare_digest`` against the exact minted string explicitly "not a
transcript substring" (``receipt_challenge.py``). Promotion therefore requires a
live model that received the challenge in its context and echoed it exactly.
``receipt-observer-client.py`` will post ANY string as the latest assistant
message. A promotion client that posted its own receipt would satisfy the broker
while proving nothing a gate-disabler indistinguishable from a working fix
unless someone looks for it. **This module never posts a receipt.** Emitting it
belongs to the runtime adapter, where a real model turn happens.
The construction binds the exact normative source bytes. ``h_source`` /
``h_payload`` are derived by the framework's own
``normative_fragments.build_payload`` rather than reimplemented: the broker
derives them the same way and any divergence yields ``PAYLOAD_BINDING_MISMATCH``.
There must be exactly one implementation.
WHAT THE BINDING DOES *NOT* PROVE
---------------------------------
It is tempting to read a VERIFIED lease as "this agent is running THIS law".
**It does not mean that**, and writing it down that way is how the belief spread.
The broker holds no reference copy of any normative source and never opens one;
it recomputes ``h_source`` / ``h_payload`` from the fragment bytes THIS CLIENT
sent and compares them to the binding THIS CLIENT sent (``daemon.py:602-616``).
Both sides of that comparison originate here, so it detects corruption in
transit and nothing else. What the binding actually asserts is "the client
claims these bytes, self-consistently".
Making it mean the stronger thing requires the broker to re-read the on-disk
sources itself, against a manifest the agent cannot rewrite i.e. broker code
attestation under its own uid. Until then, do not cite a VERIFIED lease as
evidence of law integrity.
Usage
-----
lease_promote.py --begin # prints the receipt the MODEL must emit
lease_promote.py --complete <challenge> # after the adapter observed it
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import socket
import sys
from pathlib import Path
from typing import Final
# Isolated (`python -I`) adapter invocations must still import co-located
# framework modules; never depend on the caller's PYTHONPATH.
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
if _MODULE_DIRECTORY not in sys.path:
sys.path.insert(0, _MODULE_DIRECTORY)
from normative_fragments import NormativeFragment, build_payload # noqa: E402
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 3.0
SCHEMA_VERSION: Final = 1
MANIFEST_VERSION: Final = 1
GENERATOR_VERSION: Final = "mosaic/lease_promote@1"
DEFAULT_TTL_SECONDS: Final = 3600
# Normative sources whose exact bytes bind the lease, in binding order. Order is
# load-bearing: ``h_source`` frames the resolved sequence, so reordering changes
# the derivation. Never fabricate a source that is not on disk.
FRAGMENT_SOURCES: Final = (
"CONSTITUTION.md",
"AGENTS.md",
"SOUL.md",
"USER.md",
"STANDARDS.md",
"TOOLS.md",
)
# Framework-owned sources, reconciled on every upgrade — `install.sh:76`
# FRAMEWORK_OWNED and `config/file-adapter.ts` FRAMEWORK_OWNED_FILES — plus the
# per-runtime contract shipped under `framework/runtime/<runtime>/`. A deployment
# missing one of these is broken, not minimal, so their absence is refused rather
# than silently dropped from the binding.
#
# SOUL.md and USER.md are deliberately excluded: install.sh does not seed them
# ("intentionally NOT seeded here — they are generated by `mosaic init`"), so a
# fresh install legitimately lacks both. TOOLS.md is user-seeded on first install
# only. Absence of those three is reported, not fatal.
REQUIRED_SOURCES: Final = frozenset({"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"})
class IncompleteBinding(RuntimeError):
"""A source that must bind this lease could not be read.
**Never downgrade this to a skip.** The broker recomputes the hashes from the
fragments it is sent, so an omitted fragment is internally consistent and
``PAYLOAD_BINDING_MISMATCH`` cannot fire a partial law promotes exactly like
a complete one, and nothing downstream can tell the difference. Dropping an
unreadable source therefore does not degrade the binding, it forges a smaller
one. Fail here, where the omission is still visible.
"""
def mosaic_home() -> Path:
return Path(os.environ.get("MOSAIC_HOME") or Path.home() / ".config" / "mosaic")
def broker_socket() -> Path:
value = os.environ.get("MOSAIC_LEASE_BROKER_SOCKET")
if value:
return Path(value)
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
if runtime_dir:
return Path(runtime_dir) / "mosaic-lease" / "broker.sock"
return Path(f"/run/user/{os.getuid()}/mosaic-lease/broker.sock")
def session_identity() -> tuple[str, int, str]:
"""Session id, CURRENT generation, runtime.
The generation file wins over the env var, matching ``lease_generation.py``.
Sending a generation HIGHER than the broker's would revoke this session's own
authority (``daemon.py:342-344``), so this never guesses.
"""
session_id = os.environ["MOSAIC_LEASE_SESSION_ID"]
runtime = os.environ["MOSAIC_LEASE_RUNTIME"]
state_file = os.environ.get("MOSAIC_LEASE_GENERATION_FILE")
if state_file:
try:
return session_id, int(Path(state_file).read_text().strip()), runtime
except (OSError, ValueError):
pass
return session_id, int(os.environ["MOSAIC_RUNTIME_GENERATION"]), runtime
def build_construction(runtime: str) -> tuple[dict[str, object], object]:
"""Assemble the wire construction and derive its hashes with the sole builder."""
runtime_contract = f"runtime/{runtime}/RUNTIME.md"
sources = list(FRAGMENT_SOURCES) + [runtime_contract]
required = REQUIRED_SOURCES | {runtime_contract}
wire_fragments: list[dict[str, str]] = []
objects: list[NormativeFragment] = []
absent: list[str] = []
for source_id in sources:
try:
content = (mosaic_home() / source_id).read_bytes()
except FileNotFoundError:
# Genuinely not on disk. Legitimate only for operator-owned sources.
if source_id in required:
raise IncompleteBinding(
f"required normative source is absent: {source_id}"
) from None
absent.append(source_id)
continue
except OSError as exc:
# The path resolves but will not read — EACCES, EIO, EISDIR, ELOOP.
# That is an anomaly for EVERY source, optional ones included: an
# unreadable file is not an un-configured one, and treating it as
# absent is what lets a permission change quietly shrink the law.
raise IncompleteBinding(
f"normative source is present but unreadable: {source_id} "
f"({type(exc).__name__})"
) from exc
digest = hashlib.sha256(content).hexdigest()
wire_fragments.append(
{
"source_id": source_id,
"content_base64": base64.b64encode(content).decode("ascii"),
"expected_sha256": digest,
}
)
objects.append(NormativeFragment(source_id, content, digest))
if not wire_fragments:
raise IncompleteBinding("no normative sources found — refusing an empty binding")
# Absence is legitimate here but never invisible. The omission is already
# baked into h_source (the framed source sequence differs), but nothing
# compares h_source to an expected value, so this line is the only place a
# human learns the binding was narrower than the full set.
if absent:
print(
f"lease_promote: binding omits absent operator sources: {', '.join(absent)}",
file=sys.stderr,
)
result = build_payload(
manifest_version=MANIFEST_VERSION,
generator_version=GENERATOR_VERSION,
fragments=objects,
)
if result.injectionDecision != "ACCEPTED" or not result.promotion:
raise RuntimeError(f"construction refused locally: {result.source_reason}")
return (
{
"manifest_version": MANIFEST_VERSION,
"generator_version": GENERATOR_VERSION,
"fragments": wire_fragments,
},
result,
)
def broker_request(payload: dict[str, object]) -> dict[str, object]:
raw = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
if len(raw) > MAX_FRAME:
raise ValueError(
f"request too large ({len(raw)} bytes); broker frame cap is {MAX_FRAME}"
)
response = bytearray()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(BROKER_TIMEOUT_SECONDS)
connection.connect(str(broker_socket()))
connection.sendall(raw)
connection.shutdown(socket.SHUT_WR)
while len(response) <= MAX_FRAME:
chunk = connection.recv(4096)
if not chunk:
break
response.extend(chunk)
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
raise ValueError("invalid broker reply")
value = json.loads(response)
if not isinstance(value, dict):
raise ValueError("invalid broker reply")
return value
def begin(
ttl_seconds: int = DEFAULT_TTL_SECONDS,
compaction_epoch: int = 0,
request_epoch: int = 0,
) -> dict[str, object]:
"""Step 1. Returns the broker reply, including the exact ``receipt`` text."""
session_id, generation, runtime = session_identity()
construction, derived = build_construction(runtime)
return broker_request(
{
"action": "begin_verification",
"session_id": session_id,
"runtime_generation": generation,
"runtime": runtime,
"ttl_seconds": ttl_seconds,
"binding": {
"compaction_epoch": compaction_epoch,
"request_epoch": request_epoch,
"h_source": derived.h_source,
"h_payload": derived.h_payload,
"schema_version": SCHEMA_VERSION,
},
"construction": construction,
}
)
def complete(challenge: str) -> dict[str, object]:
"""Steps 4-5. Assumes the model already emitted the receipt and the adapter
shipped it to the observer socket."""
session_id, generation, _ = session_identity()
observed = broker_request(
{
"action": "observe_receipt",
"session_id": session_id,
"runtime_generation": generation,
"receipt_challenge": challenge,
}
)
if observed.get("ok") is not True or observed.get("state") != "PENDING_PROMOTION":
return {"stage": "observe_receipt", **observed}
promoted = broker_request(
{
"action": "promote_lease",
"session_id": session_id,
"runtime_generation": generation,
"receipt_challenge": challenge,
}
)
return {"stage": "promote_lease", **promoted}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Mosaic lease promotion client.")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--begin",
action="store_true",
help="mint a challenge; prints the receipt the MODEL must emit verbatim",
)
group.add_argument(
"--complete",
metavar="CHALLENGE",
help="observe the emitted receipt and promote the lease",
)
parser.add_argument("--ttl-seconds", type=int, default=DEFAULT_TTL_SECONDS)
arguments = parser.parse_args(argv)
try:
if arguments.begin:
print(json.dumps(begin(ttl_seconds=arguments.ttl_seconds), indent=2))
else:
print(json.dumps(complete(arguments.complete), indent=2))
except KeyError as exc:
print(f"missing lease environment: {exc}; not a lease-gated session", file=sys.stderr)
return 2
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""Claude UserPromptSubmit hook for operator-triggered lease promotion."""
from __future__ import annotations
import fcntl
import json
import os
import secrets
import stat
import subprocess
import sys
import time
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Final, TextIO
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
if _MODULE_DIRECTORY not in sys.path:
sys.path.insert(0, _MODULE_DIRECTORY)
from receipt_challenge import receipt_for # noqa: E402
MAX_FRAME: Final = 64 * 1024
PENDING_MAX_AGE_SECONDS: Final = 60 * 60
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
PROMOTION_PROMPT: Final = "/mosaic-promote"
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
PENDING_DIRECTORY: Final = "mosaic-lease"
LOCK_FILE: Final = "promotion.lock"
EXPECTED_BEGIN_KEYS: Final = frozenset(
{"ok", "state", "receipt_challenge", "receipt", "binding"}
)
EXPECTED_BINDING_KEYS: Final = frozenset(
{
"compaction_epoch",
"request_epoch",
"h_source",
"h_payload",
"runtime_generation",
"schema_version",
}
)
class PromotionAlreadyInProgress(RuntimeError):
pass
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
value: dict[str, object] = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate promoter JSON key")
value[key] = item
return value
def read_hook_input(stream: object) -> dict[str, object]:
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
raise ValueError("invalid UserPromptSubmit input")
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
if not isinstance(value, dict):
raise ValueError("invalid UserPromptSubmit input")
return value
def emit_context(stream: TextIO, message: str) -> None:
json.dump(
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": message,
}
},
stream,
separators=(",", ":"),
)
stream.write("\n")
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
if not runtime_dir.is_absolute():
raise ValueError("XDG_RUNTIME_DIR must be absolute")
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
raise ValueError("invalid lease session id")
return runtime_dir, f"pending-{session_id}"
def open_pending_directory(runtime_dir: Path) -> int:
directory_flags = (
os.O_RDONLY
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_DIRECTORY", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
runtime_descriptor = os.open(runtime_dir, directory_flags)
try:
runtime_metadata = os.fstat(runtime_descriptor)
if (
not stat.S_ISDIR(runtime_metadata.st_mode)
or runtime_metadata.st_uid != os.getuid()
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
):
raise ValueError("unsafe XDG runtime directory")
try:
os.mkdir(PENDING_DIRECTORY, mode=0o700, dir_fd=runtime_descriptor)
except FileExistsError:
pass
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
finally:
os.close(runtime_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISDIR(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o700
):
os.close(descriptor)
raise ValueError("unsafe promotion pending directory")
return descriptor
def acquire_lock(directory_descriptor: int) -> int:
flags = (
os.O_RDWR
| os.O_CREAT
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o600
):
os.close(descriptor)
raise ValueError("unsafe promotion lock file")
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as error:
os.close(descriptor)
raise PromotionAlreadyInProgress() from error
return descriptor
def sweep_stale_pending(directory_descriptor: int, current_time: float) -> None:
cutoff = current_time - PENDING_MAX_AGE_SECONDS
removed = False
with os.scandir(directory_descriptor) as entries:
for candidate in entries:
if not (
candidate.name.startswith("pending-")
or candidate.name.startswith(".pending-")
):
continue
try:
metadata = candidate.stat(follow_symlinks=False)
if metadata.st_mtime < cutoff and not stat.S_ISDIR(metadata.st_mode):
os.unlink(candidate.name, dir_fd=directory_descriptor)
removed = True
except FileNotFoundError:
continue
if removed:
os.fsync(directory_descriptor)
def write_pending(directory_descriptor: int, name: str, challenge: str) -> None:
temporary = f".{name}.tmp-{secrets.token_hex(8)}"
flags = (
os.O_WRONLY
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
stream.write(challenge)
stream.flush()
os.fsync(stream.fileno())
os.replace(
temporary,
name,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
)
os.fsync(directory_descriptor)
except Exception:
try:
os.unlink(temporary, dir_fd=directory_descriptor)
except FileNotFoundError:
pass
raise
finally:
os.close(descriptor)
def parse_begin_reply(
completed: subprocess.CompletedProcess[str],
) -> tuple[str, dict[str, object] | None]:
if completed.returncode != 0:
return f"PROMOTER_EXIT_{completed.returncode}", None
try:
value = json.loads(
completed.stdout,
object_pairs_hook=reject_duplicate_json_keys,
)
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
return "INVALID_PROMOTER_REPLY", None
if not isinstance(value, dict):
return "INVALID_PROMOTER_REPLY", None
if value.get("ok") is False and set(value) == {"ok", "code"}:
code = value.get("code")
return code if isinstance(code, str) and code else "PROMOTION_BEGIN_REFUSED", value
if set(value) != EXPECTED_BEGIN_KEYS or value.get("ok") is not True:
return "INVALID_PROMOTER_REPLY", None
if value.get("state") != "PENDING_VERIFICATION":
return "INVALID_PROMOTER_REPLY", None
challenge = value.get("receipt_challenge")
receipt = value.get("receipt")
binding = value.get("binding")
if (
not isinstance(challenge, str)
or len(challenge) != 64
or any(character not in "0123456789abcdef" for character in challenge)
or not isinstance(receipt, str)
or not isinstance(binding, dict)
or set(binding) != EXPECTED_BINDING_KEYS
):
return "INVALID_PROMOTER_REPLY", None
integer_fields = (
"compaction_epoch",
"request_epoch",
"runtime_generation",
"schema_version",
)
if any(type(binding.get(field)) is not int or binding[field] < 0 for field in integer_fields):
return "INVALID_PROMOTER_REPLY", None
if not all(
isinstance(binding.get(field), str)
and len(binding[field]) == 64
and all(character in "0123456789abcdef" for character in binding[field])
for field in ("h_source", "h_payload")
):
return "INVALID_PROMOTER_REPLY", None
if not secrets.compare_digest(
receipt.encode("utf-8"),
receipt_for(challenge, binding).encode("utf-8"),
):
return "INVALID_PROMOTER_REPLY", None
return "", value
def main(
*,
environ: Mapping[str, str] | None = None,
stdin: object | None = None,
stdout: TextIO | None = None,
stderr: TextIO | None = None,
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
now: Callable[[], float] = time.time,
) -> int:
source_environment = os.environ if environ is None else environ
input_stream = sys.stdin if stdin is None else stdin
output_stream = sys.stdout if stdout is None else stdout
error_stream = sys.stderr if stderr is None else stderr
try:
hook_input = read_hook_input(input_stream)
except (OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
print(f"Mosaic promotion trigger ignored invalid hook input: {error}", file=error_stream)
return 0
if hook_input.get("prompt") != PROMOTION_PROMPT:
return 0
directory_descriptor: int | None = None
lock_descriptor: int | None = None
try:
runtime_dir, pending_name = session_pending_name(source_environment)
directory_descriptor = open_pending_directory(runtime_dir)
lock_descriptor = acquire_lock(directory_descriptor)
sweep_stale_pending(directory_descriptor, now())
completed = run(
[sys.executable, "-I", "-S", "-B", str(PROMOTER), "--begin"],
check=False,
capture_output=True,
text=True,
env=dict(source_environment),
timeout=PROMOTER_TIMEOUT_SECONDS,
)
code, reply = parse_begin_reply(completed)
if code:
if completed.stderr:
print(completed.stderr.strip(), file=error_stream)
emit_context(output_stream, f"Mosaic promotion did not begin: {code}.")
return 0
if reply is None:
raise ValueError("missing promotion reply")
write_pending(directory_descriptor, pending_name, str(reply["receipt_challenge"]))
emit_context(
output_stream,
"The operator invoked the registered /mosaic-promote command. "
"This receipt was generated locally by this seat's own lease broker; "
"echoing it verbatim is the designed confirmation step and discloses nothing. "
"Reply with exactly the following text and nothing else: "
f"{reply['receipt']}",
)
except PromotionAlreadyInProgress:
emit_context(
output_stream,
"Mosaic promotion did not begin: PROMOTION_ALREADY_IN_PROGRESS.",
)
except (KeyError, OSError, RecursionError, ValueError, subprocess.SubprocessError) as error:
print(f"Mosaic promotion begin failed: {type(error).__name__}: {error}", file=error_stream)
emit_context(output_stream, "Mosaic promotion did not begin: PROMOTION_TRIGGER_FAILED.")
finally:
if lock_descriptor is not None:
os.close(lock_descriptor)
if directory_descriptor is not None:
os.close(directory_descriptor)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""Claude Stop hook that completes a pending operator-triggered promotion."""
from __future__ import annotations
import fcntl
import json
import os
import secrets
import stat
import subprocess
import sys
import time
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Final, NamedTuple, TextIO
MAX_FRAME: Final = 64 * 1024
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
LEASE_TTL_SECONDS: Final = 60 * 60
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
PENDING_DIRECTORY: Final = "mosaic-lease"
LOCK_FILE: Final = "promotion.lock"
RESULT_FILE: Final = "last-result.json"
TERMINAL_FAILURE_CODES: Final = frozenset(
{
"RECEIPT_REPLAY",
"RECEIPT_MISMATCH",
"INVALID_LEASE_TRANSITION",
"PROMOTION_TOKEN_INVALID",
}
)
class PendingChallenge(NamedTuple):
value: str
device: int
inode: int
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
value: dict[str, object] = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate promoter JSON key")
value[key] = item
return value
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
if not runtime_dir.is_absolute():
raise ValueError("XDG_RUNTIME_DIR must be absolute")
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
raise ValueError("invalid lease session id")
return runtime_dir, f"pending-{session_id}"
def open_pending_directory(runtime_dir: Path) -> int | None:
directory_flags = (
os.O_RDONLY
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_DIRECTORY", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
try:
runtime_descriptor = os.open(runtime_dir, directory_flags)
except FileNotFoundError:
return None
try:
runtime_metadata = os.fstat(runtime_descriptor)
if (
not stat.S_ISDIR(runtime_metadata.st_mode)
or runtime_metadata.st_uid != os.getuid()
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
):
raise ValueError("unsafe XDG runtime directory")
try:
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
except FileNotFoundError:
return None
finally:
os.close(runtime_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISDIR(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o700
):
os.close(descriptor)
raise ValueError("unsafe promotion pending directory")
return descriptor
def acquire_lock(directory_descriptor: int) -> int:
flags = (
os.O_RDWR
| os.O_CREAT
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o600
):
os.close(descriptor)
raise ValueError("unsafe promotion lock file")
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(descriptor)
raise
return descriptor
def read_pending(directory_descriptor: int, name: str) -> PendingChallenge | None:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(name, flags, dir_fd=directory_descriptor)
except FileNotFoundError:
return None
try:
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o600
or metadata.st_size <= 0
or metadata.st_size > MAX_FRAME
):
raise ValueError("unsafe promotion pending file")
raw = os.read(descriptor, MAX_FRAME + 1)
finally:
os.close(descriptor)
if len(raw) > MAX_FRAME:
raise ValueError("oversized promotion challenge")
challenge = raw.decode("utf-8")
if (
len(challenge) != 64
or any(character not in "0123456789abcdef" for character in challenge)
):
raise ValueError("invalid promotion challenge")
return PendingChallenge(challenge, metadata.st_dev, metadata.st_ino)
def write_result(
directory_descriptor: int,
attempt_id: str,
verified: bool,
reason: str | None,
session_id: str,
wall_clock: float,
) -> None:
result = {
"attempt_id": attempt_id,
"expires_at_wallclock": wall_clock + LEASE_TTL_SECONDS if verified else None,
"reason": reason,
"session_id": session_id,
"ts": wall_clock,
"verified": verified,
}
temporary = f".{RESULT_FILE}.tmp-{secrets.token_hex(8)}"
flags = (
os.O_WRONLY
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
json.dump(result, stream, separators=(",", ":"), sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
os.replace(
temporary,
RESULT_FILE,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
)
os.fsync(directory_descriptor)
except Exception:
try:
os.unlink(temporary, dir_fd=directory_descriptor)
except FileNotFoundError:
pass
raise
finally:
os.close(descriptor)
def delete_pending_if_unchanged(
directory_descriptor: int,
name: str,
pending: PendingChallenge,
error_stream: TextIO,
) -> None:
quarantine = f".{name}.delete-{secrets.token_hex(8)}"
try:
os.rename(
name,
quarantine,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
)
except FileNotFoundError:
return
except OSError as error:
print(f"Mosaic promotion could not quarantine pending file: {error}", file=error_stream)
return
try:
moved = os.stat(
quarantine,
dir_fd=directory_descriptor,
follow_symlinks=False,
)
if (moved.st_dev, moved.st_ino) == (pending.device, pending.inode):
os.unlink(quarantine, dir_fd=directory_descriptor)
os.fsync(directory_descriptor)
return
print("Mosaic promotion pending file changed; preserving replacement.", file=error_stream)
try:
os.link(
quarantine,
name,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
follow_symlinks=False,
)
except FileExistsError:
print(
f"Mosaic promotion preserved replacement as {quarantine}.",
file=error_stream,
)
else:
os.unlink(quarantine, dir_fd=directory_descriptor)
os.fsync(directory_descriptor)
except OSError as error:
print(f"Mosaic promotion could not resolve pending file: {error}", file=error_stream)
def parse_reply(completed: subprocess.CompletedProcess[str]) -> dict[str, object] | None:
if completed.returncode != 0:
return None
try:
value = json.loads(
completed.stdout,
object_pairs_hook=reject_duplicate_json_keys,
)
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
return None
if not isinstance(value, dict):
return None
if set(value) == {"stage", "ok", "state"}:
if (
value.get("stage") == "promote_lease"
and value.get("ok") is True
and value.get("state") == "VERIFIED"
):
return value
return None
if set(value) == {"stage", "ok", "code"}:
if (
value.get("stage") in {"observe_receipt", "promote_lease"}
and value.get("ok") is False
and isinstance(value.get("code"), str)
and value.get("code")
):
return value
return None
def main(
*,
environ: Mapping[str, str] | None = None,
stderr: TextIO | None = None,
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
now: Callable[[], float] = time.time,
) -> int:
source_environment = os.environ if environ is None else environ
error_stream = sys.stderr if stderr is None else stderr
directory_descriptor: int | None = None
lock_descriptor: int | None = None
try:
runtime_dir, pending_name = session_pending_name(source_environment)
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
directory_descriptor = open_pending_directory(runtime_dir)
if directory_descriptor is None:
return 0
try:
lock_descriptor = acquire_lock(directory_descriptor)
except (BlockingIOError, FileNotFoundError):
print("Mosaic promotion completion deferred: promotion is in progress.", file=error_stream)
return 0
pending = read_pending(directory_descriptor, pending_name)
if pending is None:
return 0
completed = run(
[
sys.executable,
"-I",
"-S",
"-B",
str(PROMOTER),
"--complete",
pending.value,
],
check=False,
capture_output=True,
text=True,
env=dict(source_environment),
timeout=PROMOTER_TIMEOUT_SECONDS,
)
reply = parse_reply(completed)
if reply is not None and reply.get("ok") is True:
write_result(directory_descriptor, pending.value, True, None, session_id, now())
delete_pending_if_unchanged(
directory_descriptor,
pending_name,
pending,
error_stream,
)
print("Mosaic lease promotion completed.", file=error_stream)
return 0
if reply is not None:
code = str(reply["code"])
print(f"Mosaic promotion incomplete: {code}.", file=error_stream)
if code in TERMINAL_FAILURE_CODES:
write_result(directory_descriptor, pending.value, False, code, session_id, now())
delete_pending_if_unchanged(
directory_descriptor,
pending_name,
pending,
error_stream,
)
else:
diagnostic = completed.stderr.strip() or f"promoter exit {completed.returncode}"
print(f"Mosaic promotion retryable failure: {diagnostic}.", file=error_stream)
except (KeyError, OSError, RecursionError, UnicodeError, ValueError, subprocess.SubprocessError) as error:
print(f"Mosaic promotion completion deferred: {type(error).__name__}: {error}", file=error_stream)
finally:
if lock_descriptor is not None:
os.close(lock_descriptor)
if directory_descriptor is not None:
os.close(directory_descriptor)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -22,13 +22,23 @@ from typing import Final
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
BENIGN_OBSERVATION_UNAVAILABLE_CODE: Final = "OBSERVATION_UNAVAILABLE"
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
value: dict[str, object] = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate observer JSON key")
value[key] = item
return value
def read_json(stream: object) -> dict[str, object]:
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
raise ValueError("invalid observer input")
value = json.loads(raw)
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
if not isinstance(value, dict):
raise ValueError("invalid observer input")
return value
@@ -100,9 +110,9 @@ def observer_request(socket_path: Path, request: dict[str, object]) -> dict[str,
if not chunk:
break
response.extend(chunk)
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
if len(response) > MAX_FRAME or response.count(b"\n") != 1 or not response.endswith(b"\n"):
raise ValueError("invalid observer reply")
value = json.loads(response)
value = json.loads(response[:-1], object_pairs_hook=reject_duplicate_json_keys)
if not isinstance(value, dict):
raise ValueError("invalid observer reply")
return value
@@ -119,6 +129,11 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
if arguments.runtime == "claude":
if not arguments.latest_entry:
raise ValueError("Claude observer requires --latest-entry")
if "last_assistant_message" in source:
message = source["last_assistant_message"]
if not isinstance(message, str):
raise ValueError("invalid Claude observer input")
else:
message = claude_latest_entry(source)
else:
if arguments.latest_entry:
@@ -133,10 +148,18 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
"runtime": arguments.runtime,
"latest_assistant_message": message,
})
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
except (KeyError, OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
return 2
return 0 if reply == {"ok": True} else 2
if set(reply) == {"ok"} and reply.get("ok") is True:
return 0
if (
set(reply) == {"ok", "code"}
and reply.get("ok") is False
and reply.get("code") == BENIGN_OBSERVATION_UNAVAILABLE_CODE
):
return 0
return 2
if __name__ == "__main__":
@@ -33,7 +33,7 @@ def is_verbatim_receipt(message: str, challenge: str, binding: dict[str, object]
"""Require the exact one current-cycle receipt, not a transcript substring."""
expected = receipt_for(challenge, binding)
return hmac.compare_digest(message, expected)
return hmac.compare_digest(message.encode("utf-8"), expected.encode("utf-8"))
def latest_assistant_digest(message: str) -> str:
@@ -32,6 +32,7 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
# --- single-suite directories: unmeasured in CI ---
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
+2
View File
@@ -15,6 +15,7 @@ import { registerAgentCommand } from './commands/agent.js';
import { registerInteractionCommand } from './commands/interaction.js';
import { registerConfigCommand } from './commands/config.js';
import { registerFleetCommand } from './commands/fleet.js';
import { registerPromoteCommand } from './commands/promote.js';
import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js';
import { registerRestoreCommand } from './commands/restore.js';
@@ -370,6 +371,7 @@ registerInteractionCommand(program);
// ─── fleet ─────────────────────────────────────────────────────────────
registerFleetCommand(program);
registerPromoteCommand(program);
// ─── config ────────────────────────────────────────────────────────────
@@ -131,14 +131,13 @@ async function exists(path: string): Promise<boolean> {
}
describe('projectRosterV2AgentGeneratedEnv', (): void => {
it('maps a roster-v2 agent to exactly the nine generated projection keys', (): void => {
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => {
const roster = parseRosterV2(rosterYaml, 'yaml');
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
expect(agent).toBeDefined();
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
expect(values).toEqual({
MOSAIC_AGENT_NAME: 'coder0',
MOSAIC_GIT_IDENTITY: 'coder0',
MOSAIC_AGENT_CLASS: 'code',
MOSAIC_AGENT_RUNTIME: 'pi',
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
@@ -422,7 +422,6 @@ describe('fleet roster parsing', () => {
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_GIT_IDENTITY=coder0',
// Reflects the roster's canonicalized compatibility class (A3a).
'MOSAIC_AGENT_CLASS=code',
'MOSAIC_AGENT_RUNTIME=codex',
@@ -3800,7 +3799,6 @@ describe('fleet add command', () => {
'utf8',
);
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
expect(envContent).toContain('MOSAIC_GIT_IDENTITY=coder0');
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
});
-1
View File
@@ -484,7 +484,6 @@ function generateAgentEnvValues(
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
return {
MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
+175 -8
View File
@@ -14,9 +14,11 @@ import {
readdirSync,
realpathSync,
rmSync,
appendFileSync,
} from 'node:fs';
import { createHash, randomBytes } from 'node:crypto';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { homedir, hostname } from 'node:os';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
import {
@@ -42,6 +44,163 @@ const RUNTIME_LABELS: Record<RuntimeName, string> = {
pi: 'Pi',
};
// ─── Harness home isolation ──────────────────────────────────────────────────
// Mosaic-launched runtimes read config from a dedicated home under the mosaic
// tree — never the operator's base install. A bare `claude` / `pi` therefore
// keeps its own config AND its own auth, and stays a working break-glass no
// matter what mosaic does to its own tree.
//
// These paths are manifest-UNKNOWN, which resolves to operator ownership
// (framework-manifest.txt rule 3, #791), so a keep-mode `mosaic update` can
// neither overwrite nor prune them. Overwrite-mode install still would.
//
// opencode has no dedicated config-dir variable and follows XDG, so isolating it
// sets XDG_CONFIG_HOME for that process tree. That is blunter than the other
// three: it also relocates XDG lookups for anything opencode spawns.
const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
claude: 'CLAUDE_CONFIG_DIR',
pi: 'PI_CODING_AGENT_DIR',
codex: 'CODEX_HOME',
opencode: 'XDG_CONFIG_HOME',
};
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
function harnessHome(runtime: RuntimeName): string {
return join(MOSAIC_HOME, `.${runtime}`);
}
/**
* Env overlay pointing a runtime at its mosaic-owned home. The directory is
* created on demand so a first launch does not fail on a missing path.
*/
function harnessEnv(runtime: RuntimeName): Record<string, string> {
const key = HARNESS_HOME_ENV[runtime];
if (!key) return {};
const home = harnessHome(runtime);
mkdirSync(home, { recursive: true });
return { [key]: home };
}
// ─── Launch record (immutable provenance) ────────────────────────────────────
// MANDATORY and MECHANICAL: every launch appends one record of what the agent
// actually launched with, written before exec. No model involvement, no opt-out.
//
// WHY LAUNCH-TIME AND NOT INSPECT-LATER: pi rewrites its own argv to a bare
// `pi`, so /proc/<pid>/cmdline DESTROYS the launch evidence. That has already
// produced a confident wrong diagnosis ("this agent bypassed the launcher"),
// disproved only by the parent process's argv and only because the parent had
// not yet exited. A record written before exec is the only place this survives.
//
// Lands in fleet/run/sessions/ — the #797 Runtime Session Ledger path, already
// operator-classified in framework-manifest.txt and already covered by
// test-upgrade-manifest-guard.sh, so an upgrade can neither overwrite nor prune
// it.
//
// CORRELATION is by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime()
// uses spawnSync, so the runtime is a CHILD with a different pid.
// launch-runtime.py appends the matching `lease.register` event.
//
// NEVER records a credential value: env is captured as PRESENT NAMES ONLY, and
// oversized argv values (the composed system prompt) become a digest + length.
const LAUNCH_LEDGER_DIR = join(MOSAIC_HOME, 'fleet', 'run', 'sessions');
const CLI_VERSION: string | null = (() => {
try {
// Resolved RELATIVELY: the package `exports` map does not expose
// package.json, so '@mosaicstack/mosaic/package.json' throws
// ERR_PACKAGE_PATH_NOT_EXPORTED. Same relative depth from src/ and dist/.
return (createRequire(import.meta.url)('../../package.json') as { version: string }).version;
} catch {
return null;
}
})();
interface NormativeFragmentDigest {
source_id: string;
sha256: string | null;
bytes: number | null;
missing?: boolean;
}
function sha256Of(value: string | Buffer): string {
return createHash('sha256').update(value).digest('hex');
}
/**
* Hash the normative sources injected into the agent. This is "what the agent
* IS" and it is the same fragment set the lease broker hashes for promotion,
* so an unexpected digest here is a mechanically detectable red flag rather than
* a matter of judgement.
*/
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
const candidates: Array<[string, string]> = [
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
['USER.md', join(MOSAIC_HOME, 'USER.md')],
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
];
return candidates.map(([sourceId, path]) => {
try {
const bytes = readFileSync(path);
return { source_id: sourceId, sha256: sha256Of(bytes), bytes: bytes.length };
} catch {
return { source_id: sourceId, sha256: null, bytes: null, missing: true };
}
});
}
/** argv with oversized values replaced by a digest, so the record stays small
* and never inlines injected content verbatim. */
function redactArgv(argv: string[]): string[] {
return argv.map((a) =>
typeof a === 'string' && a.length > 256
? `<redacted sha256:${sha256Of(a).slice(0, 16)} bytes:${a.length}>`
: a,
);
}
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
try {
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
// Correlation id for the lease.register half. Set into process.env so it
// propagates through every `...process.env` / `...baseEnv` spread below.
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
process.env['MOSAIC_LAUNCH_ID'] = launchId;
const record = {
seq: Date.now(),
kind: 'session.launch',
launch_id: launchId,
ts: new Date().toISOString(),
host: hostname(),
pid: process.pid,
runtime,
mode: yolo ? 'yolo' : 'normal',
cwd: process.cwd(),
cli_version: CLI_VERSION,
config_home: harnessHome(runtime),
config_home_isolated: true,
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
argv: redactArgv(cliArgs),
normative_fragments: normativeFragmentDigests(runtime),
// names only — values are never recorded
mosaic_env_present: Object.keys(process.env)
.filter((k) => k.startsWith('MOSAIC_'))
.sort(),
};
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
mode: 0o600,
});
} catch (err) {
// Never block a launch on bookkeeping — but never fail silently either.
console.error(
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// ─── Pre-flight checks ──────────────────────────────────────────────────────
function checkMosaicHome(): void {
@@ -105,11 +264,11 @@ interface SettingsAudit {
function auditClaudeSettings(): SettingsAudit {
const warnings: string[] = [];
const settingsPath = join(homedir(), '.claude', 'settings.json');
const settingsPath = join(harnessHome('claude'), 'settings.json');
const settings = readJson(settingsPath);
if (!settings) {
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
warnings.push(`${settingsPath} not found — hooks and plugins will be missing`);
return { warnings };
}
@@ -561,7 +720,9 @@ function skillRealPath(dir: string): string {
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
* skills dir and the project-local one relative to the launch cwd. */
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
// PI_CODING_AGENT_DIR replaces ~/.pi/agent (not ~/.pi), so skills live at
// <home>/skills — there is no extra 'agent' segment under the isolated home.
return [join(harnessHome('pi'), 'skills'), join(cwd, '.pi', 'skills')];
}
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
@@ -764,12 +925,13 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('claude', cliArgs, yolo);
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
break;
}
case 'codex': {
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md'));
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
@@ -777,14 +939,17 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execRuntime('codex', cliArgs);
recordLaunch('codex', cliArgs, yolo);
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
break;
}
case 'opencode': {
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
console.log(`[mosaic] Launching ${label}${modeStr}...`);
execRuntime('opencode', args);
recordLaunch('opencode', args, yolo);
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
break;
}
@@ -799,6 +964,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('pi', cliArgs, yolo);
execLeaseGatedRuntime('pi', cliArgs);
break;
}
@@ -835,6 +1001,7 @@ function execLeaseGatedRuntime(
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
{
...baseEnv,
...harnessEnv(runtime),
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
},
@@ -0,0 +1,290 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import type { FleetRoster } from './fleet.js';
import { TmuxPromotionTransport } from '../fleet/promotion-transport.js';
import {
promoteSeat,
registerPromoteCommand,
type PromotionBreadcrumbStore,
type PromotionResult,
type PromotionTransport,
} from './promote.js';
const attemptId = 'a'.repeat(64);
const target = {
bundle: 'local',
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
};
function transport(): PromotionTransport {
return {
resolve: vi.fn(async () => target),
sendPromotion: vi.fn(async () => {}),
};
}
describe('mosaic promote', () => {
it('accepts only a fresh result correlated to this attempt', async () => {
const promotionTransport = transport();
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
readResult: vi
.fn()
.mockResolvedValueOnce({
attempt_id: 'c'.repeat(64),
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
})
.mockResolvedValueOnce({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
}),
};
const result = await promoteSeat('claude-seat', {
clock: () => 1_000,
sleep: async () => {},
store,
timeoutMs: 1,
transport: promotionTransport,
});
expect(result).toEqual({
bundle: 'local',
expiresAtWallclock: 4_600,
reason: null,
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
status: 'VERIFIED',
});
expect(promotionTransport.sendPromotion).toHaveBeenCalledWith(target);
expect(store.readResult).toHaveBeenCalledTimes(2);
});
it('accepts a fresh result for this session when completion consumed the pending nonce', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
})),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result.status).toBe('VERIFIED');
});
it('returns UNVERIFIED within the command bound when a tmux runner wedges', async () => {
vi.useFakeTimers();
const roster: FleetRoster = {
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
defaults: { workingDirectory: '~/src' },
runtimes: {},
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
transport: 'tmux',
version: 1,
};
const promotionTransport = new TmuxPromotionTransport({
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner: async () => new Promise(() => {}),
});
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => null),
};
try {
let observedResult: PromotionResult | undefined;
void promoteSeat('claude-seat', {
store,
transport: promotionTransport,
}).then((result) => {
observedResult = result;
});
await vi.advanceTimersByTimeAsync(5_000);
expect(observedResult).toMatchObject({
reason: 'RESOLVE_FAILED: Promotion transport command timed out after 5000ms.',
seat: 'claude-seat',
status: 'UNVERIFIED',
});
} finally {
vi.useRealTimers();
}
});
it('prints VERIFIED with the resolved seat, session, bundle, and wall-clock expiry', async () => {
const promotionTransport = transport();
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: Number.MAX_SAFE_INTEGER,
verified: true,
})),
};
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
const program = new Command().exitOverride();
registerPromoteCommand(program, { store, transport: promotionTransport });
try {
await program.parseAsync(['node', 'mosaic', 'promote', 'claude-seat']);
expect(output).toHaveBeenCalledWith(
`VERIFIED seat=claude-seat session=${target.sessionId} bundle=local expiry=1970-01-01T01:16:40.000Z`,
);
expect(process.exitCode).not.toBe(1);
} finally {
output.mockRestore();
process.exitCode = undefined;
}
});
it('prints UNVERIFIED and exits 1 when delivery fails', async () => {
const promotionTransport: PromotionTransport = {
resolve: vi.fn(async () => target),
sendPromotion: vi.fn(async () => {
throw new Error('tmux unavailable');
}),
};
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => null),
};
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
const program = new Command().exitOverride();
registerPromoteCommand(program, { store, transport: promotionTransport });
try {
process.exitCode = undefined;
await program.parseAsync(['node', 'mosaic', 'promote', 'claude-seat']);
expect(output).toHaveBeenCalledWith(
`UNVERIFIED seat=claude-seat session=${target.sessionId} bundle=local expiry=none reason=DELIVERY_FAILED: tmux unavailable`,
);
expect(process.exitCode).toBe(1);
} finally {
output.mockRestore();
process.exitCode = undefined;
}
});
it('rejects a stale result even when its nonce matches', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_000,
verified: true,
})),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result).toEqual({
bundle: 'local',
expiresAtWallclock: null,
reason: 'PROMOTION_TIMEOUT',
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
status: 'UNVERIFIED',
});
});
it('does not accept a result for a pending attempt that existed before send', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => attemptId),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
})),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result.status).toBe('UNVERIFIED');
expect(store.readResult).not.toHaveBeenCalled();
});
it('returns UNVERIFIED after a bounded timeout without reading stdin', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => null),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result).toEqual({
bundle: 'local',
expiresAtWallclock: null,
reason: 'PROMOTION_TIMEOUT',
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
status: 'UNVERIFIED',
});
expect(promotionTransport.sendPromotion).toHaveBeenCalledOnce();
expect(store.readResult).toHaveBeenCalledTimes(2);
});
});
+319
View File
@@ -0,0 +1,319 @@
import { spawn } from 'node:child_process';
import { constants } from 'node:fs';
import { open } from 'node:fs/promises';
import { join } from 'node:path';
import type { Command } from 'commander';
import {
TmuxPromotionTransport,
type PromotionTarget,
type PromotionTransport,
} from '../fleet/promotion-transport.js';
export type { PromotionTransport } from '../fleet/promotion-transport.js';
import { resolveFleetPaths, type CommandRunner } from './fleet.js';
const ATTEMPT_ID_PATTERN = /^[a-f0-9]{64}$/;
const DEFAULT_POLL_INTERVAL_MS = 250;
const DEFAULT_TIMEOUT_MS = 30_000;
const SUBPROCESS_TIMEOUT_MS = 4_500;
const PENDING_DIRECTORY = 'mosaic-lease';
const RESULT_FILE = 'last-result.json';
export interface PromotionBreadcrumb {
attempt_id: string;
expires_at_wallclock: number | null;
reason: string | null;
session_id: string;
ts: number;
verified: boolean;
}
export interface PromotionBreadcrumbStore {
readAttemptId(sessionId: string): Promise<string | null>;
readResult(): Promise<PromotionBreadcrumb | null>;
}
export interface PromotionResult {
bundle: string;
expiresAtWallclock: number | null;
reason: string | null;
seat: string;
sessionId: string;
status: 'VERIFIED' | 'UNVERIFIED';
}
export interface PromoteSeatOptions {
clock?: () => number;
pollIntervalMs?: number;
sleep?: (milliseconds: number) => Promise<void>;
store: PromotionBreadcrumbStore;
timeoutMs?: number;
transport: PromotionTransport;
}
export interface PromoteCommandDeps {
mosaicHome?: string;
runner?: CommandRunner;
store?: PromotionBreadcrumbStore;
transport?: PromotionTransport;
}
/** Private, local result store shared with the in-seat completion hook. */
export class FilePromotionBreadcrumbStore implements PromotionBreadcrumbStore {
constructor(private readonly runtimeDirectory = defaultRuntimeDirectory()) {}
async readAttemptId(sessionId: string): Promise<string | null> {
if (!ATTEMPT_ID_PATTERN.test(sessionId)) return null;
const content = await readPrivateFile(
join(this.runtimeDirectory, PENDING_DIRECTORY, `pending-${sessionId}`),
);
const attemptId = content?.trim();
return attemptId !== undefined && ATTEMPT_ID_PATTERN.test(attemptId) ? attemptId : null;
}
async readResult(): Promise<PromotionBreadcrumb | null> {
const content = await readPrivateFile(
join(this.runtimeDirectory, PENDING_DIRECTORY, RESULT_FILE),
);
if (content === null) return null;
try {
return parseBreadcrumb(JSON.parse(content) as unknown);
} catch {
return null;
}
}
}
/** Drives one bounded, non-interactive in-seat promotion attempt. */
export async function promoteSeat(
seat: string,
options: PromoteSeatOptions,
): Promise<PromotionResult> {
const clock = options.clock ?? wallClockSeconds;
const sleep = options.sleep ?? defaultSleep;
const timeoutMs = normalizeTimeout(options.timeoutMs);
const pollIntervalMs = normalizePollInterval(options.pollIntervalMs);
let target: PromotionTarget;
try {
target = await options.transport.resolve(seat);
} catch (error: unknown) {
return unverifiedUnresolvedSeat(seat, `RESOLVE_FAILED: ${errorMessage(error)}`);
}
const previousAttemptId = await options.store.readAttemptId(target.sessionId);
const preSendTimestamp = clock();
try {
await options.transport.sendPromotion(target);
} catch (error: unknown) {
return unverified(target, `DELIVERY_FAILED: ${errorMessage(error)}`);
}
const deadline = preSendTimestamp + timeoutMs / 1_000;
let attemptId: string | null = null;
while (true) {
const currentAttemptId = await options.store.readAttemptId(target.sessionId);
if (currentAttemptId !== null && currentAttemptId !== previousAttemptId) {
attemptId = currentAttemptId;
}
if (attemptId !== null || previousAttemptId === null) {
// Completion can consume a first attempt's nonce before this poll observes it.
// In that branch, correlation degrades to session_id + fresh timestamp, which
// is acceptable for this 0600, same-UID local trust boundary.
const breadcrumb = await options.store.readResult();
if (
breadcrumb !== null &&
breadcrumb.session_id === target.sessionId &&
(attemptId === null || breadcrumb.attempt_id === attemptId) &&
breadcrumb.ts > preSendTimestamp
) {
return {
bundle: target.bundle,
expiresAtWallclock: breadcrumb.expires_at_wallclock,
reason: breadcrumb.reason,
seat: target.seat,
sessionId: target.sessionId,
status: breadcrumb.verified ? 'VERIFIED' : 'UNVERIFIED',
};
}
}
if (clock() >= deadline) return unverified(target, 'PROMOTION_TIMEOUT');
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - clock()) * 1_000));
}
}
export function registerPromoteCommand(program: Command, deps: PromoteCommandDeps = {}): void {
const mosaicHome = deps.mosaicHome ?? resolveFleetPaths().mosaicHome;
const transport =
deps.transport ?? new TmuxPromotionTransport({ mosaicHome, runner: deps.runner ?? runCommand });
const store = deps.store ?? new FilePromotionBreadcrumbStore();
program
.command('promote <seat>')
.description('Promote a Claude fleet seat and report the correlated lease result')
.option(
'--timeout <ms>',
`Bounded result wait in milliseconds (default: ${DEFAULT_TIMEOUT_MS})`,
)
.action(async (seat: string, opts: { timeout?: string }) => {
const result = await promoteSeat(seat, {
store,
timeoutMs: parseOptionTimeout(opts.timeout),
transport,
});
const expiry =
result.expiresAtWallclock === null
? 'none'
: new Date(result.expiresAtWallclock * 1_000).toISOString();
const reason = result.reason === null ? '' : ` reason=${result.reason}`;
console.log(
`${result.status} seat=${result.seat} session=${result.sessionId} bundle=${result.bundle} expiry=${expiry}${reason}`,
);
if (result.status === 'UNVERIFIED') process.exitCode = 1;
});
}
function defaultRuntimeDirectory(): string {
const configured = process.env['XDG_RUNTIME_DIR'];
if (configured) return configured;
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
return `/run/user/${uid}`;
}
async function readPrivateFile(path: string): Promise<string | null> {
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
} catch {
return null;
}
try {
const metadata = await handle.stat();
if (
!metadata.isFile() ||
metadata.uid !== (typeof process.getuid === 'function' ? process.getuid() : 0) ||
(metadata.mode & 0o077) !== 0
) {
return null;
}
return handle.readFile({ encoding: 'utf8' });
} catch {
return null;
} finally {
await handle.close();
}
}
function parseBreadcrumb(value: unknown): PromotionBreadcrumb | null {
if (!isRecord(value) || Object.keys(value).length !== 6) return null;
const { attempt_id, expires_at_wallclock, reason, session_id, ts, verified } = value;
if (
typeof attempt_id !== 'string' ||
!ATTEMPT_ID_PATTERN.test(attempt_id) ||
typeof verified !== 'boolean' ||
typeof session_id !== 'string' ||
!ATTEMPT_ID_PATTERN.test(session_id) ||
typeof ts !== 'number' ||
!Number.isFinite(ts) ||
(expires_at_wallclock !== null &&
(typeof expires_at_wallclock !== 'number' || !Number.isFinite(expires_at_wallclock))) ||
(reason !== null && typeof reason !== 'string')
) {
return null;
}
return { attempt_id, expires_at_wallclock, reason, session_id, ts, verified };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function normalizeTimeout(value: number | undefined): number {
return value !== undefined && Number.isFinite(value) ? Math.max(0, value) : DEFAULT_TIMEOUT_MS;
}
function normalizePollInterval(value: number | undefined): number {
return value !== undefined && Number.isFinite(value)
? Math.max(1, value)
: DEFAULT_POLL_INTERVAL_MS;
}
function parseOptionTimeout(value: string | undefined): number | undefined {
if (value === undefined) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function unverifiedUnresolvedSeat(seat: string, reason: string): PromotionResult {
return {
bundle: 'unresolved',
expiresAtWallclock: null,
reason,
seat,
sessionId: 'unresolved',
status: 'UNVERIFIED',
};
}
function unverified(target: PromotionTarget, reason: string): PromotionResult {
return {
bundle: target.bundle,
expiresAtWallclock: null,
reason,
seat: target.seat,
sessionId: target.sessionId,
status: 'UNVERIFIED',
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function wallClockSeconds(): number {
return Date.now() / 1_000;
}
function defaultSleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function runCommand(
command: string,
args: string[],
): Promise<{
exitCode: number;
stderr: string;
stdout: string;
}> {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
let settled = false;
const finish = (result: { exitCode: number; stderr: string; stdout: string }): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(result);
};
const timeout = setTimeout(() => {
child.kill('SIGKILL');
finish({
exitCode: 124,
stderr: `Promotion transport subprocess timed out after ${SUBPROCESS_TIMEOUT_MS}ms.`,
stdout,
});
}, SUBPROCESS_TIMEOUT_MS);
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (error: Error) => {
finish({ exitCode: 127, stderr: error.message, stdout });
});
child.on('close', (code: number | null) => {
finish({ exitCode: code ?? 1, stderr, stdout });
});
});
}
@@ -358,7 +358,6 @@ function generatedValues(
): Readonly<Record<string, string>> {
return {
MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model,
@@ -380,7 +380,7 @@ const COMMAND_RECORDS: Readonly<Record<string, RegExp>> = {
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
'DATA.DOTENV.FLEET_LAUNCH':
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
'DATA.TEXT_TABLE.FLEET_TASKS':
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
@@ -406,7 +406,7 @@ const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
'DATA.JSON.MUTATION_RESULT':
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
'DATA.DOTENV.GENERATED_ENV':
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
'DATA.YAML.ROSTER_FIELDS':
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
};
@@ -922,8 +922,8 @@ describe('fleet operator documentation', (): void => {
);
expect(
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
).toHaveLength(863);
expect(surfaces).toHaveLength(887);
).toHaveLength(858);
expect(surfaces).toHaveLength(882);
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
const auxiliary: CodeSurface = {
@@ -597,7 +597,6 @@ export function projectRosterV2AgentGeneratedEnv(
): Readonly<Record<string, string>> {
return {
MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model,
@@ -22,7 +22,6 @@ import {
const generatedValues = {
MOSAIC_AGENT_NAME: 'coder0',
MOSAIC_GIT_IDENTITY: 'coder0',
MOSAIC_AGENT_CLASS: 'code',
MOSAIC_AGENT_RUNTIME: 'pi',
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
@@ -46,7 +45,6 @@ describe('generated fleet agent environment boundary', (): void => {
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
[
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_GIT_IDENTITY=coder0',
'MOSAIC_AGENT_CLASS=code',
'MOSAIC_AGENT_RUNTIME=pi',
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
@@ -80,22 +78,6 @@ describe('generated fleet agent environment boundary', (): void => {
expect(String(error)).toMatch(/key=.*sha256=/);
});
it.each([
['unsafe-git-identity', 'other/identity'],
['git-identity-mismatch', 'reviewer0'],
])('rejects %s before any launch consumer can use it', (code: string, identity: string): void => {
expect((): void => {
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_GIT_IDENTITY: identity,
});
}).toThrow(
expect.objectContaining({
diagnostic: expect.objectContaining({ code, key: 'MOSAIC_GIT_IDENTITY' }),
}),
);
});
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
expect((): void => {
renderGeneratedAgentEnvironment({
@@ -73,7 +73,6 @@ export class AgentEnvBoundaryError extends Error {
export const GENERATED_AGENT_ENV_KEYS = [
'MOSAIC_AGENT_NAME',
'MOSAIC_GIT_IDENTITY',
'MOSAIC_AGENT_CLASS',
'MOSAIC_AGENT_RUNTIME',
'MOSAIC_AGENT_MODEL',
@@ -403,7 +402,6 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
}
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
const gitIdentity = requiredGeneratedValue(values, 'MOSAIC_GIT_IDENTITY');
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
@@ -414,12 +412,6 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
if (!AGENT_NAME.test(name))
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
if (!AGENT_NAME.test(gitIdentity)) {
throw new AgentEnvBoundaryError('unsafe-git-identity', 'MOSAIC_GIT_IDENTITY', gitIdentity);
}
if (gitIdentity !== name) {
throw new AgentEnvBoundaryError('git-identity-mismatch', 'MOSAIC_GIT_IDENTITY', gitIdentity);
}
if (!POLICY_NAME.test(className)) {
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
}
@@ -0,0 +1,104 @@
import { describe, expect, it, vi } from 'vitest';
import type { CommandResult, CommandRunner, FleetRoster } from '../commands/fleet.js';
import { TmuxPromotionTransport } from './promotion-transport.js';
const sessionId = 'a'.repeat(64);
const roster: FleetRoster = {
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
defaults: { workingDirectory: '~/src' },
runtimes: {},
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
transport: 'tmux',
version: 1,
};
function result(stdout = '', exitCode = 0, stderr = ''): CommandResult {
return { exitCode, stderr, stdout };
}
describe('TmuxPromotionTransport', () => {
it('resolves the exact roster seat and sends the registered command literally', async () => {
const runner = vi
.fn<CommandRunner>()
.mockResolvedValueOnce(result('1234 claude 0 0 0 0\n'))
.mockResolvedValueOnce(result())
.mockResolvedValueOnce(result());
const environmentReader = vi.fn(async () => `MOSAIC_LEASE_SESSION_ID=${sessionId}\0`);
const transport = new TmuxPromotionTransport({
environmentReader,
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner,
});
const target = await transport.resolve('claude-seat');
await transport.sendPromotion(target);
expect(target).toEqual({
bundle: 'mosaic-fleet',
seat: 'claude-seat',
sessionId,
});
expect(environmentReader).toHaveBeenCalledWith(1234);
expect(runner).toHaveBeenNthCalledWith(2, 'tmux', [
'-L',
'mosaic-fleet',
'send-keys',
'-t',
'=claude-seat:0.0',
'-l',
'/mosaic-promote',
]);
expect(runner).toHaveBeenNthCalledWith(3, 'tmux', [
'-L',
'mosaic-fleet',
'send-keys',
'-t',
'=claude-seat:0.0',
'Enter',
]);
});
// Regression for #1124: the launcher runs claude as a spawnSync CHILD of
// node(mosaic), so the lease env is on the child, not the tmux pane pid. The
// transport must WALK the subtree. This test exercises the real walk (no
// full mock of the resolution) — the seam the original unit test hid.
it('walks the pane subtree to the claude child that carries the lease id', async () => {
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
// pane pid 1234 = node(mosaic): NO lease env. child 5678 = claude: carries it.
const environmentReader = vi.fn(async (pid: number) =>
pid === 5678 ? `FOO=bar\0MOSAIC_LEASE_SESSION_ID=${sessionId}\0` : `FOO=bar\0`,
);
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
const transport = new TmuxPromotionTransport({
environmentReader,
childrenReader,
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner,
});
const target = await transport.resolve('claude-seat');
expect(target.sessionId).toBe(sessionId);
expect(environmentReader).toHaveBeenCalledWith(1234); // pane pid: no lease
expect(environmentReader).toHaveBeenCalledWith(5678); // walked to the child
expect(childrenReader).toHaveBeenCalledWith(1234); // walk actually ran
});
it('fails closed when no process in the pane subtree carries a lease id', async () => {
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
const environmentReader = vi.fn(async () => `FOO=bar\0`);
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
const transport = new TmuxPromotionTransport({
environmentReader,
childrenReader,
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner,
});
await expect(transport.resolve('claude-seat')).rejects.toThrow('no readable lease session');
expect(childrenReader).toHaveBeenCalledWith(1234);
});
});
@@ -0,0 +1,208 @@
import { readFile } from 'node:fs/promises';
import {
buildTmuxListPanesCommand,
getRosterAgent,
parseTmuxListPanes,
resolveFleetPaths,
type CommandResult,
type CommandRunner,
type FleetRoster,
RUNTIME_ACCEPTABLE_COMMANDS,
socketArgs,
} from '../commands/fleet.js';
import { loadFleetRoster } from './fleet-roster-v1.js';
const PROMOTION_COMMAND = '/mosaic-promote';
const SESSION_ID_PATTERN = /^[a-f0-9]{64}$/;
const TRANSPORT_COMMAND_TIMEOUT_MS = 5_000;
export interface PromotionTarget {
bundle: string;
seat: string;
sessionId: string;
}
export interface PromotionTransport {
resolve(seat: string): Promise<PromotionTarget>;
sendPromotion(target: PromotionTarget): Promise<void>;
}
export interface TmuxPromotionTransportOptions {
environmentReader?: (pid: number) => Promise<string>;
childrenReader?: (pid: number) => Promise<number[]>;
mosaicHome: string;
rosterLoader?: () => Promise<FleetRoster>;
runner: CommandRunner;
}
// The launcher runs the runtime as a spawnSync CHILD of node(mosaic) (see
// launch.ts:99 — deliberate, so the parent survives to propagate signals), so
// MOSAIC_LEASE_SESSION_ID lives on the claude child, NOT on the tmux pane's root
// pid. Bound the descendant search so a hung/large process tree can't stall it.
const MAX_SUBTREE_PIDS = 128;
/** Local, roster-bound transport for the in-seat promotion command. */
export class TmuxPromotionTransport implements PromotionTransport {
private readonly environmentReader: (pid: number) => Promise<string>;
private readonly childrenReader: (pid: number) => Promise<number[]>;
private readonly rosterLoader: () => Promise<FleetRoster>;
constructor(private readonly options: TmuxPromotionTransportOptions) {
this.environmentReader = options.environmentReader ?? readPaneEnvironment;
this.childrenReader = options.childrenReader ?? readChildPids;
this.rosterLoader =
options.rosterLoader ??
(() => loadFleetRoster(resolveFleetPaths(options.mosaicHome).rosterPath));
}
async resolve(seat: string): Promise<PromotionTarget> {
const roster = await this.rosterLoader();
const agent = getRosterAgent(roster, seat);
if (agent.runtime !== 'claude') {
throw new Error(`Lease promotion is currently available only for Claude seats: ${seat}.`);
}
const paneResult = await this.run(
buildTmuxListPanesCommand(agent.name, roster.tmux.socketName),
);
if (paneResult.exitCode !== 0) {
throw new Error(`Promotion seat is unavailable: ${seat}.`);
}
const pane = parseTmuxListPanes(paneResult.stdout);
const allowedCommands = RUNTIME_ACCEPTABLE_COMMANDS.claude;
if (
pane.dead ||
pane.pid === null ||
pane.command === null ||
allowedCommands === undefined ||
!allowedCommands.includes(pane.command)
) {
throw new Error(`Promotion seat runtime identity mismatch: ${seat}.`);
}
const sessionId = await this.resolveLeaseSessionId(pane.pid);
if (sessionId === null) {
throw new Error(`Promotion seat has no readable lease session: ${seat}.`);
}
return {
bundle: roster.tmux.socketName || 'default',
seat: agent.name,
sessionId,
};
}
/**
* Find the lease session id in the pane's process subtree. The pane's root pid
* is node(mosaic), which has no lease env; the id lives on the claude child.
* BFS from the root, bounded, returning the first descendant that carries a
* valid MOSAIC_LEASE_SESSION_ID. Fail-closed (null) if none is found.
*/
private async resolveLeaseSessionId(rootPid: number): Promise<string | null> {
const queue: number[] = [rootPid];
const seen = new Set<number>();
while (queue.length > 0 && seen.size < MAX_SUBTREE_PIDS) {
const pid = queue.shift()!;
if (seen.has(pid)) continue;
seen.add(pid);
let sessionId: string | null = null;
try {
sessionId = parseLeaseSessionId(await this.environmentReader(pid));
} catch {
sessionId = null;
}
if (sessionId !== null) return sessionId;
let children: number[] = [];
try {
children = await this.childrenReader(pid);
} catch {
children = [];
}
for (const child of children) {
if (!seen.has(child)) queue.push(child);
}
}
return null;
}
async sendPromotion(target: PromotionTarget): Promise<void> {
const targetPane = `=${target.seat}:0.0`;
const socketName = target.bundle === 'default' ? '' : target.bundle;
// Registered Claude commands must arrive as their exact literal text; the
// fleet agent sender prepends an identity envelope, so it cannot carry this
// command without preventing the UserPromptSubmit matcher from recognizing it.
await this.runPromotionCommand([
'tmux',
...socketArgs(socketName),
'send-keys',
'-t',
targetPane,
'-l',
PROMOTION_COMMAND,
]);
await this.runPromotionCommand([
'tmux',
...socketArgs(socketName),
'send-keys',
'-t',
targetPane,
'Enter',
]);
}
private async runPromotionCommand(command: string[]): Promise<void> {
const result = await this.run(command);
if (result.exitCode !== 0) {
throw new Error('Promotion command delivery failed.');
}
}
private async run(command: string[]): Promise<CommandResult> {
const [executable, ...args] = command;
if (executable === undefined) {
throw new Error('Promotion transport command is empty.');
}
return await withTimeout(this.options.runner(executable, args), TRANSPORT_COMMAND_TIMEOUT_MS);
}
}
function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Promotion transport command timed out after ${timeoutMs}ms.`));
}, timeoutMs);
void operation.then(
(value) => {
clearTimeout(timeout);
resolve(value);
},
(error: unknown) => {
clearTimeout(timeout);
reject(error);
},
);
});
}
async function readPaneEnvironment(pid: number): Promise<string> {
return readFile(`/proc/${pid}/environ`, 'utf8');
}
async function readChildPids(pid: number): Promise<number[]> {
// Linux exposes direct children of the main thread here (CONFIG_PROC_CHILDREN).
try {
const raw = await readFile(`/proc/${pid}/task/${pid}/children`, 'utf8');
return raw
.split(/\s+/)
.filter(Boolean)
.map((value) => Number.parseInt(value, 10))
.filter((value) => Number.isInteger(value) && value > 0);
} catch {
return [];
}
}
function parseLeaseSessionId(environment: string): string | null {
const value = environment
.split('\0')
.find((entry) => entry.startsWith('MOSAIC_LEASE_SESSION_ID='))
?.slice('MOSAIC_LEASE_SESSION_ID='.length);
return value !== undefined && SESSION_ID_PATTERN.test(value) ? value : null;
}
@@ -1405,7 +1405,6 @@ function generatedValues(
): Readonly<Record<string, string>> {
return {
MOSAIC_AGENT_NAME: agent.name,
MOSAIC_GIT_IDENTITY: agent.name,
MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model,
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""Invariant R: a read-only carve-out can neither disappear nor be shadowed.
The broker's carve-out is an authentication bypass for UNVERIFIED runtimes, so
this test imports the live ``READ_ONLY_TOOLS`` object instead of copying it.
Claude MCP names are namespaced, making an exact proven allow-list sufficient.
Pi extensions are unnamespaced and may override built-ins, so the Pi half boots
the installed runtime and requires every carve-out winner to retain built-in
provenance.
"""
from __future__ import annotations
import importlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
from typing import Final
PACKAGE_ROOT = Path(__file__).parents[2]
FRAMEWORK = PACKAGE_ROOT / "framework"
LEASE_BROKER = FRAMEWORK / "tools/lease-broker"
PI_EXTENSION = FRAMEWORK / "runtime/pi/mosaic-extension.ts"
sys.path.insert(0, str(LEASE_BROKER))
daemon = importlib.import_module("daemon")
READ_ONLY_TOOLS = daemon.READ_ONLY_TOOLS
# Claude Code's measured, bare built-ins that are both registered and incapable
# of filesystem mutation or subprocess execution. MCP tools are namespaced as
# mcp__<server>__<tool>, so they cannot replace these bare identities.
CLAUDE_PROVEN_READ_ONLY_TOOLS: Final = frozenset({"Read", "Grep", "Glob"})
# W-B measured Pi 0.84.1 through getAllTools(), observed every tool_call name,
# and cross-checked dist/core/tools/index.js:18. Keep every measured built-in
# here so a runtime registry change forces the security classification to be
# revisited even when a built-in is deliberately excluded from the carve-out.
PI_VERSION: Final = "0.84.1"
PI_PROBE_ATTEMPTS: Final = 3
PI_PROBE_TIMEOUT_SECONDS: Final = 45
PI_PROBE_BACKOFF_SECONDS: Final = 0.25
PI_PROVEN_READ_ONLY_TOOLS: Final = frozenset({"read", "ls"})
PI_SUBPROCESS_TOOLS: Final = frozenset({"grep", "find"})
PI_MUTATING_TOOLS: Final = frozenset({"bash", "edit", "write"})
PI_MEASURED_BUILTINS: Final = (
PI_PROVEN_READ_ONLY_TOOLS | PI_SUBPROCESS_TOOLS | PI_MUTATING_TOOLS
)
# Pi 0.84.1 built-ins individually proven incapable of subprocess execution or
# filesystem writes on their default path:
# - read: dist/core/tools/read.js:26-29 dispatches only read/access operations.
# - ls: dist/core/tools/ls.js:19-22 dispatches only exists/stat/readdir operations.
# grep and find are deliberately absent: grep.js:99/148 and find.js:161/203
# reach ensureTool(..., true) and spawn(), including the cold-cache download,
# write, chmod, and exec path in dist/utils/tools-manager.js:285-313.
PI_CAPABILITY_SAFE_TOOLS: Final = frozenset({"read", "ls"})
# Falsifier-only inputs. They are intentionally undocumented outside this test:
# normal CI leaves them unset; the W-A evidence run uses them to prove that the
# suite turns red for a nonexistent Claude carve-out or a Pi built-in override.
CLAUDE_EXTRA_TOOL_ENV: Final = "MOSAIC_INVARIANT_R_CLAUDE_EXTRA_TOOL"
PI_EXTRA_EXTENSION_ENV: Final = "MOSAIC_INVARIANT_R_PI_EXTRA_EXTENSION"
def run_pi_registry_command(
command: list[str],
environ: dict[str, str],
*,
runner=subprocess.run,
sleeper=time.sleep,
) -> subprocess.CompletedProcess[str]:
"""Run the registry probe with bounded retries for concurrent-Pi stalls."""
for attempt in range(1, PI_PROBE_ATTEMPTS + 1):
try:
return runner(
command,
check=False,
capture_output=True,
text=True,
env=environ,
timeout=PI_PROBE_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired as error:
if attempt == PI_PROBE_ATTEMPTS:
raise AssertionError(
"Pi registry probe could not complete after "
f"{PI_PROBE_ATTEMPTS} attempts (concurrent pi?); this is a "
"probe/infra failure, NOT an Invariant R violation"
) from error
sleeper(PI_PROBE_BACKOFF_SECONDS * attempt)
raise AssertionError("unreachable Pi registry retry state")
def probe_pi_registry() -> list[dict[str, object]]:
"""Boot Pi's real registry and return the final winning tool definitions."""
pi = shutil.which("pi")
if pi is None:
raise AssertionError("installed Pi runtime is required for Invariant R")
version = subprocess.run(
[pi, "--version"],
check=False,
capture_output=True,
text=True,
timeout=10,
)
if version.returncode != 0:
raise AssertionError(f"Pi version probe failed: {version.stderr.strip()}")
if version.stdout.strip() != PI_VERSION:
raise AssertionError(
f"Pi runtime changed from measured {PI_VERSION} to {version.stdout.strip()!r}; "
"remeasure its registry before updating Invariant R"
)
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
output = root / "registry.json"
observer = root / "registry-observer.ts"
observer.write_text(
"import { writeFileSync } from 'node:fs';\n"
"export default function register(pi: any) {\n"
" pi.on('session_start', () => {\n"
f" writeFileSync({json.dumps(str(output))}, JSON.stringify(pi.getAllTools()));\n"
" process.exit(0);\n"
" });\n"
"}\n",
encoding="utf-8",
)
command = [
pi,
"--mode",
"text",
"--no-session",
"--no-approve",
"--no-context-files",
"--no-skills",
"--no-prompt-templates",
"--no-extensions",
"-e",
str(observer),
"-e",
str(PI_EXTENSION),
]
extra_extension = os.environ.get(PI_EXTRA_EXTENSION_ENV)
if extra_extension:
command.extend(("-e", extra_extension))
command.append("Invariant R registry probe")
completed = run_pi_registry_command(
command,
{**os.environ, "PI_OFFLINE": "1"},
)
if completed.returncode != 0 or not output.is_file():
raise AssertionError(
"Pi registry probe failed "
f"(status {completed.returncode}): {completed.stderr.strip()}"
)
value = json.loads(output.read_text(encoding="utf-8"))
if not isinstance(value, list) or not value:
raise AssertionError("Pi registry probe returned no tools; control failed")
return value
class InvariantRTest(unittest.TestCase):
def test_live_carve_out_has_only_supported_runtimes(self) -> None:
self.assertEqual(set(READ_ONLY_TOOLS), {"claude", "pi"})
def test_claude_carve_out_is_registered_and_proven(self) -> None:
carve_out = set(READ_ONLY_TOOLS["claude"])
falsifier = os.environ.get(CLAUDE_EXTRA_TOOL_ENV)
if falsifier:
carve_out.add(falsifier)
self.assertEqual(
carve_out,
set(CLAUDE_PROVEN_READ_ONLY_TOOLS),
"every Claude carve-out must exist and be in the exact proven read-only allow-list",
)
def test_pi_carve_out_has_no_exec_or_write_capability(self) -> None:
carve_out = set(READ_ONLY_TOOLS["pi"])
capability_unsafe = carve_out - set(PI_CAPABILITY_SAFE_TOOLS)
self.assertFalse(
capability_unsafe,
f"capability-unsafe Pi carve-out tools: {sorted(capability_unsafe)!r}; "
"Pi 0.84.1 grep.js:99/148 and find.js:161/203 reach "
"ensureTool(..., true) and spawn(), whose cold-cache path downloads, "
"writes, chmods, and execs",
)
def test_pi_carve_out_resolves_to_real_unshadowed_builtins(self) -> None:
carve_out = set(READ_ONLY_TOOLS["pi"])
self.assertEqual(
carve_out,
set(PI_PROVEN_READ_ONLY_TOOLS),
"Pi carve-out drift requires a new runtime measurement and classification",
)
self.assertTrue(carve_out.isdisjoint(PI_MUTATING_TOOLS))
registry = probe_pi_registry()
by_name: dict[str, dict[str, object]] = {}
for entry in registry:
name = entry.get("name")
if not isinstance(name, str):
self.fail(f"Pi registry entry has no string name: {entry!r}")
by_name[name] = entry
builtin_names = {
name
for name, entry in by_name.items()
if isinstance(entry.get("sourceInfo"), dict)
and entry["sourceInfo"].get("source") == "builtin"
}
self.assertEqual(
builtin_names,
set(PI_MEASURED_BUILTINS),
"Pi's real built-in registry drifted from the positive-control W-B measurement",
)
for name in sorted(carve_out):
with self.subTest(tool=name):
self.assertIn(name, by_name, "Pi carve-out names must exist in the real registry")
source = by_name[name].get("sourceInfo")
self.assertIsInstance(source, dict)
if isinstance(source, dict):
self.assertEqual(
source.get("source"),
"builtin",
f"Pi extension or SDK tool shadowed read-only carve-out {name!r}",
)
self.assertEqual(source.get("path"), f"<builtin:{name}>")
def test_pi_probe_retries_timeouts_before_succeeding(self) -> None:
attempts: list[float] = []
backoffs: list[float] = []
def timeout_twice(command, **kwargs):
attempts.append(kwargs["timeout"])
if len(attempts) < 3:
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
return subprocess.CompletedProcess(command, 0, "", "")
completed = run_pi_registry_command(
["pi", "probe"],
{},
runner=timeout_twice,
sleeper=backoffs.append,
)
self.assertEqual(completed.returncode, 0)
self.assertEqual(attempts, [45, 45, 45])
self.assertEqual(backoffs, [0.25, 0.5])
def test_pi_probe_labels_exhausted_timeouts_as_infrastructure_failure(self) -> None:
attempts = 0
def always_timeout(command, **kwargs):
nonlocal attempts
attempts += 1
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
with self.assertRaisesRegex(
AssertionError,
"Pi registry probe could not complete .* NOT an Invariant R violation",
) as caught:
run_pi_registry_command(
["pi", "probe"],
{},
runner=always_timeout,
sleeper=lambda _delay: None,
)
self.assertEqual(attempts, 3)
self.assertIsInstance(caught.exception.__cause__, subprocess.TimeoutExpired)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""The promotion client must never build a binding narrower than it claims.
RED-first against a real defect: ``build_construction`` skipped any normative
source it could not read (``except OSError: continue``) and promoted whatever
remained. That is not a degraded binding, it is a forged smaller one the
broker recomputes ``h_source`` / ``h_payload`` from the fragments it is *sent*
(``daemon.py:602-616``), so an omitted fragment is internally consistent and
``PAYLOAD_BINDING_MISMATCH`` cannot fire. Measured before the fix: with only
``USER.md`` readable (964 bytes on the live host), the client produced a
one-fragment construction with ``promotion=True``.
The classification under test mirrors the framework's own file ownership, and
must keep mirroring it:
* framework-owned, reconciled every upgrade (``install.sh`` FRAMEWORK_OWNED /
``config/file-adapter.ts`` FRAMEWORK_OWNED_FILES) plus the per-runtime
contract absence is a broken deployment, so it is REFUSED;
* ``SOUL.md`` / ``USER.md`` install.sh deliberately does not seed them
("generated by `mosaic init`"), so absence is legitimate and ALLOWED.
Unreadable is treated separately from absent for *every* source, optional ones
included: a file that will not open is not a file that was never configured, and
collapsing the two is what let a permission change quietly shrink the law.
"""
from __future__ import annotations
import contextlib
import io
import os
import sys
import tempfile
import unittest
from pathlib import Path
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
sys.path.insert(0, str(TOOLS))
import lease_promote # noqa: E402
RUNTIME = "pi"
RUNTIME_CONTRACT = f"runtime/{RUNTIME}/RUNTIME.md"
ALL_SOURCES = (*lease_promote.FRAGMENT_SOURCES, RUNTIME_CONTRACT)
REQUIRED = frozenset(lease_promote.REQUIRED_SOURCES) | {RUNTIME_CONTRACT}
# Derived, never listed: a hand-kept second copy is exactly the drift this file
# exists to catch.
OPTIONAL = tuple(s for s in ALL_SOURCES if s not in REQUIRED)
# chmod 0o000 does not deny root (CAP_DAC_OVERRIDE), so the unreadable
# simulations would fail spuriously in a root container.
runs_unprivileged = unittest.skipIf(
os.geteuid() == 0, "chmod 0o000 cannot make a file unreadable to root"
)
class PromotionBindingTest(unittest.TestCase):
def setUp(self) -> None:
self._previous_home = os.environ.get("MOSAIC_HOME")
self._temporary = tempfile.TemporaryDirectory()
self.root = Path(self._temporary.name)
for source_id in ALL_SOURCES:
path = self.root / source_id
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(f"# {source_id}\nnormative bytes\n".encode())
os.environ["MOSAIC_HOME"] = str(self.root)
def tearDown(self) -> None:
for path in self.root.rglob("*"):
if path.is_file():
path.chmod(0o644)
self._temporary.cleanup()
if self._previous_home is None:
os.environ.pop("MOSAIC_HOME", None)
else:
os.environ["MOSAIC_HOME"] = self._previous_home
def reset_home(self) -> None:
"""Discard the current home and seed a fresh complete one.
Each subTest mutates the tree destructively, so it needs a clean start
and the old one must be released, not orphaned.
"""
self.tearDown()
self.setUp()
def build(self):
return lease_promote.build_construction(RUNTIME)
def source_ids(self) -> list[str]:
construction, _ = self.build()
return [f["source_id"] for f in construction["fragments"]]
# --- the binding is complete when the deployment is complete -------------
def test_complete_deployment_binds_every_source(self) -> None:
construction, result = self.build()
self.assertEqual([f["source_id"] for f in construction["fragments"]], list(ALL_SOURCES))
self.assertTrue(result.promotion)
# --- absence: refused for framework-owned, allowed for operator-owned ----
def test_absent_required_source_is_refused(self) -> None:
for source_id in sorted(REQUIRED):
with self.subTest(source=source_id):
self.reset_home()
(self.root / source_id).unlink()
with self.assertRaises(lease_promote.IncompleteBinding) as caught:
self.build()
self.assertIn(source_id, str(caught.exception))
def test_absent_operator_source_still_binds_the_rest(self) -> None:
for source_id in OPTIONAL:
with self.subTest(source=source_id):
self.reset_home()
(self.root / source_id).unlink()
notice = io.StringIO()
with contextlib.redirect_stderr(notice):
bound = self.source_ids()
self.assertNotIn(source_id, bound)
for required in lease_promote.REQUIRED_SOURCES:
self.assertIn(required, bound)
# A silent omission is the original defect in miniature: the
# narrower binding must announce itself.
self.assertIn(source_id, notice.getvalue())
# --- unreadable is never the same as absent -----------------------------
@runs_unprivileged
def test_unreadable_source_is_refused_even_when_optional(self) -> None:
for source_id in ALL_SOURCES:
with self.subTest(source=source_id):
self.reset_home()
(self.root / source_id).chmod(0o000)
with self.assertRaises(lease_promote.IncompleteBinding) as caught:
self.build()
self.assertIn(source_id, str(caught.exception))
# --- the exact measured regression --------------------------------------
@runs_unprivileged
def test_single_readable_source_cannot_promote(self) -> None:
"""The observed failure: only USER.md readable produced a valid binding."""
for source_id in ALL_SOURCES:
if source_id != "USER.md":
(self.root / source_id).chmod(0o000)
with self.assertRaises(lease_promote.IncompleteBinding):
self.build()
@runs_unprivileged
def test_no_source_readable_cannot_promote(self) -> None:
for source_id in ALL_SOURCES:
(self.root / source_id).chmod(0o000)
with self.assertRaises(lease_promote.IncompleteBinding):
self.build()
# --- the classification must not drift from the framework's -------------
def test_required_set_excludes_only_the_unseeded_sources(self) -> None:
"""`install.sh` decides which files exist; this list must follow it.
If a source moves between framework-owned and operator-generated
upstream, this fails and forces the classification to be re-read rather
than silently inherited.
"""
self.assertEqual(
set(lease_promote.REQUIRED_SOURCES),
{"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"},
"REQUIRED_SOURCES changed — re-read install.sh FRAMEWORK_OWNED and "
"config/file-adapter.ts FRAMEWORK_OWNED_FILES before accepting it",
)
self.assertTrue(
set(lease_promote.REQUIRED_SOURCES) <= set(lease_promote.FRAGMENT_SOURCES),
"a required source is not in the binding order",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,609 @@
#!/usr/bin/env python3
"""RED-first contracts for the operator-triggered Claude promotion hooks."""
from __future__ import annotations
import importlib.util
import io
import json
import os
import stat
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock
PACKAGE_ROOT = Path(__file__).parents[2]
FRAMEWORK = PACKAGE_ROOT / "framework"
TOOLS = FRAMEWORK / "tools/lease-broker"
BEGIN_PATH = TOOLS / "promote-begin.py"
COMPLETE_PATH = TOOLS / "promote-complete.py"
OBSERVER_CLIENT_PATH = TOOLS / "receipt-observer-client.py"
RECEIPT_CHALLENGE_PATH = TOOLS / "receipt_challenge.py"
CLAUDE_SETTINGS = FRAMEWORK / "runtime/claude/settings.json"
CLAUDE_COMMAND = FRAMEWORK / "runtime/claude/commands/mosaic-promote.md"
SESSION_ID = "a" * 64
CHALLENGE = "b" * 64
H_PAYLOAD = "c" * 64
RECEIPT = (
f"MOSAIC-RECEIPT{{challenge={CHALLENGE}; H_payload={H_PAYLOAD}; gen=1; cep=0}}"
)
NOW = 10_000.0
def load_module(name: str, path: Path):
if not path.is_file():
raise AssertionError(f"shipped module is missing: {path}")
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"unable to load {name}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class PromotionHookFixture(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.begin = load_module("promotion_begin_test", BEGIN_PATH)
cls.complete = load_module("promotion_complete_test", COMPLETE_PATH)
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.runtime_dir = Path(self.temporary.name)
self.environment = {
"XDG_RUNTIME_DIR": str(self.runtime_dir),
"MOSAIC_LEASE_SESSION_ID": SESSION_ID,
}
self.pending_dir = self.runtime_dir / "mosaic-lease"
self.pending_file = self.pending_dir / f"pending-{SESSION_ID}"
def tearDown(self) -> None:
self.temporary.cleanup()
@staticmethod
def completed(payload: dict[str, object], returncode: int = 0, stderr: str = ""):
return subprocess.CompletedProcess(
["lease_promote.py"],
returncode,
json.dumps(payload),
stderr,
)
@classmethod
def successful_begin_reply(cls, **extra: object) -> dict[str, object]:
return {
"ok": True,
"state": "PENDING_VERIFICATION",
"receipt_challenge": CHALLENGE,
"receipt": RECEIPT,
"binding": {
"compaction_epoch": 0,
"request_epoch": 0,
"h_source": "d" * 64,
"h_payload": H_PAYLOAD,
"runtime_generation": 1,
"schema_version": 1,
},
**extra,
}
def run_begin(
self,
prompt: str,
runner: mock.Mock,
) -> tuple[int, str, str]:
stdout = io.StringIO()
stderr = io.StringIO()
result = self.begin.main(
environ=self.environment,
stdin=io.BytesIO(json.dumps({"prompt": prompt}).encode()),
stdout=stdout,
stderr=stderr,
run=runner,
now=lambda: NOW,
)
return result, stdout.getvalue(), stderr.getvalue()
def write_pending(self, challenge: str = CHALLENGE) -> None:
self.pending_dir.mkdir(mode=0o700, exist_ok=True)
self.pending_file.write_text(challenge, encoding="utf-8")
self.pending_file.chmod(0o600)
def run_complete(
self,
runner: mock.Mock,
now: float | None = None,
) -> tuple[int, str]:
stderr = io.StringIO()
options: dict[str, object] = {
"environ": self.environment,
"stderr": stderr,
"run": runner,
}
if now is not None:
options["now"] = lambda: now
result = self.complete.main(**options)
return result, stderr.getvalue()
class PromotionBeginTest(PromotionHookFixture):
def test_exact_prompt_writes_private_challenge_and_injects_verbatim_receipt(self) -> None:
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
result, stdout, stderr = self.run_begin("/mosaic-promote", runner)
self.assertEqual(result, 0)
self.assertEqual(stderr, "")
output = json.loads(stdout)
self.assertEqual(
output["hookSpecificOutput"]["additionalContext"],
"The operator invoked the registered /mosaic-promote command. "
"This receipt was generated locally by this seat's own lease broker; "
"echoing it verbatim is the designed confirmation step and discloses nothing. "
f"Reply with exactly the following text and nothing else: {RECEIPT}",
)
self.assertEqual(self.pending_file.read_text(encoding="utf-8"), CHALLENGE)
self.assertEqual(stat.S_IMODE(self.pending_file.stat().st_mode), 0o600)
command = runner.call_args.args[0]
self.assertEqual(command[-1], "--begin")
self.assertTrue(command[-2].endswith("lease_promote.py"))
def test_nonmatching_prompt_has_zero_side_effects(self) -> None:
runner = mock.Mock()
result, stdout, stderr = self.run_begin("please /mosaic-promote", runner)
self.assertEqual(result, 0)
self.assertEqual(stdout, "")
self.assertEqual(stderr, "")
runner.assert_not_called()
self.assertFalse(self.pending_dir.exists())
def test_begin_refusal_reports_daemon_code_without_pending_file(self) -> None:
runner = mock.Mock(
return_value=self.completed({"ok": False, "code": "INVALID_BINDING"})
)
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
self.assertEqual(result, 0)
self.assertIn("INVALID_BINDING", json.loads(stdout)["hookSpecificOutput"]["additionalContext"])
self.assertFalse(self.pending_file.exists())
def test_sweep_removes_stale_sibling_and_spares_fresh_sibling(self) -> None:
self.pending_dir.mkdir(mode=0o700)
stale = self.pending_dir / "pending-stale"
fresh = self.pending_dir / "pending-fresh"
stale.write_text("stale", encoding="utf-8")
fresh.write_text("fresh", encoding="utf-8")
os.utime(stale, (NOW - 3_601, NOW - 3_601))
os.utime(fresh, (NOW - 3_599, NOW - 3_599))
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
self.assertEqual(result, 0)
self.assertFalse(stale.exists())
self.assertTrue(fresh.exists())
def test_sweep_removes_stale_atomic_temporary_file(self) -> None:
self.pending_dir.mkdir(mode=0o700)
stale_temporary = self.pending_dir / f".pending-{SESSION_ID}.tmp-abandoned"
stale_temporary.write_text("partial", encoding="utf-8")
os.utime(stale_temporary, (NOW - 3_601, NOW - 3_601))
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
self.assertEqual(result, 0)
self.assertFalse(stale_temporary.exists())
def test_insecure_pending_directory_mode_refuses_before_begin(self) -> None:
self.pending_dir.mkdir(mode=0o755)
self.pending_dir.chmod(0o755)
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
self.assertEqual(result, 0)
runner.assert_not_called()
self.assertFalse(self.pending_file.exists())
self.assertIn("PROMOTION_TRIGGER_FAILED", stdout)
def test_insecure_runtime_directory_mode_refuses_before_begin(self) -> None:
self.runtime_dir.chmod(0o755)
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
self.assertEqual(result, 0)
runner.assert_not_called()
self.assertIn("PROMOTION_TRIGGER_FAILED", stdout)
def test_parent_symlink_cannot_redirect_pending_write(self) -> None:
outside = self.runtime_dir / "outside"
outside.mkdir(mode=0o700)
self.pending_dir.symlink_to(outside, target_is_directory=True)
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
self.assertEqual(result, 0)
runner.assert_not_called()
self.assertFalse((outside / f"pending-{SESSION_ID}").exists())
def test_concurrent_begin_is_refused_without_minting_a_second_challenge(self) -> None:
inner_runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
inner_result: list[tuple[int, str, str]] = []
def overlap(*_args: object, **_kwargs: object):
inner_result.append(self.run_begin("/mosaic-promote", inner_runner))
return self.completed(self.successful_begin_reply())
outer_runner = mock.Mock(side_effect=overlap)
result, _stdout, _stderr = self.run_begin("/mosaic-promote", outer_runner)
self.assertEqual(result, 0)
inner_runner.assert_not_called()
self.assertEqual(inner_result[0][0], 0)
self.assertIn("PROMOTION_ALREADY_IN_PROGRESS", inner_result[0][1])
def test_non_ascii_receipt_reply_is_rejected_without_crashing_hook(self) -> None:
reply = self.successful_begin_reply()
reply["receipt"] = "MOSAIC—RECEIPT"
runner = mock.Mock(return_value=self.completed(reply))
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
self.assertEqual(result, 0)
self.assertFalse(self.pending_file.exists())
self.assertIn("INVALID_PROMOTER_REPLY", stdout)
def test_success_shaped_reply_with_extra_fields_is_rejected(self) -> None:
runner = mock.Mock(
return_value=self.completed(self.successful_begin_reply(unexpected=True))
)
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
self.assertEqual(result, 0)
self.assertFalse(self.pending_file.exists())
self.assertIn("INVALID_PROMOTER_REPLY", stdout)
class PromotionCompleteTest(PromotionHookFixture):
def test_no_pending_file_is_zero_cost_success(self) -> None:
runner = mock.Mock()
result, stderr = self.run_complete(runner)
self.assertEqual(result, 0)
self.assertEqual(stderr, "")
runner.assert_not_called()
def test_success_deletes_pending_file(self) -> None:
self.write_pending()
runner = mock.Mock(
return_value=self.completed(
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
)
)
result, _stderr = self.run_complete(runner)
self.assertEqual(result, 0)
self.assertFalse(self.pending_file.exists())
self.assertEqual(runner.call_args.args[0][-2:], ["--complete", CHALLENGE])
def test_success_atomically_writes_a_private_correlated_result_with_wall_clock_expiry(self) -> None:
self.write_pending()
runner = mock.Mock(
return_value=self.completed(
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
)
)
with mock.patch.object(self.complete.os, "replace", wraps=os.replace) as replace:
result, _stderr = self.run_complete(runner, now=12_345.0)
result_file = self.pending_dir / "last-result.json"
self.assertEqual(result, 0)
self.assertEqual(stat.S_IMODE(result_file.stat().st_mode), 0o600)
self.assertEqual(
json.loads(result_file.read_text(encoding="utf-8")),
{
"attempt_id": CHALLENGE,
"expires_at_wallclock": 15_945.0,
"reason": None,
"session_id": SESSION_ID,
"ts": 12_345.0,
"verified": True,
},
)
temporary, destination = replace.call_args.args
self.assertRegex(temporary, r"^\.last-result\.json\.tmp-[0-9a-f]+$")
self.assertEqual(destination, "last-result.json")
self.assertFalse(any(path.name.startswith(".last-result.json.tmp-") for path in self.pending_dir.iterdir()))
def test_terminal_failure_writes_a_private_correlated_unverified_result(self) -> None:
self.write_pending()
runner = mock.Mock(
return_value=self.completed(
{"stage": "observe_receipt", "ok": False, "code": "RECEIPT_MISMATCH"}
)
)
result, _stderr = self.run_complete(runner, now=12_345.0)
result_file = self.pending_dir / "last-result.json"
self.assertEqual(result, 0)
self.assertFalse(self.pending_file.exists())
self.assertEqual(stat.S_IMODE(result_file.stat().st_mode), 0o600)
self.assertEqual(
json.loads(result_file.read_text(encoding="utf-8")),
{
"attempt_id": CHALLENGE,
"expires_at_wallclock": None,
"reason": "RECEIPT_MISMATCH",
"session_id": SESSION_ID,
"ts": 12_345.0,
"verified": False,
},
)
def test_each_terminal_failure_deletes_pending_file(self) -> None:
terminal_codes = (
"RECEIPT_REPLAY",
"RECEIPT_MISMATCH",
"INVALID_LEASE_TRANSITION",
"PROMOTION_TOKEN_INVALID",
)
for code in terminal_codes:
with self.subTest(code=code):
self.write_pending()
runner = mock.Mock(
return_value=self.completed(
{"stage": "observe_receipt", "ok": False, "code": code}
)
)
result, stderr = self.run_complete(runner)
self.assertEqual(result, 0)
self.assertFalse(self.pending_file.exists())
self.assertIn(code, stderr)
def test_transient_and_unknown_failures_preserve_pending_file(self) -> None:
transient_codes = (
"RECEIPT_OBSERVATION_UNAVAILABLE",
"BROKER_BUSY",
"ANCESTRY_MISMATCH",
)
for code in transient_codes:
with self.subTest(code=code):
self.write_pending()
runner = mock.Mock(
return_value=self.completed(
{"stage": "observe_receipt", "ok": False, "code": code}
)
)
result, stderr = self.run_complete(runner)
self.assertEqual(result, 0)
self.assertTrue(self.pending_file.exists())
self.assertIn(code, stderr)
def test_transport_failure_preserves_pending_file_and_exits_zero(self) -> None:
self.write_pending()
runner = mock.Mock(
return_value=self.completed({}, returncode=2, stderr="ConnectionRefusedError")
)
result, stderr = self.run_complete(runner)
self.assertEqual(result, 0)
self.assertTrue(self.pending_file.exists())
self.assertIn("ConnectionRefusedError", stderr)
def test_result_write_failure_exits_zero(self) -> None:
self.write_pending()
runner = mock.Mock(
return_value=self.completed(
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
)
)
with mock.patch.object(self.complete, "write_result", side_effect=OSError("disk full")):
result, stderr = self.run_complete(runner)
self.assertEqual(result, 0)
self.assertIn("OSError", stderr)
def test_missing_lease_session_id_exits_zero(self) -> None:
self.write_pending()
environment = dict(self.environment)
del environment["MOSAIC_LEASE_SESSION_ID"]
runner = mock.Mock()
stderr = io.StringIO()
result = self.complete.main(environ=environment, stderr=stderr, run=runner)
self.assertEqual(result, 0)
runner.assert_not_called()
self.assertIn("KeyError", stderr.getvalue())
def test_insecure_runtime_directory_mode_preserves_pending(self) -> None:
self.write_pending(CHALLENGE)
self.runtime_dir.chmod(0o755)
runner = mock.Mock(
return_value=self.completed(
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
)
)
result, _stderr = self.run_complete(runner)
self.assertEqual(result, 0)
runner.assert_not_called()
self.assertTrue(self.pending_file.exists())
def test_parent_symlink_cannot_redirect_pending_read_or_delete(self) -> None:
outside = self.runtime_dir / "outside"
outside.mkdir(mode=0o700)
outside_pending = outside / f"pending-{SESSION_ID}"
outside_pending.write_text(CHALLENGE, encoding="utf-8")
outside_pending.chmod(0o600)
self.pending_dir.symlink_to(outside, target_is_directory=True)
runner = mock.Mock(
return_value=self.completed(
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
)
)
result, _stderr = self.run_complete(runner)
self.assertEqual(result, 0)
runner.assert_not_called()
self.assertTrue(outside_pending.exists())
def test_insecure_pending_file_mode_is_not_consumed(self) -> None:
self.write_pending(CHALLENGE)
self.pending_file.chmod(0o644)
runner = mock.Mock(
return_value=self.completed(
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
)
)
result, _stderr = self.run_complete(runner)
self.assertEqual(result, 0)
runner.assert_not_called()
self.assertTrue(self.pending_file.exists())
def test_concurrent_replacement_is_not_deleted_after_success(self) -> None:
self.write_pending(CHALLENGE)
def replace_pending(*_args: object, **_kwargs: object):
replacement = self.pending_dir / "replacement"
replacement.write_text("replacement", encoding="utf-8")
replacement.chmod(0o600)
os.replace(replacement, self.pending_file)
return self.completed(
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
)
runner = mock.Mock(side_effect=replace_pending)
result, _stderr = self.run_complete(runner)
self.assertEqual(result, 0)
self.assertEqual(self.pending_file.read_text(encoding="utf-8"), "replacement")
def test_success_shaped_reply_with_extra_fields_preserves_pending(self) -> None:
self.write_pending(CHALLENGE)
runner = mock.Mock(
return_value=self.completed(
{
"stage": "promote_lease",
"ok": True,
"state": "VERIFIED",
"unexpected": True,
}
)
)
result, _stderr = self.run_complete(runner)
self.assertEqual(result, 0)
self.assertTrue(self.pending_file.exists())
class PromotionTemplateWiringTest(unittest.TestCase):
def test_gated_claude_template_wires_begin_and_ordered_stop_chain(self) -> None:
settings = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8"))
hooks = settings["hooks"]
submit_commands = [
hook["command"]
for group in hooks["UserPromptSubmit"]
for hook in group["hooks"]
]
self.assertEqual(
submit_commands,
["python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py"],
)
self.assertEqual(
[group.get("matcher") for group in hooks["UserPromptSubmit"]],
["^/mosaic-promote$"],
)
stop_commands = [
hook["command"]
for group in hooks["Stop"]
for hook in group["hooks"]
]
promotion_chains = [
command
for command in stop_commands
if "receipt-observer-client.py" in command and "promote-complete.py" in command
]
self.assertEqual(len(promotion_chains), 1)
chain = promotion_chains[0]
self.assertLess(
chain.index("receipt-observer-client.py"),
chain.index("promote-complete.py"),
)
self.assertIn("observer_status=$?", chain)
self.assertTrue(chain.endswith("exit $observer_status"))
def test_registered_command_is_one_line_and_defers_to_injected_instruction(self) -> None:
body = CLAUDE_COMMAND.read_text(encoding="utf-8")
self.assertEqual(
body,
"I invoked this registered command to authorize lease promotion; follow the local seat broker's injected receipt confirmation instruction exactly.\n",
)
class PromotionVerbatimToleranceTest(unittest.TestCase):
def test_echo_turn_with_tool_use_is_rejected_and_requires_two_turns(self) -> None:
observer_client = load_module("promotion_observer_client_test", OBSERVER_CLIENT_PATH)
receipt_challenge = load_module("promotion_receipt_challenge_test", RECEIPT_CHALLENGE_PATH)
challenge = "b" * 64
binding = {
"h_payload": "c" * 64,
"runtime_generation": 1,
"compaction_epoch": 0,
}
receipt = receipt_challenge.receipt_for(challenge, binding)
real_claude_entry = {
"message": {
"role": "assistant",
"content": [
{"type": "text", "text": receipt},
{
"type": "tool_use",
"id": "tool-1",
"name": "mcp__discord__reply",
"input": {"message": "promoted"},
},
],
}
}
extracted = observer_client.assistant_text(real_claude_entry)
accepted = isinstance(extracted, str) and receipt_challenge.is_verbatim_receipt(
extracted,
challenge,
binding,
)
self.assertIsNone(extracted)
self.assertFalse(accepted)
if __name__ == "__main__":
unittest.main()
@@ -207,6 +207,24 @@ class ReceiptObserverTest(BrokerFixture):
with self.assertRaisesRegex(DAEMON.BrokerFailure, "INVALID_LEASE_TRANSITION"):
self.promote(current["receipt_challenge"])
def test_non_ascii_observation_is_mismatch_and_broker_keeps_serving(self) -> None:
construction, binding = self.construction()
cycle = self.begin(binding, construction)
self.record("I refuse—this is not the receipt")
with self.assertRaisesRegex(DAEMON.BrokerFailure, "RECEIPT_MISMATCH"):
self.observe(cycle["receipt_challenge"])
denied = self.broker.handle(self.peer, {
"action": "authorize_tool",
"session_id": self.session_id,
"runtime_generation": 7,
"runtime": "pi",
"tool_name": "bash",
})
self.assertEqual(denied["decision"], "deny")
self.assertEqual(denied["state"], DAEMON.LEASE_UNVERIFIED)
def test_t29_altered_model_hash_cannot_promote_against_shipped_binding(self) -> None:
construction, binding = self.construction()
cycle = self.begin(binding, construction)
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""Exit-semantics tests for the receipt observer Stop-hook client."""
from __future__ import annotations
import importlib.util
import io
import json
import tempfile
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest import mock
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
CLIENT_PATH = TOOLS / "receipt-observer-client.py"
def load_client():
spec = importlib.util.spec_from_file_location("receipt_observer_client_test", CLIENT_PATH)
if spec is None or spec.loader is None:
raise RuntimeError("unable to load receipt-observer-client.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
CLIENT = load_client()
VALID_INPUT = json.dumps({"latest_assistant_message": "ordinary turn"}).encode()
DEEPLY_NESTED_JSON = b"[" * 2_000 + b"0" + b"]" * 2_000
ENVIRONMENT = {
"MOSAIC_RECEIPT_OBSERVER_SOCKET": "/unused/observer.sock",
"MOSAIC_LEASE_SESSION_ID": "a" * 64,
"MOSAIC_RUNTIME_GENERATION": "1",
}
class FakeObserverSocket:
def __init__(self, response: bytes) -> None:
self.response = response
def __enter__(self):
return self
def __exit__(self, *_args: object) -> None:
return None
def settimeout(self, _timeout: float) -> None:
return None
def connect(self, _path: str) -> None:
return None
def sendall(self, _payload: bytes) -> None:
return None
def shutdown(self, _how: int) -> None:
return None
def recv(self, _size: int) -> bytes:
response, self.response = self.response, b""
return response
class ReceiptObserverClientExitSemanticsTest(unittest.TestCase):
def run_client(
self,
*,
input_bytes: bytes = VALID_INPUT,
reply: dict[str, object] | None = None,
transport_error: OSError | None = None,
runtime: str = "pi",
) -> tuple[int, str, mock.Mock]:
request = mock.Mock(return_value=reply)
if transport_error is not None:
request.side_effect = transport_error
stderr = io.StringIO()
with (
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(input_bytes)),
mock.patch.object(CLIENT, "observer_request", request),
redirect_stderr(stderr),
):
arguments = ["--runtime", runtime]
if runtime == "claude":
arguments.append("--latest-entry")
result = CLIENT.main(arguments, environ=ENVIRONMENT)
return result, stderr.getvalue(), request
def test_claude_prefers_inline_last_assistant_message(self) -> None:
inline = "the just-finished assistant message"
input_bytes = json.dumps({
"last_assistant_message": inline,
"transcript_path": "/must/not/be/opened.jsonl",
}).encode()
result, stderr, request = self.run_client(
input_bytes=input_bytes,
reply={"ok": True},
runtime="claude",
)
self.assertEqual(result, 0)
self.assertEqual(stderr, "")
self.assertEqual(
request.call_args.args[1]["latest_assistant_message"],
inline,
)
def test_claude_falls_back_to_transcript_when_inline_field_is_absent(self) -> None:
with tempfile.TemporaryDirectory() as directory:
transcript = Path(directory) / "transcript.jsonl"
transcript.write_text(
json.dumps({
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "fallback message"}],
}
})
+ "\n",
encoding="utf-8",
)
input_bytes = json.dumps({"transcript_path": str(transcript)}).encode()
result, stderr, request = self.run_client(
input_bytes=input_bytes,
reply={"ok": True},
runtime="claude",
)
self.assertEqual(result, 0)
self.assertEqual(stderr, "")
self.assertEqual(
request.call_args.args[1]["latest_assistant_message"],
"fallback message",
)
def test_claude_present_invalid_inline_field_fails_without_fallback(self) -> None:
fallback = mock.Mock(return_value="must not be used")
input_bytes = json.dumps({
"last_assistant_message": None,
"transcript_path": "/unused/transcript.jsonl",
}).encode()
with mock.patch.object(CLIENT, "claude_latest_entry", fallback):
result, stderr, request = self.run_client(
input_bytes=input_bytes,
reply={"ok": True},
runtime="claude",
)
self.assertEqual(result, 2)
self.assertIn("Mosaic receipt observer refused", stderr)
request.assert_not_called()
fallback.assert_not_called()
def test_claude_inline_message_size_guard_stays_enforced(self) -> None:
request = mock.Mock(return_value={"ok": True})
stderr = io.StringIO()
with (
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(b"{}")),
mock.patch.object(
CLIENT,
"read_json",
return_value={"last_assistant_message": "x" * (CLIENT.MAX_FRAME + 1)},
),
mock.patch.object(CLIENT, "observer_request", request),
redirect_stderr(stderr),
):
result = CLIENT.main(
["--runtime", "claude", "--latest-entry"],
environ=ENVIRONMENT,
)
self.assertEqual(result, 2)
self.assertIn("Mosaic receipt observer refused", stderr.getvalue())
request.assert_not_called()
def test_pi_still_posts_only_its_runtime_message(self) -> None:
result, stderr, request = self.run_client(reply={"ok": True})
self.assertEqual(result, 0)
self.assertEqual(stderr, "")
self.assertEqual(
request.call_args.args[1]["latest_assistant_message"],
"ordinary turn",
)
def test_nothing_pending_observation_refusal_is_benign(self) -> None:
result, stderr, request = self.run_client(
reply={"ok": False, "code": "OBSERVATION_UNAVAILABLE"}
)
self.assertEqual(result, 0)
self.assertEqual(stderr, "")
request.assert_called_once()
def test_success_reply_remains_successful(self) -> None:
result, stderr, _request = self.run_client(reply={"ok": True})
self.assertEqual(result, 0)
self.assertEqual(stderr, "")
def test_pending_cycle_auth_failure_stays_fail_closed(self) -> None:
result, _stderr, _request = self.run_client(
reply={"ok": False, "code": "ANCESTRY_MISMATCH"}
)
self.assertEqual(result, 2)
def test_transport_failure_stays_fail_closed(self) -> None:
result, stderr, _request = self.run_client(
transport_error=ConnectionRefusedError("observer unavailable")
)
self.assertEqual(result, 2)
self.assertIn("Mosaic receipt observer refused", stderr)
def test_parse_failure_stays_fail_closed(self) -> None:
result, stderr, request = self.run_client(input_bytes=b"{")
self.assertEqual(result, 2)
self.assertIn("Mosaic receipt observer refused", stderr)
request.assert_not_called()
def test_malformed_wire_replies_stay_fail_closed(self) -> None:
for response in (
b"not-json\n",
b'{"ok":true}',
b"{}\n{}\n",
b'{"ok":false,"code":"OBSERVATION_UNAVAILABLE"}\n\n',
b'{"ok":true,"ok":false,"code":"OBSERVATION_UNAVAILABLE"}\n',
DEEPLY_NESTED_JSON + b"\n",
b"x" * (CLIENT.MAX_FRAME + 1),
):
with self.subTest(response=response):
stderr = io.StringIO()
with (
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(VALID_INPUT)),
mock.patch.object(
CLIENT.socket,
"socket",
return_value=FakeObserverSocket(response),
),
redirect_stderr(stderr),
):
result = CLIENT.main(["--runtime", "pi"], environ=ENVIRONMENT)
self.assertEqual(result, 2)
self.assertIn("Mosaic receipt observer refused", stderr.getvalue())
def test_oversized_input_stays_fail_closed(self) -> None:
result, stderr, request = self.run_client(input_bytes=b"x" * (CLIENT.MAX_FRAME + 1))
self.assertEqual(result, 2)
self.assertIn("Mosaic receipt observer refused", stderr)
request.assert_not_called()
def test_deeply_nested_input_stays_fail_closed(self) -> None:
result, stderr, request = self.run_client(input_bytes=DEEPLY_NESTED_JSON)
self.assertEqual(result, 2)
self.assertIn("Mosaic receipt observer refused", stderr)
request.assert_not_called()
def test_json_recursion_failure_stays_fail_closed(self) -> None:
request = mock.Mock()
stderr = io.StringIO()
with (
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(VALID_INPUT)),
mock.patch.object(CLIENT, "observer_request", request),
mock.patch.object(
CLIENT.json,
"loads",
side_effect=RecursionError("maximum JSON nesting exceeded"),
),
redirect_stderr(stderr),
):
result = CLIENT.main(["--runtime", "pi"], environ=ENVIRONMENT)
self.assertEqual(result, 2)
self.assertIn("Mosaic receipt observer refused", stderr.getvalue())
request.assert_not_called()
def test_observation_unavailable_with_unexpected_fields_stays_fail_closed(self) -> None:
result, _stderr, _request = self.run_client(
reply={"ok": False, "code": "OBSERVATION_UNAVAILABLE", "unexpected": True}
)
self.assertEqual(result, 2)
def test_non_boolean_ok_values_stay_fail_closed(self) -> None:
for reply in (
{"ok": 1},
{"ok": 0, "code": "OBSERVATION_UNAVAILABLE"},
):
with self.subTest(reply=reply):
result, _stderr, _request = self.run_client(reply=reply)
self.assertEqual(result, 2)
if __name__ == "__main__":
unittest.main()
@@ -388,8 +388,12 @@ describe('whole mutator-class lease gate', () => {
['claude', 'Write'],
['claude', 'NotebookEdit'],
['claude', 'mcp__provider__close_issue'],
['claude', 'Ls'],
['claude', 'Find'],
['pi', 'bash'],
['pi', 'edit'],
['pi', 'grep'],
['pi', 'find'],
['pi', 'write'],
['pi', 'deploy'],
['pi', 'unknown_custom_tool'],
@@ -409,8 +413,9 @@ describe('whole mutator-class lease gate', () => {
for (const [runtime, toolName] of [
['claude', 'Read'],
['claude', 'Grep'],
['claude', 'Glob'],
['pi', 'read'],
['pi', 'grep'],
['pi', 'ls'],
['pi', 'mosaic_context_recover'],
] as const) {
expect(await authorize(socket, sessionId, runtime, toolName)).toMatchObject({
+1 -1
View File
@@ -37,7 +37,7 @@ const RUNTIME_DEFS: Record<
label: 'Pi',
command: 'pi',
versionFlag: '--version',
installHint: 'npm install -g @mariozechner/pi-coding-agent',
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
},
};
@@ -85,16 +85,16 @@ function makeConfigService(): ConfigService {
describe('finalizeStage — skill installer', () => {
let tmp: string;
let binDir: string;
let scriptsDir: string;
let syncScript: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
binDir = join(tmp, 'bin');
mkdirSync(binDir, { recursive: true });
syncScript = join(binDir, 'mosaic-sync-skills');
scriptsDir = join(tmp, 'tools', '_scripts');
mkdirSync(scriptsDir, { recursive: true });
syncScript = join(scriptsDir, 'mosaic-sync-skills');
// Default: script exists and succeeds
// Default: current framework layout has tools/_scripts and succeeds.
writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' });
});
@@ -156,10 +156,29 @@ describe('finalizeStage — skill installer', () => {
const call = findSkillsSyncCall();
expect(call).toBeDefined();
expect(call![1]).toEqual([join(tmp, 'tools', '_scripts', 'mosaic-sync-skills')]);
const opts = call![2] as { env?: Record<string, string> };
expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging');
});
it('falls back to legacy bin path for pre-migration installs', async () => {
rmSync(syncScript);
const legacyBinDir = join(tmp, 'bin');
mkdirSync(legacyBinDir, { recursive: true });
const legacySyncScript = join(legacyBinDir, 'mosaic-sync-skills');
writeFileSync(legacySyncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
const state = makeState(tmp, ['brainstorming']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
const call = findSkillsSyncCall();
expect(call).toBeDefined();
expect(call![1]).toEqual([legacySyncScript]);
});
it('skips the sync script entirely when no skills are selected', async () => {
const state = makeState(tmp, []);
const p = buildPrompter();
@@ -199,7 +218,9 @@ describe('finalizeStage — skill installer', () => {
// spawnSync should NOT have been called for the skills script
expect(findSkillsSyncCall()).toBeUndefined();
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(p.warn).toHaveBeenCalledWith(
expect.stringContaining('tools/_scripts/mosaic-sync-skills'),
);
});
it('includes skills count in the summary when install succeeds', async () => {
+45 -14
View File
@@ -13,16 +13,22 @@ import {
type SkillSyncResult as ClaudeSkillSyncResult,
} from '../commands/skill.js';
/**
* Link runtime assets. Returns a warning string when the install-ordering
* guard (#869 Point-1 C2) reported a degraded outcome i.e. the
* lease-enforcement hooks were NOT wired into ~/.claude/settings.json because
* this host could not confirm it can activate them so the caller can
* surface it via `p.warn(...)` instead of it being swallowed by `stdio:
* 'pipe'`. Non-fatal either way: the wizard always continues.
*/
function frameworkScriptPath(mosaicHome: string, name: string): string {
const currentPath = join(mosaicHome, 'tools', '_scripts', name);
if (existsSync(currentPath)) return currentPath;
// Backward-compatible fallback for pre-migration installs that still have bin/.
const legacyPath = join(mosaicHome, 'bin', name);
if (existsSync(legacyPath)) return legacyPath;
// Return the current expected path so user-facing errors point at the layout
// installed by packages/mosaic/framework/install.sh.
return currentPath;
}
/** Link runtime assets and surface a non-zero install-ordering guard outcome. */
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): string | undefined {
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
const script = frameworkScriptPath(mosaicHome, 'mosaic-link-runtime-assets');
if (!existsSync(script)) return undefined;
try {
const result = spawnSync('bash', [script], {
@@ -69,7 +75,7 @@ function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsRes
return { success: true, installedCount: 0 };
}
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
const script = frameworkScriptPath(mosaicHome, 'mosaic-sync-skills');
if (!existsSync(script)) {
return {
success: false,
@@ -117,7 +123,7 @@ interface DoctorResult {
}
function runDoctor(mosaicHome: string): DoctorResult {
const script = join(mosaicHome, 'bin', 'mosaic-doctor');
const script = frameworkScriptPath(mosaicHome, 'mosaic-doctor');
if (!existsSync(script)) {
return { warnings: 0, output: 'mosaic-doctor not found' };
}
@@ -170,11 +176,24 @@ function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
}
}
export interface FinalizeStageOptions {
/**
* Defer the success summary/outro so callers can run downstream readiness
* gates (gateway health/bootstrap) before claiming Mosaic is ready.
*/
deferSummary?: boolean;
}
export interface FinalizeStageResult {
showSummary: () => void;
}
export async function finalizeStage(
p: WizardPrompter,
state: WizardState,
config: ConfigService,
): Promise<void> {
options: FinalizeStageOptions = {},
): Promise<FinalizeStageResult> {
p.separator();
const spin = p.spinner();
@@ -269,7 +288,12 @@ export async function finalizeStage(
// 7. PATH setup
const pathAction = setupPath(state.mosaicHome, p);
// 8. Summary
let summaryShown = false;
const showSummary = () => {
if (summaryShown) return;
summaryShown = true;
// 7. Summary
const skillsSummary = skillsResult.success
? skillsResult.installedCount > 0
? `${skillsResult.installedCount.toString()} installed`
@@ -294,7 +318,7 @@ export async function finalizeStage(
p.note(summary.join('\n'), 'Installation Summary');
// 9. Next steps
// 8. Next steps
const nextSteps: string[] = [];
if (pathAction === 'added') {
const profilePath = getShellProfilePath();
@@ -309,4 +333,11 @@ export async function finalizeStage(
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
p.outro('Mosaic is ready.');
};
if (!options.deferSummary) {
showSummary();
}
return { showSummary };
}
@@ -136,6 +136,7 @@ describe('gatewayConfigStage', () => {
delete process.env['MOSAIC_STORAGE_TIER'];
delete process.env['MOSAIC_DATABASE_URL'];
delete process.env['MOSAIC_VALKEY_URL'];
delete process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'];
});
afterEach(() => {
@@ -167,6 +168,75 @@ describe('gatewayConfigStage', () => {
expect(state.gateway?.regeneratedConfig).toBe(true);
});
it('installs the gateway package on fresh install when skipInstall is not set', async () => {
const p = buildPrompter();
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: false,
});
expect(result.ready).toBe(true);
expect(daemonState.installPkgCalled).toBe(1);
});
it('honors MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 and skips the registry install (dev/offline installs)', async () => {
process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] = '1';
const p = buildPrompter();
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: false,
});
// The source-built global gateway must NOT be overwritten by @latest.
expect(result.ready).toBe(true);
expect(daemonState.installPkgCalled).toBe(0);
});
it('does not ask for a gateway API key when provider setup was completed with no key', async () => {
delete process.env['MOSAIC_ASSUME_YES'];
const originalIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
try {
const textFn = vi.fn(async (opts: { message: string; initialValue?: string }) => {
if (opts.message === 'Gateway port') return opts.initialValue ?? '14242';
if (opts.message === 'Web UI hostname (for browser access)') return 'localhost';
if (opts.message.includes('API_KEY')) {
throw new Error('gateway API key prompt should be skipped');
}
return '';
});
const p = buildPrompter({ text: textFn, select: vi.fn().mockResolvedValue('local') });
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: true,
providerType: 'none',
});
expect(result.ready).toBe(true);
expect(textFn).not.toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('API_KEY') }),
);
const envContents = readFileSync(daemonState.envFile, 'utf-8');
expect(envContents).not.toContain('ANTHROPIC_API_KEY=');
expect(envContents).not.toContain('OPENAI_API_KEY=');
} finally {
Object.defineProperty(process.stdin, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
}
});
it('short-circuits when gateway is already fully installed and user declines rerun', async () => {
// Pre-populate both files + running daemon + meta with token
const fs = require('node:fs');
+9 -1
View File
@@ -294,7 +294,12 @@ export async function gatewayConfigStage(
}
// Install the gateway npm package on first install or after failure.
if (!opts.skipInstall && !daemonRunning) {
// MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 forces a skip even without opts.skipInstall:
// used by dev/offline installs where @mosaicstack/gateway is already present
// globally (e.g. a build-from-source `install.sh --dev`) and must not be
// overwritten by the registry @latest build.
const skipNpmInstall = opts.skipInstall || process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] === '1';
if (!skipNpmInstall && !daemonRunning) {
installGatewayPackage();
}
@@ -506,6 +511,9 @@ async function collectAndWriteConfig(
if (opts.providerKey) {
anthropicKey = opts.providerKey;
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`);
} else if (opts.providerType === 'none') {
anthropicKey = '';
p.log('No API key provided during provider setup; skipping gateway API key prompt.');
} else {
anthropicKey = await p.text({
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
+15 -5
View File
@@ -37,6 +37,7 @@ export async function quickStartPath(
// 1. Provider setup (first question)
await providerSetupStage(prompter, state);
state.completedSections?.add('providers');
// Apply sensible defaults for everything else
state.soul.agentName ??= 'Mosaic';
@@ -57,9 +58,13 @@ export async function quickStartPath(
// Skills (recommended set, no user input in quick mode)
await skillsSelectStage(prompter, state);
state.completedSections?.add('skills');
// Finalize (writes configs, links runtime assets, syncs skills)
await finalizeStage(prompter, state, configService);
// Finalize writes configs/assets/skills, but defer the success summary until
// after the gateway health/bootstrap gates complete.
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway config + bootstrap
if (!options.skipGateway) {
@@ -72,7 +77,7 @@ export async function quickStartPath(
portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey,
providerType: state.providerType ?? 'none',
providerType: state.providerType,
});
if (!configResult.ready || !configResult.host || !configResult.port) {
@@ -80,7 +85,9 @@ export async function quickStartPath(
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
process.exit(1);
}
} else {
return;
}
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host,
port: configResult.port,
@@ -88,11 +95,14 @@ export async function quickStartPath(
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
return;
}
}
finalizeResult.showSummary();
} catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
} else {
finalizeResult.showSummary();
}
}
+55 -13
View File
@@ -126,6 +126,11 @@ type MenuChoice =
| 'advanced'
| 'finish';
function menuSectionKey(section: MenuChoice): MenuSection | null {
if (section === 'quick-start' || section === 'finish') return null;
return section === 'gateway-config' ? 'gateway' : section;
}
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
const labels: Record<MenuChoice, string> = {
'quick-start': 'Quick Start',
@@ -137,14 +142,24 @@ function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
finish: 'Finish & Apply',
};
const base = labels[section];
const sectionKey: MenuSection =
section === 'gateway-config' ? 'gateway' : (section as MenuSection);
if (completed.has(sectionKey)) {
const sectionKey = menuSectionKey(section);
if (sectionKey && completed.has(sectionKey)) {
return `${base} [done]`;
}
return base;
}
function skipCompletedMenuChoice(
prompter: WizardPrompter,
completed: Set<MenuSection>,
choice: MenuChoice,
): boolean {
const sectionKey = menuSectionKey(choice);
if (!sectionKey || !completed.has(sectionKey)) return false;
prompter.log(`${menuLabel(choice, completed)} is already complete; skipping.`);
return true;
}
async function runMenuLoop(
prompter: WizardPrompter,
state: WizardState,
@@ -201,21 +216,25 @@ async function runMenuLoop(
return; // Quick start is a complete flow — exit menu
case 'providers':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await providerSetupStage(prompter, state);
completed.add('providers');
break;
case 'identity':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await agentIntentStage(prompter, state);
completed.add('identity');
break;
case 'skills':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await skillsSelectStage(prompter, state);
completed.add('skills');
break;
case 'gateway-config':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
// Gateway config is handled during Finish — mark as "configured"
// after user reviews settings.
await runGatewaySubMenu(prompter, state, options);
@@ -223,6 +242,7 @@ async function runMenuLoop(
break;
case 'advanced':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await runAdvancedSubMenu(prompter, state);
completed.add('advanced');
break;
@@ -310,8 +330,11 @@ async function runFinishPath(
await skillsSelectStage(prompter, state);
}
// Finalize (writes configs, links runtime assets, syncs skills)
await finalizeStage(prompter, state, configService);
// Finalize writes configs/assets/skills, but defer the success summary until
// after the gateway health/bootstrap gates complete.
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages
if (!options.skipGateway) {
@@ -322,7 +345,7 @@ async function runFinishPath(
portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey,
providerType: state.providerType ?? 'none',
providerType: state.providerType,
});
if (configResult.ready && configResult.host && configResult.port) {
@@ -333,12 +356,16 @@ async function runFinishPath(
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
return;
}
finalizeResult.showSummary();
}
} catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
} else {
finalizeResult.showSummary();
}
}
@@ -374,8 +401,11 @@ async function runHeadlessPath(
// Skills
await skillsSelectStage(prompter, state);
// Finalize
await finalizeStage(prompter, state, configService);
// Finalize writes configs/assets/skills, but defer the success summary until
// after the gateway health/bootstrap gates complete.
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages
if (!options.skipGateway) {
@@ -386,13 +416,15 @@ async function runHeadlessPath(
portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey,
providerType: state.providerType ?? 'none',
providerType: state.providerType,
});
if (!configResult.ready || !configResult.host || !configResult.port) {
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
process.exit(1);
} else {
return;
}
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host,
port: configResult.port,
@@ -400,12 +432,15 @@ async function runHeadlessPath(
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
return;
}
}
finalizeResult.showSummary();
} catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
} else {
finalizeResult.showSummary();
}
}
@@ -426,8 +461,11 @@ async function runKeepPath(
// Skills
await skillsSelectStage(prompter, state);
// Finalize
await finalizeStage(prompter, state, configService);
// Finalize writes configs/assets/skills, but defer the success summary until
// after the gateway health/bootstrap gates complete.
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages
if (!options.skipGateway) {
@@ -447,11 +485,15 @@ async function runKeepPath(
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
return;
}
finalizeResult.showSummary();
}
} catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
} else {
finalizeResult.showSummary();
}
}
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
FAKE_BIN="$TMP/bin"
HOME_DIR="$TMP/home"
PREFIX="$TMP/prefix"
MOSAIC_HOME="$TMP/mosaic"
STATE="$TMP/state"
LOG="$TMP/npm.log"
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
STATE="${MOSAIC_TEST_STATE:?}"
echo "$*" >> "$LOG"
if [[ "$1" == "view" ]]; then
case "$2 $3" in
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
"@mosaicstack/mosaic version") echo "0.0.48" ;;
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "install" ]]; then
case "$*" in
*"@mosaicstack/[email protected]"*)
echo "0.0.49-next.999" > "$STATE/mosaic"
;;
*"@mosaicstack/[email protected]"*)
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
echo "forced gateway install failure" >&2
exit 1
fi
echo "0.0.7-next.999" > "$STATE/gateway"
;;
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/mosaic"
;;
*"mosaicstack-gateway-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/gateway"
;;
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "ls" ]]; then
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
node -e '
const cli = process.argv[1];
const gateway = process.argv[2];
const dependencies = {};
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
process.stdout.write(JSON.stringify({ dependencies }));
' "$cli" "$gateway"
exit 0
fi
echo "unexpected npm command: $*" >&2
exit 1
FAKE_NPM
chmod +x "$FAKE_BIN/npm"
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
#!/usr/bin/env bash
set -euo pipefail
# The fake tar creates the source tree; curl only needs to keep the pipe alive.
exit 0
FAKE_CURL
chmod +x "$FAKE_BIN/curl"
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
#!/usr/bin/env bash
set -euo pipefail
dest=""
while [[ $# -gt 0 ]]; do
case "$1" in
-C) dest="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$dest" ]]; then
echo "fake tar missing -C destination" >&2
exit 1
fi
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
FAKE_TAR
chmod +x "$FAKE_BIN/tar"
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
echo "pnpm $*" >> "$LOG"
if [[ "$1" == "pack" ]]; then
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--pack-destination) out="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$out" ]]; then
echo "fake pnpm pack missing destination" >&2
exit 1
fi
mkdir -p "$out"
case "$PWD" in
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
esac
exit 0
fi
# install/build commands are no-ops in this harness.
exit 0
FAKE_PNPM
chmod +x "$FAKE_BIN/pnpm"
reset_state() {
: > "$LOG"
rm -f "$STATE"/*
}
reset_state
echo "[test] --next fast path pins resolved package versions"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
echo "expected exact-version installs, found mutable @next install" >&2
exit 1
fi
if grep -qF 'Downloading source from next' <<<"$OUTPUT"; then
echo "fast path unexpectedly fell back to source" >&2
exit 1
fi
reset_state
echo "[test] fast path failure falls back to source build"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
grep -qF 'Downloading source from next' <<<"$OUTPUT"
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
reset_state
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
)"
grep -qF 'explicit ref wins, build-from-source' <<<"$OUTPUT"
if grep -qF '@next version' "$LOG"; then
echo "explicit ref should not query @next dist-tags" >&2
exit 1
fi
reset_state
echo "[test] --check --next warns on mismatched prerelease pipeline suffixes"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next
)"
grep -qF '@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source.' <<<"$OUTPUT"
echo "[test] installer next lane tests passed"
+192 -11
View File
@@ -16,6 +16,10 @@
# --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main)
# --next Prerelease lane: try fast npm @next install for CLI +
# gateway from the Gitea registry, then fall back to a
# source build at next if unavailable. Explicit
# --ref/MOSAIC_REF wins and uses the source path.
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the
# registry @latest. Zero registry writes — packs local
# tarballs and installs them globally. Use to test a branch
@@ -31,6 +35,7 @@
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_NEXT — equivalent to --next (set to 1)
# MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# ──────────────────────────────────────────────────────────────────────────────
@@ -49,7 +54,12 @@ FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false
FLAG_UNINSTALL=false
FLAG_DEV=false
FLAG_NEXT=false
GIT_REF="${MOSAIC_REF:-main}"
GIT_REF_EXPLICIT=false
if [[ -n "${MOSAIC_REF:-}" ]]; then
GIT_REF_EXPLICIT=true
fi
# MOSAIC_ASSUME_YES env var acts the same as --yes
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
@@ -61,8 +71,18 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true
fi
# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with
# source fallback from the permanent next integration branch unless
# MOSAIC_REF/--ref explicitly wins.
if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
FLAG_NEXT=true
if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then
GIT_REF="next"
fi
fi
installer_usage() {
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
}
while [[ $# -gt 0 ]]; do
@@ -82,9 +102,11 @@ while [[ $# -gt 0 ]]; do
exit 2
fi
GIT_REF="$2"
GIT_REF_EXPLICIT=true
shift 2
;;
--dev) FLAG_DEV=true; shift ;;
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
--yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;;
@@ -96,12 +118,24 @@ while [[ $# -gt 0 ]]; do
esac
done
# Explicit refs represent a request for that exact source tree. Keep --next as
# a lane selector, but do not install the registry @next package for a different
# ref than the permanent next branch.
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then
FLAG_DEV=true
fi
if [[ "$FLAG_YES" == "true" ]]; then
export MOSAIC_ASSUME_YES=1
fi
# ─── constants ────────────────────────────────────────────────────────────────
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/mosaic"
GATEWAY_PKG="${SCOPE}/gateway"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
@@ -116,6 +150,20 @@ fi
WORK_DIR=""
EXTRACTED_DIR=""
newest_matching_file() {
local dir="$1"
local pattern="$2"
local matches=()
[[ -d "$dir" ]] || return 0
shopt -s nullglob
# shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern.
matches=("$dir"/$pattern)
shopt -u nullglob
[[ "${#matches[@]}" -gt 0 ]] || return 0
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
ls -1t "${matches[@]}" 2>/dev/null | head -1
}
# ─── uninstall path ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line.
@@ -179,7 +227,7 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
# Find most recent backup
backup=""
if [[ -d "$dir" ]]; then
backup="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
fi
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
cp "$backup" "$dest"
@@ -235,6 +283,22 @@ fail() { echo "${R}✖${RESET} $*" >&2; }
dim() { echo "${DIM}$*${RESET}"; }
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
is_next_registry_lane() {
[[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]]
}
source_ref_details() {
if is_next_registry_lane; then
echo "ref: next, --next prerelease lane"
elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
echo "ref: next, --next prerelease lane (build-from-source)"
elif [[ "$FLAG_NEXT" == "true" ]]; then
echo "ref: ${GIT_REF}, --next requested, explicit ref wins"
else
echo "ref: ${GIT_REF}"
fi
}
# ─── helpers ──────────────────────────────────────────────────────────────────
require_cmd() {
@@ -257,10 +321,43 @@ installed_cli_version() {
fi
}
installed_gateway_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
if [[ -n "$json" ]]; then
node -e "
const d = JSON.parse(process.argv[1]);
const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? '';
process.stdout.write(v);
" "$json" 2>/dev/null || true
fi
}
latest_cli_version() {
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
}
next_cli_version() {
npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_gateway_version() {
npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_pipeline_suffix() {
printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p'
}
next_versions_share_pipeline() {
local cli_next="$1"
local gateway_next="$2"
local cli_pipeline gateway_pipeline
cli_pipeline="$(next_pipeline_suffix "$cli_next")"
gateway_pipeline="$(next_pipeline_suffix "$gateway_next")"
[[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]]
}
version_lt() {
node -e "
const a=process.argv[1], b=process.argv[2];
@@ -353,8 +450,8 @@ install_cli_from_source() {
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
local cli_tgz gw_tgz
cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"
if [[ ! -f "$cli_tgz" ]]; then
fail "CLI tarball was not produced by pnpm pack."
@@ -376,6 +473,49 @@ install_cli_from_source() {
ok "Installed from source: CLI $(installed_cli_version)"
}
install_next_cli_from_registry() {
local cli_next gateway_next
cli_next="$(next_cli_version)"
gateway_next="$(next_gateway_version)"
if [[ -z "$cli_next" ]]; then
warn "${CLI_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if [[ -z "$gateway_next" ]]; then
warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then
warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})."
return 1
fi
info "Installing ${CLI_PKG}@${cli_next} from registry…"
if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast CLI @next install failed."
return 1
fi
info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…"
if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast gateway @next install failed."
return 1
fi
local installed_cli installed_gateway
installed_cli="$(installed_cli_version)"
installed_gateway="$(installed_gateway_version)"
if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then
warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})."
return 1
fi
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
}
# ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node
@@ -409,7 +549,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
else
dim " Installed: (none)"
fi
dim " Source: ${REPO_BASE} (ref: ${GIT_REF})"
dim " Source: ${REPO_BASE} ($(source_ref_details))"
echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then
@@ -476,8 +616,12 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi
CURRENT="$(installed_cli_version)"
NEXT_GATEWAY=""
if [[ "$FLAG_DEV" == "true" ]]; then
LATEST=""
elif is_next_registry_lane; then
LATEST="$(next_cli_version)"
NEXT_GATEWAY="$(next_gateway_version)"
else
LATEST="$(latest_cli_version)"
fi
@@ -489,7 +633,19 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi
if [[ "$FLAG_DEV" == "true" ]]; then
dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)"
elif is_next_registry_lane; then
if [[ -n "$LATEST" ]]; then
dim " Next CLI: ${CLI_PKG}@${LATEST}"
else
dim " Next CLI: (registry @next unreachable)"
fi
if [[ -n "$NEXT_GATEWAY" ]]; then
dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}"
else
dim " Next GW: (registry @next unreachable)"
fi
dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)"
elif [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}"
else
@@ -500,6 +656,12 @@ if [[ "$FLAG_CLI" == "true" ]]; then
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
elif is_next_registry_lane; then
if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then
ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}."
else
warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source."
fi
elif [[ -z "$LATEST" ]]; then
warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then
@@ -516,6 +678,23 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ensure_monorepo
install_cli_from_source
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
elif is_next_registry_lane; then
info "Next mode — trying fast npm @next install from ${REGISTRY}"
if install_next_cli_from_registry; then
:
else
warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues."
unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL
ensure_monorepo
install_cli_from_source
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
@@ -624,7 +803,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
local base dir backup_path backup_val
base="$(basename "$dest")"
dir="$(dirname "$dest")"
backup_path="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
if [[ -n "$backup_path" ]]; then
backup_val="\"$backup_path\""
else
@@ -649,7 +828,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
fi
node -e "
if node -e "
const fs = require('fs');
const path = require('path');
const p = process.argv[1];
@@ -674,9 +853,11 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
"$MANIFEST_CLI_VERSION" \
"$MANIFEST_FW_VERSION" \
"$NPMRC_LINES_JSON" \
"$RUNTIME_COPIES" 2>/dev/null \
&& ok "Install manifest written: $MANIFEST_PATH" \
|| warn "Could not write install manifest (non-fatal)"
"$RUNTIME_COPIES" 2>/dev/null; then
ok "Install manifest written: $MANIFEST_PATH"
else
warn "Could not write install manifest (non-fatal)"
fi
echo ""
ok "Done."