Compare commits

..
Author SHA1 Message Date
Jason Woltje 018db7e083 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-06 17:48:43 -05:00
Jason Woltje db16de1a81 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-06 17:41:38 -05:00
Jason Woltje 939f2e0496 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-06 17:10:39 -05:00
Jason Woltje 7272cfdc88 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-06 16:57:00 -05:00
Jason Woltje 4a7fc07ee2 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-06 16:56:35 -05:00
be-coder-08andMos 80a45b1e1c feat(pr-merge): preserve linked authors in squash messages (#1066)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-08 <[email protected]>
2026-08-06 05:36:59 +00:00
11 changed files with 1248 additions and 744 deletions
+30 -14
View File
@@ -15,7 +15,7 @@ Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genui
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
4. The BLOCK arm must be observed firing; a normal single-author API payload remains exactly `{ "Do": "squash" }`.
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
## Derived interface decisions
@@ -25,18 +25,19 @@ Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genui
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
## Plan
## Canonical delivery plan
1. Version the currently deployed wrapper byte-for-byte under `infra/fleet/tools/git/pr-merge.sh`.
2. Pre-register verified, null-login BLOCK, unchanged single-author, and hardcoded-squash tests; observe RED before implementation.
3. Implement authenticated commit enumeration, construction-only trailers, message fields on REST, and force REST when trailers are requested.
4. Copy the exact final versioned bytes to the deployed wrapper; verify hashes match.
5. Run focused and baseline checks, static/security review, identity-bound commit, queue guard plus direct Woodpecker terminal enumeration, push, self-post PR, and provider poster read-back. Stop at push/PR; do not merge.
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
## Evidence
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
- GREEN after implementation: verified linked multi-author payload, null-login BLOCK, required named principal, unchanged ordinary `{ "Do": "squash" }` payload, hard non-squash refusal, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
@@ -45,20 +46,20 @@ Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genui
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
3. Focused tests cover both fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, unchanged ordinary payload, and retained log-safe provider diagnostics.
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
4. Codex review rounds 35 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
## Disposable provider fixture still required
## Disposable provider fixture acceptance
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
- Arm A supplies a message-field trailer and classifies the landed squash object as `APPENDS`, `OVERWRITES`, or `REPLACES`; its absence control must also be demonstrated.
- Arm B includes a registered foreign branch trailer and classifies it as `SURVIVES` or `DROPPED`; verify that identity through an existing commit whose `author.login` resolves. Demonstrate an absence control.
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Stop on `OVERWRITES`, `REPLACES`, or any poster-attribution regression.
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
## Fixture preflight
@@ -77,7 +78,22 @@ Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genui
2. `Co-authored-by: be-coder-08 <[email protected]>`
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
## Current hold point
No delivery branch push or PR exists. The deployed wrapper remains on its original baseline bytes. Implementation and disposable end-to-end read-back are green; final review disposition, commit/rebase, and delivery gates remain.
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
## Security review 96 remediation
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
Security remediation:
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
-623
View File
@@ -1,623 +0,0 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--co-author-trailers --escalate-to PRINCIPAL]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
source "$SCRIPT_DIR/detect-platform.sh"
# Default values
PR_NUMBER=""
MERGE_METHOD="squash"
DELETE_BRANCH=false
SKIP_QUEUE_GUARD=false
DRY_RUN=false
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Merge a pull request on the current repository (Gitea or GitHub).
Options:
-n, --number NUMBER PR number to merge (required)
-m, --method METHOD Merge method: squash only (default: squash)
-d, --delete-branch Delete the head branch after merge
--skip-queue-guard Skip CI queue guard wait before merge
--dry-run Run metadata/login preflight without merging
--co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK
-h, --help Show this help message
Examples:
$(basename "$0") -n 42 # Merge PR #42
$(basename "$0") -n 42 -m squash # Squash merge
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
EOF
exit "${1:-1}"
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
PR_NUMBER="$2"
shift 2
;;
-m|--method)
MERGE_METHOD="$2"
shift 2
;;
-d|--delete-branch)
DELETE_BRANCH=true
shift
;;
--skip-queue-guard)
SKIP_QUEUE_GUARD=true
shift
;;
--dry-run)
DRY_RUN=true
SKIP_QUEUE_GUARD=true
shift
;;
--co-author-trailers)
CO_AUTHOR_TRAILERS=true
shift
;;
--escalate-to)
if [[ $# -lt 2 ]]; then
echo "Error: --escalate-to requires one principal name." >&2
exit 1
fi
ESCALATE_TO="$2"
shift 2
;;
-h|--help)
usage 0
;;
*)
echo "Unknown option: $1" >&2
usage
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
echo "Error: PR number is required (-n)" >&2
usage
fi
if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
echo "Error: Invalid PR number '$PR_NUMBER'. PR number must contain digits only." >&2
exit 1
fi
if [[ "$MERGE_METHOD" != "squash" ]]; then
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
exit 1
fi
if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then
echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2
exit 1
fi
if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then
echo "Error: --escalate-to must be one exact principal name." >&2
exit 1
fi
if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then
echo "Error: --escalate-to is valid only with --co-author-trailers." >&2
exit 1
fi
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
exit 1
fi
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
"$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \
-B "$BASE_BRANCH" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
fi
PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
is_known_tea_empty_identity_failure() {
local error_file="$1"
python3 - "$error_file" <<'PY'
import re
import sys
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
error = handle.read()
known_empty_identity = re.search(
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
error,
flags=re.IGNORECASE | re.DOTALL,
)
raise SystemExit(0 if known_empty_identity else 1)
PY
}
write_curl_auth_config() {
local mode="$1" credential="$2"
printf '%s' "$credential" | python3 -c '
import sys
mode = sys.argv[1]
credential = sys.stdin.read()
if not credential or any(char in credential for char in "\r\n"):
raise SystemExit(1)
escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"")
if mode == "token":
print(f"header = \"Authorization: token {escaped}\"")
elif mode == "basic":
print(f"user = \"{escaped}\"")
else:
raise SystemExit(1)
' "$mode"
}
LAST_GITEA_HTTP_CODE="000"
LAST_GITEA_ERROR=""
MERGE_TEMP_DIRS=()
format_gitea_error_response() {
local response_file="$1"
python3 - "$response_file" <<'PY'
import json
import sys
with open(sys.argv[1], "rb") as handle:
raw = handle.read(65536)
try:
response = json.loads(raw.decode("utf-8", errors="replace"))
except (UnicodeDecodeError, json.JSONDecodeError):
message = "non-JSON response omitted"
else:
if isinstance(response, dict):
message = response.get("message") or response.get("error")
if not message and response.get("errors") is not None:
message = json.dumps(response["errors"], separators=(",", ":"))
else:
message = None
if not message:
message = "JSON response contained no error message"
message = str(message)
if len(message) > 500:
message = message[:500] + "..."
print(ascii(message))
PY
}
cleanup_merge_temp_dirs() {
local path
for path in "${MERGE_TEMP_DIRS[@]}"; do
[[ -n "$path" ]] && rm -rf -- "$path"
done
}
trap cleanup_merge_temp_dirs EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
fetch_gitea_pr_head() {
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
local response_file raw_code api_url
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
raw_code=$(write_curl_auth_config "$auth_mode" "$credential" | \
curl -sS -K - -w '%{http_code}' -o "$response_file" \
-H "User-Agent: curl/8" "$api_url" || true)
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file")
rm -f "$response_file"
return 1
fi
if ! python3 - "$response_file" <<'PY'
import json
import re
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
pull = json.load(handle)
head = pull.get("head") if isinstance(pull, dict) else None
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
raise SystemExit(1)
print(sha)
PY
then
echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2
rm -f "$response_file"
return 1
fi
rm -f "$response_file"
}
fetch_gitea_pr_commits() {
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
local page page_file combined_file merged_file raw_code page_count api_url
mkdir -p "$work_root"
combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX")
printf '[]' > "$combined_file"
page=1
while true; do
page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX")
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}"
raw_code=$(write_curl_auth_config "$auth_mode" "$credential" | \
curl -sS -K - -w '%{http_code}' -o "$page_file" \
-H "User-Agent: curl/8" "$api_url" || true)
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file")
rm -f "$page_file" "$combined_file"
return 1
fi
if ! page_count=$(python3 - "$page_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
page = json.load(handle)
if not isinstance(page, list):
raise SystemExit(1)
print(len(page))
PY
); then
echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2
rm -f "$page_file" "$combined_file"
return 1
fi
merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX")
if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
combined = json.load(handle)
with open(sys.argv[2], encoding="utf-8") as handle:
page = json.load(handle)
json.dump(combined + page, sys.stdout, separators=(",", ":"))
PY
then
echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2
rm -f "$page_file" "$combined_file" "$merged_file"
return 1
fi
mv "$merged_file" "$combined_file"
rm -f "$page_file"
if [[ "$page_count" -lt 50 ]]; then
break
fi
page=$((page + 1))
if [[ "$page" -gt 1000 ]]; then
echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2
rm -f "$combined_file"
return 1
fi
done
cat "$combined_file"
rm -f "$combined_file"
}
# LIMITATION: author.login resolution proves the commit address maps to a registered account.
# It does NOT prove the named principal authored the commit — git author metadata is self-asserted.
# This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted.
build_coauthor_message_fields() {
local commits_file="$1" context_file="$2" head_file="$3"
python3 - "$commits_file" "$context_file" "$head_file" <<'PY'
import json
import re
import sys
commits_path, context_path, head_path = sys.argv[1:]
with open(commits_path, encoding="utf-8") as handle:
commits = json.load(handle)
head_sha = open(head_path, encoding="utf-8").read().strip()
context_parts = open(context_path, "rb").read().split(b"\0")
if len(context_parts) != 4 or context_parts[-1] != b"":
raise SystemExit(1)
poster, title, principal = (part.decode("utf-8") for part in context_parts[:3])
if not isinstance(commits, list) or not commits:
print(
f"BLOCK: provider returned no PR commits; author identity is unmeasurable. "
f"Refusing merge; escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not poster:
print(
f"BLOCK: PR poster login is empty; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha):
print(
f"BLOCK: inspected PR head SHA is invalid; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
seen = set()
trailers = []
head_seen = False
for item in commits:
if not isinstance(item, dict):
print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr)
raise SystemExit(75)
sha = str(item.get("sha") or "<unknown>")
if sha == head_sha:
head_seen = True
commit = item.get("commit") if isinstance(item.get("commit"), dict) else {}
commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
email = str(commit_author.get("email") or "").strip()
provider_author = item.get("author") if isinstance(item.get("author"), dict) else {}
login = str(provider_author.get("login") or "").strip()
if not login:
diagnostic_email = email or "<missing>"
print(
f"BLOCK: commit {sha!r} has author.login=NULL while "
f"commit.author.email={diagnostic_email!r}; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not re.fullmatch(r"[A-Za-z0-9_.-]+", login) or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email):
print(
f"BLOCK: commit {sha!r} has unusable linked identity "
f"author.login={login!r}, commit.author.email={email!r}; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if login == poster or login in seen:
continue
seen.add(login)
trailers.append(f"Co-authored-by: {login} <{email}>")
if not head_seen:
print(
f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not trailers:
print(json.dumps({"head_commit_id": head_sha}, separators=(",", ":")))
raise SystemExit(0)
if not title:
print(
f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title):
print(
f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
print(json.dumps({
"head_commit_id": head_sha,
"MergeTitleField": title,
"MergeMessageField": "\n".join(trailers),
}, separators=(",", ":")))
PY
}
merge_gitea_api_attempt() {
local host="$1" auth_mode="$2" credential="$3"
local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc
LAST_GITEA_ERROR=""
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
mkdir -p "$work_root"
attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX")
chmod 0700 "$attempt_dir"
MERGE_TEMP_DIRS+=("$attempt_dir")
body_file=$(mktemp "$attempt_dir/api-response.XXXXXX")
fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX")
payload_file=$(mktemp "$attempt_dir/payload.XXXXXX")
printf '{}' > "$fields_file"
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
commits_file=$(mktemp "$work_root/pr-merge-commits-input.XXXXXX")
context_file=$(mktemp "$work_root/pr-merge-message-context.XXXXXX")
head_file=$(mktemp "$work_root/pr-merge-head-input.XXXXXX")
printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file"
if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
rm -f "$commits_file" "$context_file" "$head_file"
fi
if ! python3 - "$fields_file" > "$payload_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
fields = json.load(handle)
payload = {"Do": "squash"}
payload.update(fields)
if payload.get("Do") != "squash" or set(payload) - {"Do", "head_commit_id", "MergeTitleField", "MergeMessageField"}:
raise SystemExit(1)
print(json.dumps(payload, separators=(",", ":")))
PY
then
rm -f "$body_file" "$fields_file" "$payload_file"
return 1
fi
rm -f "$fields_file"
raw_code=$(write_curl_auth_config "$auth_mode" "$credential" | \
curl -sS -K - -w '%{http_code}' -o "$body_file" \
-X POST -H "User-Agent: curl/8" \
-H 'Content-Type: application/json' \
--data-binary "@$payload_file" "$api_url" || true)
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file")
fi
rm -f "$body_file" "$payload_file"
rm -rf -- "$attempt_dir"
[[ "$raw_code" =~ ^2 ]]
}
merge_gitea_with_api() {
local host="$1" token basic_auth attempt_rc
token=$(get_gitea_token "$host" || true)
if [[ -n "$token" ]]; then
if merge_gitea_api_attempt "$host" token "$token"; then
return 0
else
attempt_rc=$?
fi
if [[ "$attempt_rc" -eq 75 ]]; then
return 75
fi
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
echo "Error: Gitea API merge failed with token credential (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR} Basic Auth fallback is allowed only after HTTP 401." >&2
return 1
fi
echo "Token credential received HTTP 401; retrying inspection and merge with configured Basic Auth." >&2
fi
basic_auth=$(get_gitea_basic_auth "$host" || true)
if [[ -n "$basic_auth" ]]; then
if merge_gitea_api_attempt "$host" basic "$basic_auth"; then
return 0
else
attempt_rc=$?
fi
if [[ "$attempt_rc" -eq 75 ]]; then
return 75
fi
fi
if [[ -z "$token" && -z "$basic_auth" ]]; then
echo "Error: No Gitea credential is available for the merge operation." >&2
else
echo "Error: Gitea API merge failed for all configured credentials (last HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
fi
return 1
}
if [[ "$DRY_RUN" == true ]]; then
if [[ "$PLATFORM" == "gitea" ]]; then
HOST=$(get_remote_host) || {
echo "Error: Cannot determine host from origin remote URL" >&2
exit 1
}
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
elif [[ -n "$TEA_LOGIN" ]]; then
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
else
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)."
fi
else
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
fi
exit 0
fi
case "$PLATFORM" in
github)
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
exit 1
fi
cmd=(gh pr merge "$PR_NUMBER" --squash)
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
"${cmd[@]}"
;;
gitea)
HOST=$(get_remote_host) || {
echo "Error: Cannot determine host from origin remote URL" >&2
exit 1
}
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
merge_gitea_with_api "$HOST"
elif [[ -n "$TEA_LOGIN" ]]; then
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
rm -f "$TEA_ERROR_FILE"
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
cat "$TEA_ERROR_FILE" >&2
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
rm -f "$TEA_ERROR_FILE"
merge_gitea_with_api "$HOST"
else
cat "$TEA_ERROR_FILE" >&2
rm -f "$TEA_ERROR_FILE"
exit 1
fi
else
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
merge_gitea_with_api "$HOST"
fi
# Delete branch after merge if requested
if [[ "$DELETE_BRANCH" == true ]]; then
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
fi
;;
*)
echo "Error: Could not detect git platform" >&2
exit 1
;;
esac
echo "PR #$PR_NUMBER merged successfully"
@@ -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"
+469 -59
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
set -euo pipefail
@@ -14,6 +14,8 @@ MERGE_METHOD="squash"
DELETE_BRANCH=false
DRY_RUN=false
EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
usage() {
cat <<EOF
@@ -27,12 +29,16 @@ Options:
-d, --delete-branch Delete the head branch after merge
--dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
--co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK
-h, --help Show this help message
Examples:
$(basename "$0") -n 42 # Merge PR #42
$(basename "$0") -n 42 -m squash # Squash merge
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
EOF
exit "${1:-1}"
}
@@ -57,9 +63,25 @@ while [[ $# -gt 0 ]]; do
shift
;;
--expect-head)
if [[ $# -lt 2 ]]; then
echo "Error: --expect-head requires one full commit SHA." >&2
exit 1
fi
EXPECT_HEAD="$2"
shift 2
;;
--co-author-trailers)
CO_AUTHOR_TRAILERS=true
shift
;;
--escalate-to)
if [[ $# -lt 2 ]]; then
echo "Error: --escalate-to requires one principal name." >&2
exit 1
fi
ESCALATE_TO="$2"
shift 2
;;
-h|--help)
usage 0
;;
@@ -88,17 +110,30 @@ if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then
echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2
exit 1
fi
if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then
echo "Error: --escalate-to must be one exact principal name." >&2
exit 1
fi
if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then
echo "Error: --escalate-to is valid only with --co-author-trailers." >&2
exit 1
fi
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
exit 1
fi
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
exit 1
@@ -122,70 +157,442 @@ PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
merge_gitea_with_api() {
local host="$1" api_url token basic_auth body_file raw_code payload
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
write_curl_auth_config() {
local mode="$1" credential="$2"
printf '%s' "$credential" | python3 -c '
import sys
mode = sys.argv[1]
credential = sys.stdin.read()
if not credential or any(char in credential for char in "\r\n"):
raise SystemExit(1)
escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"")
if mode == "token":
print(f"header = \"Authorization: token {escaped}\"")
elif mode == "basic":
print(f"user = \"{escaped}\"")
else:
raise SystemExit(1)
' "$mode"
}
LAST_GITEA_HTTP_CODE="000"
LAST_GITEA_ERROR=""
MERGE_TEMP_DIRS=()
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do
if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2
exit 1
fi
done
GITEA_CURL_BOUNDS=(
--max-filesize "$GITEA_CURL_MAX_BYTES"
--max-time "$GITEA_CURL_MAX_TIME"
--connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT"
)
format_gitea_error_response() {
local response_file="$1"
python3 - "$response_file" <<'PY'
import json
import sys
head_sha, delete_branch = sys.argv[1:]
with open(sys.argv[1], "rb") as handle:
raw = handle.read(65536)
try:
response = json.loads(raw.decode("utf-8", errors="replace"))
except (UnicodeDecodeError, json.JSONDecodeError):
message = "non-JSON response omitted"
else:
if isinstance(response, dict):
message = response.get("message") or response.get("error")
if not message and response.get("errors") is not None:
message = json.dumps(response["errors"], separators=(",", ":"))
else:
message = None
if not message:
message = "JSON response contained no error message"
message = str(message)
if len(message) > 500:
message = message[:500] + "..."
print(ascii(message))
PY
}
cleanup_merge_temp_dirs() {
local path
for path in "${MERGE_TEMP_DIRS[@]}"; do
[[ -n "$path" ]] && rm -rf -- "$path"
done
}
trap cleanup_merge_temp_dirs EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
fetch_gitea_pr_head() {
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
local response_file raw_code api_url auth_config curl_rc
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
rm -f "$response_file"
return 1
fi
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
curl_rc=$?
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ "$curl_rc" -ne 0 ]]; then
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
rm -f "$response_file"
return 1
fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file")
rm -f "$response_file"
return 1
fi
if ! python3 - "$response_file" <<'PY'
import json
import re
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
pull = json.load(handle)
head = pull.get("head") if isinstance(pull, dict) else None
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
raise SystemExit(1)
print(sha)
PY
then
echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2
rm -f "$response_file"
return 1
fi
rm -f "$response_file"
}
fetch_gitea_pr_commits() {
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc
mkdir -p "$work_root"
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
return 1
fi
combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX")
printf '[]' > "$combined_file"
page=1
while true; do
page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX")
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}"
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
curl_rc=$?
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ "$curl_rc" -ne 0 ]]; then
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
rm -f "$page_file" "$combined_file"
return 1
fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file")
rm -f "$page_file" "$combined_file"
return 1
fi
if ! page_count=$(python3 - "$page_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
page = json.load(handle)
if not isinstance(page, list):
raise SystemExit(1)
print(len(page))
PY
); then
echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2
rm -f "$page_file" "$combined_file"
return 1
fi
merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX")
if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
combined = json.load(handle)
with open(sys.argv[2], encoding="utf-8") as handle:
page = json.load(handle)
json.dump(combined + page, sys.stdout, separators=(",", ":"))
PY
then
echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2
rm -f "$page_file" "$combined_file" "$merged_file"
return 1
fi
mv "$merged_file" "$combined_file"
rm -f "$page_file"
if [[ "$page_count" -lt 50 ]]; then
break
fi
page=$((page + 1))
if [[ "$page" -gt 1000 ]]; then
echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2
rm -f "$combined_file"
return 1
fi
done
cat "$combined_file"
rm -f "$combined_file"
}
# LIMITATION: author.login resolution proves the commit address maps to a registered account.
# It does NOT prove the named principal authored the commit — git author metadata is self-asserted.
# This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted.
build_coauthor_message_fields() {
local commits_file="$1" context_file="$2" head_file="$3"
python3 - "$commits_file" "$context_file" "$head_file" <<'PY'
import json
import re
import sys
commits_path, context_path, head_path = sys.argv[1:]
with open(commits_path, encoding="utf-8") as handle:
commits = json.load(handle)
head_sha = open(head_path, encoding="utf-8").read().strip()
context_parts = open(context_path, "rb").read().split(b"\0")
if len(context_parts) != 4 or context_parts[-1] != b"":
raise SystemExit(1)
poster, title, principal = (part.decode("utf-8") for part in context_parts[:3])
if not isinstance(commits, list) or not commits:
print(
f"BLOCK: provider returned no PR commits; author identity is unmeasurable. "
f"Refusing merge; escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not poster:
print(
f"BLOCK: PR poster login is empty; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha):
print(
f"BLOCK: inspected PR head SHA is invalid; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
seen = set()
trailers = []
head_seen = False
for item in commits:
if not isinstance(item, dict):
print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr)
raise SystemExit(75)
sha = str(item.get("sha") or "<unknown>")
if sha == head_sha:
head_seen = True
commit = item.get("commit") if isinstance(item.get("commit"), dict) else {}
commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
email = str(commit_author.get("email") or "").strip()
provider_author = item.get("author") if isinstance(item.get("author"), dict) else {}
login = str(provider_author.get("login") or "").strip()
if not login:
diagnostic_email = email or "<missing>"
print(
f"BLOCK: commit {sha!r} has author.login=NULL while "
f"commit.author.email={diagnostic_email!r}; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if (
not email.isascii()
or not email.isprintable()
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email)
):
print(
f"BLOCK: commit {sha!r} has unusable linked identity "
f"author.login={login!r}, commit.author.email={email!r}; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if login == poster or login in seen:
continue
seen.add(login)
trailers.append(f"Co-authored-by: {login} <{email}>")
if not head_seen:
print(
f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not trailers:
print("{}")
raise SystemExit(0)
if not title:
print(
f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title):
print(
f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
print(json.dumps({
"MergeTitleField": title,
"MergeMessageField": "\n".join(trailers),
}, separators=(",", ":")))
PY
}
merge_gitea_api_attempt() {
local host="$1" auth_mode="$2" credential="$3"
local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc
LAST_GITEA_HTTP_CODE="000"
LAST_GITEA_ERROR=""
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
mkdir -p "$work_root"
attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX")
chmod 0700 "$attempt_dir"
MERGE_TEMP_DIRS+=("$attempt_dir")
body_file=$(mktemp "$attempt_dir/api-response.XXXXXX")
fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX")
payload_file=$(mktemp "$attempt_dir/payload.XXXXXX")
printf '{}' > "$fields_file"
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
commits_file=$(mktemp "$attempt_dir/pr-merge-commits-input.XXXXXX")
context_file=$(mktemp "$attempt_dir/pr-merge-message-context.XXXXXX")
head_file=$(mktemp "$attempt_dir/pr-merge-head-input.XXXXXX")
printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file"
if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
if [[ "$(<"$head_file")" != "$HEAD_SHA" ]]; then
echo "BLOCK: authenticated PR head moved from reviewed $HEAD_SHA to $(<"$head_file"); refusing merge; escalate to named principal '$ESCALATE_TO'." >&2
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return 75
fi
if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
rm -f "$commits_file" "$context_file" "$head_file"
fi
if ! python3 - "$fields_file" "$HEAD_SHA" "$DELETE_BRANCH" > "$payload_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
fields = json.load(handle)
head_sha, delete_branch = sys.argv[2:]
payload = {"Do": "squash", "head_commit_id": head_sha}
if delete_branch == "true":
payload["delete_branch_after_merge"] = True
payload.update(fields)
allowed = {"Do", "head_commit_id", "delete_branch_after_merge", "MergeTitleField", "MergeMessageField"}
if payload.get("Do") != "squash" or set(payload) - allowed:
raise SystemExit(1)
print(json.dumps(payload, separators=(",", ":")))
PY
)
token=$(get_gitea_token "$host" || true)
if [[ -n "$token" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
-X POST \
-H "User-Agent: curl/8" \
-H "Authorization: token $token" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then
rm -f "$body_file"
return 0
fi
then
rm -f "$body_file" "$fields_file" "$payload_file"
return 1
fi
rm -f "$fields_file"
basic_auth=$(get_gitea_basic_auth "$host" || true)
if [[ -n "$basic_auth" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
-X POST \
-u "$basic_auth" \
-H "User-Agent: curl/8" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then
rm -f "$body_file"
return 0
fi
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
rm -f "$body_file" "$payload_file"
return 1
fi
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \
-X POST -H "User-Agent: curl/8" \
-H 'Content-Type: application/json' \
--data-binary "@$payload_file" "$api_url" <<<"$auth_config")
curl_rc=$?
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ "$curl_rc" -ne 0 ]]; then
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
rm -f "$body_file" "$payload_file"
rm -rf -- "$attempt_dir"
return 1
fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file")
fi
rm -f "$body_file" "$payload_file"
rm -rf -- "$attempt_dir"
[[ "$raw_code" =~ ^2 ]]
}
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
import json
import sys
code, path = sys.argv[1], sys.argv[2]
try:
with open(path, encoding="utf-8", errors="replace") as handle:
raw = handle.read(500)
data = json.loads(raw) if raw else {}
message = data.get("message") or data.get("error") or raw or "empty response"
except Exception:
try:
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
except Exception:
message = "unreadable response"
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
PY
rm -f "$body_file"
merge_gitea_with_api() {
local host="$1" token attempt_rc
if ! token=$(get_gitea_token "$host"); then
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
return 1
fi
if [[ -z "$token" ]]; then
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
return 1
fi
if merge_gitea_api_attempt "$host" token "$token"; then
return 0
else
attempt_rc=$?
fi
if [[ "$attempt_rc" -eq 75 ]]; then
return 75
fi
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
return 1
fi
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
return 1
}
@@ -195,11 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
echo "Error: Cannot determine host from origin remote URL" >&2
exit 1
}
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
if [[ -n "$TEA_LOGIN" ]]; then
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
else
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)."
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
fi
else
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
@@ -209,6 +615,10 @@ fi
case "$PLATFORM" in
github)
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
exit 1
fi
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
"${cmd[@]}"
@@ -219,7 +629,7 @@ case "$PLATFORM" in
exit 1
}
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
# tea cannot express it, so exact-head merges use the authenticated API path.
# tea cannot express it, so every Gitea merge uses the authenticated API path.
merge_gitea_with_api "$HOST"
;;
*)
@@ -51,22 +51,23 @@ for arg in "$@"; do
prev=""
continue
fi
if [[ "$prev" == "-d" ]]; then
if [[ "$prev" == "data" ]]; then
post_data="$arg"
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
prev=""
continue
fi
if [[ "$arg" == "-o" ]]; then
prev="-o"
if [[ "$prev" == "config" ]]; then
[[ "$arg" == "-" ]] && cat >/dev/null
prev=""
continue
fi
if [[ "$arg" == "-d" ]]; then
prev="-d"
continue
fi
if [[ "$arg" == "-w" ]]; then
write_code=true
fi
case "$arg" in
-o) prev="-o" ;;
-d|--data|--data-binary) prev="data" ;;
-K|--config) prev="config" ;;
-w) write_code=true ;;
esac
done
emit_response() {
local body="$1"
@@ -36,13 +36,30 @@ cat > "$WORK_DIR/gitea/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
payload=""
for ((i=1; i<=$#; i++)); do
if [[ "${!i}" == "-d" ]]; then
j=$((i + 1))
payload="${!j}"
fi
out_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--data|--data-binary)
payload="$2"
[[ "$payload" == @* ]] && payload=$(<"${payload#@}")
shift 2
;;
-o)
out_file="$2"
shift 2
;;
-K|--config)
[[ "$2" == "-" ]] && cat >/dev/null
shift 2
;;
-w|-X|-H)
shift 2
;;
*) shift ;;
esac
done
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
[[ -n "$out_file" ]] && printf '{}' > "$out_file"
printf '200'
SH
chmod +x "$WORK_DIR/gitea/curl"
@@ -32,13 +32,16 @@ get_repo_name() { printf 'widgets\n'; }
get_remote_host() { printf 'git.example.test\n'; }
get_gitea_token() {
printf 'resolved\n' >> "${MOSAIC_TEST_TOKEN_RESOLUTION_LOG:?}"
if [[ "${MOSAIC_TEST_TOKEN_AVAILABLE:-true}" != "true" ]]; then
return 1
fi
printf 'fixture-token\n'
}
get_gitea_basic_auth() {
printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}"
if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then
printf 'fixture-user:fixture-password\n'
return 0
return "${MOSAIC_TEST_BASIC_RC:-0}"
fi
return 1
}
@@ -52,7 +55,13 @@ if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then
else
title='Preserve both branch authors'
fi
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main"}\n' "$title"
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified) head_sha=2222222222222222222222222222222222222222 ;;
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
single) head_sha=1111111111111111111111111111111111111111 ;;
*) echo "unknown commits mode" >&2; exit 2 ;;
esac
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
SH
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
@@ -90,6 +99,9 @@ out_file=""
data=""
config=""
auth_mode="none"
has_max_filesize=0
has_max_time=0
has_connect_timeout=0
while [[ $# -gt 0 ]]; do
case "$1" in
-o)
@@ -116,6 +128,18 @@ while [[ $# -gt 0 ]]; do
fi
shift 2
;;
--max-filesize)
has_max_filesize=1
shift 2
;;
--max-time)
has_max_time=1
shift 2
;;
--connect-timeout)
has_connect_timeout=1
shift 2
;;
-H|--header|-u|--user)
if [[ "$2" == *"fixture-token"* ]]; then
: > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}"
@@ -143,6 +167,7 @@ elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then
: > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}"
fi
printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}"
printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}"
case "$url" in
*/pulls/42)
@@ -152,6 +177,9 @@ case "$url" in
single) head_sha=1111111111111111111111111111111111111111 ;;
*) echo "unknown commits mode" >&2; exit 2 ;;
esac
if [[ "${MOSAIC_TEST_HEAD_MODE:-stable}" == "moved" ]]; then
head_sha=4444444444444444444444444444444444444444
fi
body="{\"head\":{\"sha\":\"$head_sha\"}}"
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
@@ -162,7 +190,11 @@ case "$url" in
*/pulls/42/commits*)
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified)
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
else
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
fi
;;
null-login)
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"[email protected]\n\u001b[31m"}},"author":null}]'
@@ -216,6 +248,10 @@ else
printf '%s' "$body"
fi
printf '%s' "$code"
case "${MOSAIC_TEST_CURL_FAILURE:-none}" in
oversize) exit 63 ;;
stalled) exit 28 ;;
esac
SH
chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \
@@ -228,6 +264,7 @@ run_case() {
shift 2
MOSAIC_TEST_COMMITS_MODE="$mode" \
MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \
MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \
MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \
MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \
MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \
@@ -273,40 +310,98 @@ fi
if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then
fail "verified path performed a forbidden second /users lookup"
fi
if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then
fail "verified path did not apply size/max-time/connect-time bounds to every provider download"
fi
# Token rejection during inspection must fall back to Basic Auth, then repeat
# BOTH inspection and merge with that one Basic credential handle.
# A linked email containing a terminal escape must block before mutation.
escape_email_dir=$(make_case escape-email)
set +e
escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
escape_email_rc=$?
set -e
[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed"
[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic"
[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API"
# Curl transfer and duration failures must remain failures even with HTTP 200.
for failure_mode in oversize stalled; do
failure_dir=$(make_case "curl-$failure_mode")
set +e
failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
failure_rc=$?
set -e
[[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output"
[[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API"
done
# The authenticated head is re-read under the mutation credential but cannot
# replace the canonical preflight/review head. A move blocks before enumeration
# or mutation even though the provider returned a valid new SHA.
moved_dir=$(make_case moved-head)
set +e
moved_output=$(MOSAIC_TEST_HEAD_MODE=moved \
run_case "$moved_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
moved_rc=$?
set -e
[[ "$moved_rc" -ne 0 ]] || fail "moved authenticated head unexpectedly passed"
[[ "$moved_output" == *"authenticated PR head moved from reviewed"* ]] || fail "moved head refusal lost its diagnostic"
[[ "$moved_output" == *"tl-mosaic"* ]] || fail "moved head refusal omitted the named escalation principal"
[[ ! -e "$moved_dir/merge-payload.json" ]] || fail "moved head refusal reached the merge API"
moved_sequence=$(awk '{print $1 ":" $2}' "$moved_dir/curl.log" | paste -sd, -)
[[ "$moved_sequence" == "GET:token" ]] || fail "moved head refusal performed post-move inspection/mutation (calls=$moved_sequence)"
# Token resolution failure is not an authentication response. It must fail
# closed instead of borrowing a Basic credential under a different principal.
token_missing_dir=$(make_case token-missing)
set +e
token_missing_output=$(MOSAIC_TEST_TOKEN_AVAILABLE=false MOSAIC_TEST_BASIC_AVAILABLE=true \
run_case "$token_missing_dir" single 2>&1)
token_missing_rc=$?
set -e
[[ "$token_missing_rc" -ne 0 ]] || fail "missing token unexpectedly borrowed Basic Auth"
[[ "$token_missing_output" == *"required Gitea token"* ]] || fail "missing token refusal lost its diagnostic"
[[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure"
[[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request"
# A failed Basic resolver must never use its nonempty output or reach mutation.
basic_rc_dir=$(make_case basic-resolver-rc)
set +e
basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \
run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
basic_rc_rc=$?
set -e
[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output"
[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API"
# HTTP 401 never changes principals: inspection rejection fails closed without
# resolving or attempting Basic Auth.
fallback_inspect_dir=$(make_case fallback-inspection)
set +e
fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \
run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
fallback_inspect_rc=$?
set -e
[[ "$fallback_inspect_rc" -eq 0 ]] || fail "inspection fallback expected rc=0, got rc=$fallback_inspect_rc: $fallback_inspect_output"
[[ -s "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection fallback did not reach the merge API"
[[ -e "$fallback_inspect_dir/basic-via-config" ]] || fail "inspection fallback did not transport Basic Auth through stdin config"
[[ ! -e "$fallback_inspect_dir/basic-in-argv" ]] || fail "inspection fallback exposed Basic Auth in curl argv"
[[ ! -e "$fallback_inspect_dir/metadata-in-argv" ]] || fail "inspection fallback exposed PR metadata in child argv"
[[ "$(wc -l < "$fallback_inspect_dir/token-resolution.log")" -eq 1 ]] || fail "inspection fallback did not resolve token exactly once"
[[ "$(wc -l < "$fallback_inspect_dir/basic-resolution.log")" -eq 1 ]] || fail "inspection fallback did not resolve Basic Auth exactly once"
[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals"
[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic"
[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth"
[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation"
inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -)
[[ "$inspect_sequence" == "GET:token,GET:basic,GET:basic,POST:basic" ]] || fail "inspection fallback was not bound per credential (calls=$inspect_sequence)"
[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)"
# Token rejection at merge is a separate seam: Basic fallback must re-inspect
# instead of reusing evidence gathered under the rejected token.
# Token rejection at merge likewise fails closed without cross-principal retry.
fallback_merge_dir=$(make_case fallback-merge)
set +e
fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \
run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
fallback_merge_rc=$?
set -e
[[ "$fallback_merge_rc" -eq 0 ]] || fail "merge fallback expected rc=0, got rc=$fallback_merge_rc: $fallback_merge_output"
[[ -s "$fallback_merge_dir/merge-payload.json" ]] || fail "merge fallback did not reach a successful merge API payload"
[[ -e "$fallback_merge_dir/basic-via-config" ]] || fail "merge fallback did not transport Basic Auth through stdin config"
[[ ! -e "$fallback_merge_dir/basic-in-argv" ]] || fail "merge fallback exposed Basic Auth in curl argv"
[[ ! -e "$fallback_merge_dir/metadata-in-argv" ]] || fail "merge fallback exposed PR metadata in child argv"
[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals"
[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic"
[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth"
[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload"
merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -)
[[ "$merge_sequence" == "GET:token,GET:token,POST:token,GET:basic,GET:basic,POST:basic" ]] || fail "merge fallback reused cross-credential evidence (calls=$merge_sequence)"
[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)"
# BLOCK path: a commit email exists but author.login is null. It must name both
# facts, name the escalation principal, and never reach the merge endpoint.
@@ -364,6 +459,7 @@ set -e
[[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded"
[[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters"
[[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape"
[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback"
[[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback"
# Authorization denials likewise fail closed instead of changing principals.
@@ -375,6 +471,7 @@ forbidden_rc=$?
set -e
[[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed"
[[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status"
[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback"
[[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback"
# The BLOCK destination cannot be generic or inferred after failure: opting in
@@ -416,7 +513,10 @@ else
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload == {"Do": "squash"}, payload
assert payload == {
"Do": "squash",
"head_commit_id": "1111111111111111111111111111111111111111",
}, payload
PY
fi
[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config"
@@ -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,269 @@
#!/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, 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 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.
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 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 = 300
# Normative sources whose exact bytes bind the lease. Sources absent on a given
# deployment are simply not part of the binding — never fabricated.
FRAGMENT_SOURCES: Final = (
"CONSTITUTION.md",
"AGENTS.md",
"SOUL.md",
"USER.md",
"STANDARDS.md",
"TOOLS.md",
)
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."""
sources = list(FRAGMENT_SOURCES) + [f"runtime/{runtime}/RUNTIME.md"]
wire_fragments: list[dict[str, str]] = []
objects: list[NormativeFragment] = []
for source_id in sources:
try:
content = (mosaic_home() / source_id).read_bytes()
except OSError:
continue
import hashlib
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 RuntimeError("no normative sources found — refusing to build an empty binding")
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())
+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 && 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-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"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/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:*",
+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',
},