Commit Graph
803 Commits
Author SHA1 Message Date
fred 07373ede4d docs(install): record the two trust/portability assumptions in install_node
ci/woodpecker/pr/ci Pipeline was successful
Comment-only, no behaviour change. Both raised by scooby in the #1229 review
as non-blocking findings worth writing down rather than fixing here.

F-A: the SHASUMS256.txt check gives integrity, not authenticity. TLS to
$NODE_DIST_BASE is the whole trust root, and MOSAIC_NODE_DIST_BASE widens it
to any mirror with no signature backstop. GPG-verifying SHASUMS256.txt.sig is
filed as its own follow-up so it gets its own review.

F-C: the uname map pulls the glibc build, so musl hosts fail — visibly, via
node_is_suitable, not silently.
2026-08-15 21:50:14 -05:00
fredandClaude Opus 5 fb5bb98a32 Revert "fix(installer): re-link runtime assets after the CLI stage"
ci/woodpecker/pr/ci Pipeline was canceled
This reverts 47e90767. I was wrong: the fix is correct about the cause and
makes the outcome worse.

The acceptance run passed everything I set out to check — greenfield canary
1125, --next --yes, no TTY, rc=0, node v22.23.2 + CLI 0.0.50-next.2413 from a
fresh login shell, and both enforcement hooks wired in ~/.claude/settings.json
where before they were stripped. Then `mosaic doctor` on that same host:

  [ERROR] Lease-enforcement hooks (mutator-gate.py, receipt-observer-client.py)
  are wired in ~/.claude/settings.json, but broker not healthy
  (checkBrokerSupervisorHealth() reports unhealthy). Every gated tool call will
  fail closed and BRICK this agent (see #869).

So the change takes a greenfield host from 'enforcement quietly off, agent
works' to 'enforcement wired, broker absent, agent bricks on the first gated
tool call'. The pre-existing behaviour reaches the safe state for the wrong
reason; this reaches the unsafe state for the right one. Safe-for-the-wrong-
reason still wins.

The real defect is underneath both, and it is not an ordering bug:

  mosaic __link-claude-settings ...   -> rc=0  (leaseEnforcementActivatable:
                                                 activatable, wire the hooks)
  mosaic doctor                       -> ERROR (checkBrokerSupervisorHealth:
                                                 unhealthy, hooks will brick)

Two capability checks, same host, opposite verdicts. And after a complete
install there is no broker supervisor to be healthy: no systemd --user unit
matching lease/broker, nothing under ~/.mosaic but the bootstrapped node, and
no lease or broker script in ~/.config/mosaic/tools/_scripts/. Lease
enforcement cannot be activated on a greenfield host at all, so
leaseEnforcementActivatable() returning true is the thing that is wrong.

Filing that separately. PR #1229 goes back to exactly the four commits scooby
reviewed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-15 21:42:20 -05:00
fredandClaude Opus 5 47e90767b7 fix(installer): re-link runtime assets after the CLI stage, so greenfield keeps its enforcement hooks
ci/woodpecker/pr/ci Pipeline was canceled
The framework's install.sh ends by running mosaic-link-runtime-assets, which
asks the `mosaic` CLI whether lease enforcement can be activated before
deciding whether to wire the #828 hooks into settings.json. Part 1 (framework)
runs before Part 2 (npm CLI), so on a first install there is no CLI to ask. The
script takes its fail-safe branch, prints a four-line ERROR, and writes
settings.json with mutator-gate.py and receipt-observer-client.py stripped out.

Measured on canary 1125, rolled back to greenfield, `--next --yes`, no TTY:

  framework template ~/.config/mosaic/runtime/claude/settings.json
    mutator-gate.py            1 occurrence
    receipt-observer-client.py 1 occurrence
  installed ~/.claude/settings.json after a clean rc=0 install
    mutator-gate.py            wired: False
    receipt-observer-client.py wired: False

So enforcement ends up off because of the order the two halves install in, not
because of anything about the host. Falsified by running the same script by
hand once the CLI existed: rc=0, both hooks wired: True. The guard's real
verdict on that host was 'activatable' the whole time.

This adds one more pass after Part 2. The script is idempotent (unchanged files
are skipped), so on an upgrade — CLI already present, first pass already
correct — it is a no-op. It deliberately does not pass
--allow-inactive-enforcement: Part 1 does not either, and a repair pass must
not be more permissive than the pass it corrects.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-15 21:38:11 -05:00
fred 00bc602f93 fix(installer): persist the bootstrapped Node on PATH, and stop duplicating PATH lines
ci/woodpecker/pr/ci Pipeline was successful
Two defects found by the second unattended greenfield run on canary (VMID 1125,
rolled back to its greenfield snapshot first).

1. ensure_node() exported the Mosaic-managed Node for the installer process and
   nothing wrote it down. The install finished rc=0, put $PREFIX/bin in
   ~/.profile, and the next login shell found `mosaic` and then died on

       env: 'node': No such file or directory

   The CLI is a Node script, so a CLI on PATH without its runtime is a
   successful install that produces a broken command. persist_node_on_path()
   now writes the runtime's bin dir to the same profile, from both the
   fresh-install and the already-installed-but-not-on-PATH branches.

2. The 'is it already in a shell rc file' guard was a single
   `grep -qslF "$dir" "${rc_files[@]}"` over four paths, most of which do
   not exist on a clean host. Handing grep a missing file makes the exit status
   implementation-defined: GNU grep 3.11 returns 0 when -q already matched an
   earlier file, ugrep 7.5 returns 2 for the missing one regardless. On the 2
   path the caller reads 'not present yet' and appends another PATH line, so
   every re-install grew the profile. Measured: 3 runs produced 3 duplicate
   entries; with the fix, 1.

   path_entry_exists() now tests each file for existence and greps it on its
   own, so the result does not depend on the grep implementation.

The profile-writing body is factored into persist_on_path(), shared by the CLI
prefix and the Node runtime, since both now need identical treatment.

Verified in a scratch $HOME: fresh write, idempotent across three runs, zsh
routes to .zshenv, an unwritable profile warns and survives set -e, and an
already-on-PATH prefix is a no-op that creates no file. Falsified by restoring
the multi-file grep: duplicates return.
2026-08-15 16:07:56 -05:00
fred d0c223bdf9 fix(wizard): write PATH to .profile/.zshenv, never .bashrc
ci/woodpecker/pr/ci Pipeline was successful
getShellProfilePath() preferred ~/.bashrc when it existed, and ~/.zshrc for
zsh. setupPath() in stages/finalize.ts appends the PATH export to whatever
it returns. Debian's default ~/.bashrc opens with

    case $- in *i*) ;; *) return;; esac

so a line appended to the bottom of it never runs for 'bash -lc', for
systemd units, for 'ssh host cmd', or for any agent seat — precisely the
consumers that need the CLI. An install could print its summary and exit 0
while leaving 'mosaic: command not found'. .zshrc has the same problem:
zsh only reads it for interactive shells.

Now ~/.profile, which login shells read and which Debian's copy sources
.bashrc from for interactive shells, so one line covers both. For zsh the
always-sourced file is .zshenv. fish and PowerShell are unchanged.

__tests__/platform/detect.test.ts pins it, including a case asserting that
no shell resolves to an interactive-only rc file. Falsified by inverting
the fix: 5 failed / 1 passed; restored 6/6. Full package suite unchanged at
17 files / 4 tests failing, matching clean origin/next.
2026-08-15 15:54:10 -05:00
fred cc0d24d5c4 fix(installer): bootstrap Node.js on a greenfield host
tools/install.sh required node and npm and installed neither. Measured on a
snapshot-reverted Debian 13 image with no node, npm or git: the run stopped
at `require_cmd node` with "Required command not found: node", exit 1,
nothing installed, and no indication of how to proceed.

Adds ensure_node() to preflight. It fetches an official Node.js release into
$HOME/.mosaic/node, verifies it against that release's SHASUMS256.txt, and
refuses rather than degrades when the entry is missing or the checksum does
not match. sha256sum on Linux, shasum on macOS. .tar.gz over the smaller
.tar.xz because gzip is universally present and xz is not — a minimal image
is the case this exists to handle.

No-op when a suitable node is already on PATH, so it never fights an
operator's nvm/fnm/distro node. MOSAIC_SKIP_NODE_BOOTSTRAP=1 declines the
download and fails with instructions instead.

Inlined rather than factored into a sibling file because this script is
fetched standalone by curl and has nothing to source.
2026-08-15 15:54:09 -05:00
fred 40fecd4d38 fix(installer): put $PREFIX/bin on PATH instead of warning about it
The three duplicated PATH blocks in tools/install.sh only warned, so an
unattended install finished with rc=0 and left `mosaic: command not found`
— there was no operator to read the advice and act on it. Measured on a
greenfield Debian 13 sandbox: `--next --yes` installed
@mosaicstack/[email protected] successfully and the CLI was still
unreachable.

Replaces all three copies with one ensure_prefix_on_path helper that
appends the export to ~/.profile (~/.zshenv under zsh) and is a no-op when
the prefix is already on PATH or already in a shell profile.

Not ~/.bashrc: Debian's default .bashrc returns early for non-interactive
shells, so a line appended there is unreachable to `bash -lc`, systemd
units and agent seats — the consumers that need the CLI.
2026-08-15 15:47:30 -05:00
mos-dt-0 7a6fb024b4 docs: establish canonical documentation architecture (#1210)
ci/woodpecker/push/publish Pipeline failed
2026-08-13 17:56:13 +00:00
mos-dt-0 f82307c4dc fix(lease): raise capability-probe timeout to 10s on both halves (#869) (#1207)
ci/woodpecker/push/publish Pipeline failed
2026-08-13 17:28:00 +00:00
shaggyandmos-dt-0 216cd72226 refactor(chat): route browser chat through one runtime (P3 Slice-Zero Task 5) (#1172)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: shaggy <[email protected]>
2026-08-12 20:11:12 +00:00
mos-dt-0 6a8ce66702 Merge pull request 'feat(lease): verified lease-remediation stack (rebased onto next) — promotion trigger + promote CLI + carve-out + TTL' (#1109) from feat/lease-promotion-and-harness-isolation into next
ci/woodpecker/push/publish Pipeline failed
2026-08-12 03:07:32 +00:00
jason.woltjeandMos 9cd9409089 P3 Slice Zero, Task 4 — replace Web free-text selection with the structured harness catalog (#1170)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
2026-08-12 02:50:18 +00:00
Jason WoltjeandClaude Fable 5 13c70a7a10 test(mutator-gate): keep launch ledger out of the shipped framework tree
ci/woodpecker/pr/ci Pipeline was successful
runRuntimeLaunchEntry set MOSAIC_HOME to the shipped framework root, so
launch-runtime.py appended its launch ledger to
framework/fleet/run/sessions/events.ndjson — polluting the tree that
manifest.spec.ts walks and failing its completeness check in CI.

Point MOSAIC_HOME at the per-entry temp root instead. Nothing in the
launch chain resolves tools via MOSAIC_HOME (entry scripts resolve via
SCRIPT_DIR); the ledger is its only consumer here.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-11 20:51:03 -05:00
dd6357e670 test(skill): install linker asserts harness-home link topology
The two install-linker-compatibility tests still asserted the pre-isolation
behavior (mosaic skill links planted in $HOME/.claude/skills). This branch
deliberately moved the link farm into the mosaic-owned harness homes
($MOSAIC_HOME/.claude/skills) and demoted the base-install dirs to
cleanup-only legacy targets, so the tests now assert the new topology:
the skill links appear under the harness home, foreign links in the legacy
dir are preserved, and no new mosaic link is planted in the base install.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-11 20:51:03 -05:00
Jason Woltje 709a23d08c feat(mosaic): mechanically authorize lease promotion 2026-08-11 20:51:03 -05:00
Jason WoltjeandClaude Opus 4.8 239a2a93f1 test(lease): #1124 regression uses node pane command (real field topology per scooby)
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-11 20:51:03 -05:00
Jason WoltjeandClaude Opus 4.8 ea1f058022 fix(lease): resolve lease session id from the claude child, not the tmux pane pid (#1124)
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-11 20:51:03 -05:00
Jason Woltje 1fde450ff1 test(lease): align mutator carve-out acceptance 2026-08-11 20:51:03 -05:00
Jason Woltje c136baa052 fix(mosaic): bound promotion transport delivery 2026-08-11 20:51:03 -05:00
Jason Woltje 4f7f6b3281 feat(mosaic): add correlated lease promotion CLI 2026-08-11 20:51:03 -05:00
Jason Woltje 77edb0dea2 feat(lease): add single-turn Claude promotion trigger 2026-08-11 20:51:03 -05:00
Jason WoltjeandClaude Fable 5 3676180ae8 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-11 20:51:03 -05:00
Jason Woltje 0e938b66ed fix(lease): ignore benign observer idle replies 2026-08-11 20:51:03 -05:00
Jason Woltje f0fef26eb7 fix(lease): constrain read-only tool carve-outs 2026-08-11 20:51:03 -05:00
Jason Woltje c9bccd4aae test(lease): assert pi carve-out capability 2026-08-11 20:51:03 -05:00
Jason Woltje 8ef2e5b91d test(lease): distinguish pi probe timeouts 2026-08-11 20:51:03 -05:00
Jason Woltje 4cab6c09fe test(lease): enforce read-only tool invariant 2026-08-11 20:51:03 -05:00
Jason Woltje 239fc6d03c docs: measure pi tool registry 2026-08-11 20:51:03 -05:00
Jason WoltjeandClaude Fable 5 d085182dc1 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-11 20:51:03 -05:00
Jason WoltjeandClaude Opus 5 e949fa3767 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-11 20:51:03 -05:00
Jason Woltje f1761c91be 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-11 20:51:03 -05:00
Jason Woltje 8109f72cf7 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-11 20:51:03 -05:00
Jason Woltje a0be592d84 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-11 20:51:03 -05:00
Jason Woltje f4a24b693e 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-11 20:51:03 -05:00
Jason Woltje e4dffb7c18 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-11 20:51:03 -05:00
be-coder-08andJason Woltje f840843908 feat(pr-merge): preserve linked authors in squash messages (#1066)
Co-authored-by: be-coder-08 <[email protected]>
2026-08-11 20:51:03 -05:00
be-coder-08andJason Woltje aacb11b0b9 fix(ci): remove upgrade rollback signal race (#1060)
Co-authored-by: be-coder-08 <[email protected]>
2026-08-11 20:51:03 -05:00
be-coder-08andJason Woltje ce6bda18f2 test(ci): make queue guard harness deterministic (#1062)
Co-authored-by: be-coder-08 <[email protected]>
2026-08-11 20:51:03 -05:00
mos-dt-0 aca28405be Merge pull request 'ci: provision Pi runtime 0.84.1 in the test step (Invariant R)' (#1164) from ci/provision-pi-runtime into next
ci/woodpecker/push/publish Pipeline failed
2026-08-12 01:50:56 +00:00
mos-dt-0 c1eb0659c4 Merge pull request 'docs(framework): adopt MOS-STE writing standard onto next (from #965)' (#1165) from adopt/965-mos-ste-writing-standard into next
ci/woodpecker/push/publish Pipeline failed
2026-08-12 01:18:29 +00:00
Jason WoltjeandClaude Fable 5 b79708fdc7 ci: provision Pi runtime 0.84.1 in the test step (Invariant R)
ci/woodpecker/pr/ci Pipeline was successful
invariant_r_unittest.py (landing with the lease-remediation stack, PR
#1109) hard-requires an installed `pi` binary pinned to the measured
version: it boots Pi's real tool registry and proves the broker's
read-only carve-out resolves to real, unshadowed builtins. Absent
runtime fails loud by design — so CI must provide it.

Install @earendil-works/[email protected].1 (the canonical Pi;
@mariozechner/* is embedded-legacy) at step level in the test step.
Step-level rather than baked into Dockerfile.ci because ci-image
publishes are currently blocked on registry UNAUTHORIZED; baking it in
is the follow-up once registry auth is fixed, at which point this line
degrades to a fast no-op guard like the openssl line above it.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-11 20:16:32 -05:00
jason.woltje ebe415132e Merge pull request 'feat(gateway): generic harness catalog + selection HTTP surfaces (P3 Slice Zero, Task 3)' (#1169) from feat/p3-slice0-task3-catalog-selection into next
ci/woodpecker/push/publish Pipeline was canceled
2026-08-12 01:11:21 +00:00
jason.woltjeandClaude Opus 4.8 f16f206a0a feat(gateway): expose generic harness catalog and selection
ci/woodpecker/pr/ci Pipeline failed
Add the Slice-Zero catalog and selection HTTP surfaces for P3 Task 3:
GET /api/harnesses, GET /api/harnesses/:harnessId/catalog,
GET+PUT /api/chat/preferences/selection. Scope is always server-derived
via scopeFromUser(CurrentUser); selection tuples are validated against the
live catalog with no fallback substitution and persisted in a transitional
owner-scoped in-memory store. HarnessModule is wired into AppModule.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-11 19:56:11 -05:00
jason.woltje a186922e3a Merge pull request 'feat(gateway): harness registry, fake adapter, no-substitution suite (P3 Slice Zero, Task 2)' (#1168) from feat/p3-slice0-task2-harness-registry into next
ci/woodpecker/push/publish Pipeline failed
2026-08-12 00:40:03 +00:00
jason.woltje 43513c28f7 feat(gateway): add harness registry and fake adapter
ci/woodpecker/pr/ci Pipeline failed
2026-08-11 19:22:27 -05:00
Jason Woltjeandmos-dt-0 b6c12bdfcb style(framework): apply prettier to WRITING-STYLE.md so CI format passes (#965)
ci/woodpecker/pr/ci Pipeline was successful
The `format` step of .woodpecker/ci.yml:89 (`pnpm format:check`) failed on
pipeline 2111 for this branch. Reproduced on a bench with the lockfile-pinned
[email protected] against the repo .prettierrc and .prettierignore, using CI's
exact glob: WRITING-STYLE.md was the only failing file.

The change is mechanical and semantically null: markdown table cell padding
and `*emphasis*` -> `_emphasis_`. Verified by normalizing both revisions
(whitespace removed, `_`/`*` folded, table rules collapsed) — the results are
byte-identical.

This does not address the prose findings published on #965 (P1-P4); those
await a ruling. The `test` step also failed on 2111, on a base ~40 commits
stale — attribution for that failure needs this rerun, and is not claimed here.

Co-authored-by: mos-dt-0 <[email protected]>
2026-08-11 19:00:46 -05:00
jason.woltje fb9f9cda5a Merge pull request 'fix(gateway): resolve InteractionCoordinationService handoff factory via optional DI token (#1145)' (#1167) from fix/1145-coord-di-compiled-boot into next
ci/woodpecker/push/publish Pipeline failed
2026-08-11 23:57:43 +00:00
jason.woltje 400a21ca18 Merge pull request 'feat(types): generic harness contracts (P3 Slice Zero, Task 1)' (#1166) from feat/p3-slice0-task1-harness-contracts into next
ci/woodpecker/push/publish Pipeline is pending
2026-08-11 23:51:50 +00:00
shaggy (mosaic-dev box) 4cefa5cd88 fix(gateway): resolve InteractionCoordinationService handoff factory via optional DI token (#1145)
ci/woodpecker/pr/ci Pipeline failed
root cause: emitDecoratorMetadata reflected the third constructor parameter as Function and Nest attempted to resolve it

fix: optional HANDOFF_ID_FACTORY injection token, no production provider, preserving undefined -> crypto.randomUUID() default and unchanged positional construction

TDD: real CoordModule red at Function index [2], then green; test overrides only unrelated AuthGuard because its AUTH provider comes from AppModule's global AuthModule context

Closes #1145
2026-08-11 18:37:54 -05:00
shaggy (mosaic-dev box) ddf8616716 feat(types): add generic harness contracts
ci/woodpecker/pr/ci Pipeline was canceled
2026-08-11 18:32:39 -05:00