Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 03:44:54 +00:00
116 changed files with 8619 additions and 9495 deletions
+104 -5
View File
@@ -1,5 +1,5 @@
# Build, publish npm packages, and push Docker images
# Runs only on main branch push/tag
# Runs on main for stable publishes and on next for integration-line prereleases/images
variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
@@ -23,9 +23,21 @@ variables:
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main]
- branch: [main, next]
event: [push, manual, tag]
steps:
@@ -103,6 +115,84 @@ steps:
depends_on:
- build
publish-next-npm:
image: *node_image
# Durable @next integration-line publish. Runs only on next; never writes
# the latest dist-tag and never commits the computed prerelease versions.
when:
- event: [push, manual]
branch: next
environment:
NPM_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
commands:
- *enable_pnpm
- |
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
exit 1
fi
if [ -z "$CI_PIPELINE_NUMBER" ]; then
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
exit 1
fi
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const roots = ['apps', 'packages', 'plugins'];
const updated = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) updatePackage(packagePath);
walk(fullPath);
}
}
}
function updatePackage(packagePath) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
}
const [, major, minor, patch] = stableMatch;
const oldVersion = manifest.version;
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
}
for (const root of roots) walk(root);
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
for (const line of updated) console.log('[publish-next] ' + line);
NODE
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
exit 1
fi
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
depends_on:
- build
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
# image: *node_image
@@ -134,8 +224,17 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next gateway publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
@@ -146,7 +245,7 @@ steps:
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
@@ -172,7 +271,7 @@ steps:
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
+13 -1
View File
@@ -30,6 +30,16 @@ This installs both components:
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
After install, the wizard runs automatically or you can invoke it manually:
```bash
@@ -361,7 +371,9 @@ The CLI also performs a background update check on every invocation (cached for
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --next # Prerelease lane: source build from next
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
```
@@ -0,0 +1,519 @@
/**
* Federation M3 single-gateway integration tests (FED-M3-10).
*
* Covers MILESTONES.md M3 acceptance:
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
* - #7: max_rows_per_query caps list results.
*
* Strategy:
* - Real PostgreSQL via @mosaicstack/db.
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
*
* Run:
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
* src/__tests__/integration/federation-m3-list.integration.test.ts
*/
import 'reflect-metadata';
import * as crypto from 'node:crypto';
import type { ExecutionContext } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { FastifyReply, FastifyRequest } from 'fastify';
import {
and,
createDb,
eq,
federationGrants,
federationPeers,
inArray,
missionTasks,
missions,
projects,
tasks,
teamMembers,
teams,
type Db,
type DbHandle,
users,
} from '@mosaicstack/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
import { FederationScopeService } from '../../federation/server/scope.service.js';
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
import { ListController } from '../../federation/server/verbs/list.controller.js';
import {
makeMosaicIssuedCert,
makeSelfSignedCert,
} from '../../federation/__tests__/helpers/test-cert.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
interface TestIds {
readonly subjectUserId: string;
readonly otherUserId: string;
readonly peerId: string;
readonly revokedPeerId: string;
readonly activeGrantId: string;
readonly revokedGrantId: string;
readonly subjectProjectId: string;
readonly subjectMissionId: string;
readonly otherProjectId: string;
readonly teamId: string;
readonly unauthorizedTeamId: string;
readonly teamProjectId: string;
readonly taskIds: readonly string[];
readonly excludedTaskIds: readonly string[];
readonly subjectNoteId: string;
readonly otherUserNoteId: string;
}
function pemToDer(pem: string): Buffer {
return Buffer.from(
pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, ''),
'base64',
);
}
function makeFederationRequest(certPem: string): FastifyRequest {
return {
raw: {
socket: {
getPeerCertificate: () => ({
raw: pemToDer(certPem),
serialNumber: CERT_SERIAL_HEX,
}),
},
},
} as unknown as FastifyRequest;
}
function makeGuardContext(request: FastifyRequest): {
readonly context: ExecutionContext;
readonly sent: { statusCode?: number; payload?: unknown };
} {
const sent: { statusCode?: number; payload?: unknown } = {};
const reply = {
status: (statusCode: number) => {
sent.statusCode = statusCode;
return {
header: () => ({
send: (payload: unknown) => {
sent.payload = payload;
},
}),
};
},
} as unknown as FastifyReply;
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => reply,
}),
} as unknown as ExecutionContext;
return { context, sent };
}
async function insertUser(db: Db, id: string, label: string): Promise<void> {
await db.insert(users).values({
id,
name: `${RUN_ID}-${label}`,
email: `${RUN_ID}-${label}@federation-test.invalid`,
emailVerified: false,
});
}
async function seedFixtures(db: Db): Promise<TestIds> {
const subjectUserId = `${RUN_ID}-subject`;
const otherUserId = `${RUN_ID}-other`;
const peerId = crypto.randomUUID();
const revokedPeerId = crypto.randomUUID();
const activeGrantId = crypto.randomUUID();
const revokedGrantId = crypto.randomUUID();
const subjectProjectId = crypto.randomUUID();
const subjectMissionId = crypto.randomUUID();
const otherProjectId = crypto.randomUUID();
const teamId = crypto.randomUUID();
const unauthorizedTeamId = crypto.randomUUID();
const teamProjectId = crypto.randomUUID();
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
const subjectNoteId = crypto.randomUUID();
const otherUserNoteId = crypto.randomUUID();
await insertUser(db, subjectUserId, 'subject');
await insertUser(db, otherUserId, 'other');
await db.insert(teams).values([
{
id: teamId,
name: `${RUN_ID} allowed team`,
slug: `${RUN_ID}-allowed-team`,
ownerId: subjectUserId,
managerId: subjectUserId,
},
{
id: unauthorizedTeamId,
name: `${RUN_ID} unauthorized team`,
slug: `${RUN_ID}-unauthorized-team`,
ownerId: otherUserId,
managerId: otherUserId,
},
]);
await db.insert(teamMembers).values([
{ teamId, userId: subjectUserId, role: 'member' },
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
]);
await db.insert(projects).values([
{
id: subjectProjectId,
name: `${RUN_ID} subject personal project`,
ownerType: 'user',
ownerId: subjectUserId,
},
{
id: otherProjectId,
name: `${RUN_ID} other personal project`,
ownerType: 'user',
ownerId: otherUserId,
},
{
id: teamProjectId,
name: `${RUN_ID} unauthorized team project`,
ownerType: 'team',
teamId: unauthorizedTeamId,
},
]);
await db.insert(missions).values({
id: subjectMissionId,
name: `${RUN_ID} subject mission`,
projectId: subjectProjectId,
userId: subjectUserId,
});
await db.insert(tasks).values([
{
id: taskIds[0],
title: `${RUN_ID} visible task 1`,
missionId: subjectMissionId,
createdAt: new Date('2026-06-25T03:00:00.000Z'),
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
},
{
id: taskIds[1],
title: `${RUN_ID} visible task 2`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T02:00:00.000Z'),
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
},
{
id: taskIds[2],
title: `${RUN_ID} visible task 3`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T01:00:00.000Z'),
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
},
{
id: excludedTaskIds[0],
title: `${RUN_ID} other user task`,
projectId: otherProjectId,
createdAt: new Date('2026-06-25T04:00:00.000Z'),
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
},
{
id: excludedTaskIds[1],
title: `${RUN_ID} unauthorized team task`,
projectId: teamProjectId,
createdAt: new Date('2026-06-25T05:00:00.000Z'),
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
},
]);
await db.insert(missionTasks).values([
{
id: subjectNoteId,
missionId: subjectMissionId,
userId: subjectUserId,
notes: `${RUN_ID} subject visible note`,
createdAt: new Date('2026-06-25T03:30:00.000Z'),
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
},
{
id: otherUserNoteId,
missionId: subjectMissionId,
userId: otherUserId,
notes: `${RUN_ID} other user note on subject mission`,
createdAt: new Date('2026-06-25T04:30:00.000Z'),
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
},
]);
await db.insert(federationPeers).values([
{
id: peerId,
commonName: `${RUN_ID}-active-peer`,
displayName: `${RUN_ID} Active Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: CERT_SERIAL_HEX,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
{
id: revokedPeerId,
commonName: `${RUN_ID}-revoked-peer`,
displayName: `${RUN_ID} Revoked Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
]);
await db.insert(federationGrants).values([
{
id: activeGrantId,
peerId,
subjectUserId,
status: 'active',
scope: {
resources: ['tasks', 'notes'],
excluded_resources: [],
filters: {
tasks: { include_personal: true, include_teams: [] },
notes: { include_personal: true, include_teams: [] },
},
max_rows_per_query: 2,
},
},
{
id: revokedGrantId,
peerId,
subjectUserId,
status: 'revoked',
revokedAt: new Date(),
revokedReason: `${RUN_ID} revoked grant fixture`,
scope: {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 2,
},
},
]);
return {
subjectUserId,
otherUserId,
peerId,
revokedPeerId,
activeGrantId,
revokedGrantId,
subjectProjectId,
subjectMissionId,
otherProjectId,
teamId,
unauthorizedTeamId,
teamProjectId,
taskIds,
excludedTaskIds,
subjectNoteId,
otherUserNoteId,
};
}
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
if (!ids) {
return;
}
await db
.delete(missionTasks)
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
.catch(() => {});
await db
.delete(tasks)
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
.catch(() => {});
await db
.delete(missions)
.where(eq(missions.id, ids.subjectMissionId))
.catch(() => {});
await db
.delete(projects)
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
.catch(() => {});
await db
.delete(teamMembers)
.where(
and(
eq(teamMembers.userId, ids.subjectUserId),
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
),
)
.catch(() => {});
await db
.delete(teams)
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
.catch(() => {});
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
.catch(() => {});
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
.catch(() => {});
await db
.delete(users)
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
.catch(() => {});
}
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
let handle: DbHandle;
let db: Db;
let moduleRef: TestingModule;
let guard: FederationAuthGuard;
let listController: ListController;
let ids: TestIds | undefined;
beforeAll(async () => {
handle = createDb(PG_URL);
db = handle.db;
ids = await seedFixtures(db);
moduleRef = await Test.createTestingModule({
controllers: [ListController],
providers: [
{ provide: DB, useValue: db },
GrantsService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
}).compile();
guard = moduleRef.get(FederationAuthGuard);
listController = moduleRef.get(ListController);
}, 30_000);
afterAll(async () => {
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
});
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
const malformedOidCert = await makeSelfSignedCert();
const request = makeFederationRequest(malformedOidCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(401);
expect(sent.payload).toMatchObject({
error: {
code: 'unauthorized',
message: expect.stringContaining('missing required OID'),
},
});
expect(request.federationContext).toBeUndefined();
});
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
expect(ids).toBeDefined();
const revokedCert = await makeMosaicIssuedCert({
grantId: ids!.revokedGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(revokedCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(403);
expect(sent.payload).toMatchObject({
error: {
code: 'forbidden',
message: 'Federation access denied',
},
});
expect(request.federationContext).toBeUndefined();
});
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('tasks', request, { limit: 100 });
const returnedIds = response.items.map((item) => item['id']);
expect(response.items).toHaveLength(2);
expect(response._truncated).toBe(true);
expect(response.nextCursor).toEqual(expect.any(String));
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
expect(returnedIds).not.toContain(ids!.taskIds[2]);
for (const excludedId of ids!.excludedTaskIds) {
expect(returnedIds).not.toContain(excludedId);
}
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('excludes another user mission task notes on the same authorized mission', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('notes', request, { limit: 10 });
const returnedIds = response.items.map((item) => item['id']);
expect(returnedIds).toEqual([ids!.subjectNoteId]);
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('fails closed for unsupported list resources', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is not supported',
},
},
status: 403,
});
});
});
@@ -1,9 +1,11 @@
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { AdminGuard } from './admin.guard.js';
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
@@ -14,6 +16,9 @@ export class AdminHealthController {
@Inject(DB) private readonly db: Db,
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(ProviderService) private readonly providerService: ProviderService,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {}
@Get()
@@ -55,6 +60,14 @@ export class AdminHealthController {
}
private async checkCache(): Promise<ServiceStatusDto> {
// On Local tier there is no Redis. The cache is intentionally absent, which
// is a healthy state for this tier — report 'ok' rather than opening a new
// ioredis connection on every admin health check (which would spam
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
// signals "no cache backend to measure" for this tier.
if (this.mosaicConfig?.queue?.type === 'local') {
return { status: 'ok', latencyMs: 0 };
}
const start = Date.now();
const handle = createQueue();
try {
@@ -72,13 +72,13 @@ const mockChatGateway = {
broadcastSessionInfo: vi.fn(),
};
function buildService(): CommandExecutorService {
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
return new CommandExecutorService(
mockRegistry as never,
mockAgentService as never,
mockSystemOverride as never,
mockSessionGC as never,
mockRedis as never,
redis as never,
mockBrain as never,
null,
mockChatGateway as never,
@@ -131,6 +131,22 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(ttl).toBe(300);
});
it('/provider login remains available without Redis on the local tier', async () => {
const localService = buildService(null);
const payload: SlashCommandPayload = {
command: 'provider',
args: 'login anthropic',
conversationId,
};
const result = await localService.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).not.toContain('token=');
expect(result.data).toEqual({ provider: 'anthropic' });
expect(mockRedis.set).not.toHaveBeenCalled();
});
// /provider with no args — returns usage
it('/provider with no args returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'provider', conversationId };
@@ -23,7 +23,10 @@ export class CommandExecutorService {
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
@Optional()
@Inject(COMMANDS_REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(BRAIN) private readonly brain: Brain,
@Optional()
@Inject(forwardRef(() => ReloadService))
@@ -443,14 +446,16 @@ export class CommandExecutorService {
byte.toString(16).padStart(2, '0'),
).join('');
const key = `mosaic:auth:poll:${tokenHash}`;
// Persist only a short-lived token digest. The raw token is delivered only by
// the authenticated dashboard flow, never in chat output or command metadata.
await this.redis.set(
key,
JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX',
300,
);
if (this.redis) {
// Persist only a short-lived token digest. The raw token is delivered only by
// the authenticated dashboard flow, never in chat output or command metadata.
await this.redis.set(
key,
JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX',
300,
);
}
return {
command: 'provider',
success: true,
+15 -5
View File
@@ -1,5 +1,7 @@
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js';
@@ -16,13 +18,17 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
providers: [
{
provide: COMMANDS_QUEUE_HANDLE,
useFactory: (): QueueHandle => {
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
// On Local tier there is no Redis — skip the ioredis connection.
// CommandExecutorService falls back to no-cache for /provider login on local.
if (config?.queue?.type === 'local') return null;
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: COMMANDS_REDIS,
useFactory: (handle: QueueHandle) => handle.redis,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
inject: [COMMANDS_QUEUE_HANDLE],
},
CommandRegistryService,
@@ -38,9 +44,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
],
})
export class CommandsModule implements OnApplicationShutdown {
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
constructor(
@Optional()
@Inject(COMMANDS_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> {
await this.handle.close().catch(() => {});
await this.handle?.close().catch(() => {});
}
}
@@ -5,6 +5,8 @@ import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GetController } from './server/verbs/get.controller.js';
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
import { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
@@ -12,7 +14,13 @@ import { ListController } from './server/verbs/list.controller.js';
import { FederationListQueryService } from './server/verbs/list-query.service.js';
@Module({
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
controllers: [
EnrollmentController,
FederationController,
CapabilitiesController,
ListController,
GetController,
],
providers: [
AdminGuard,
CaService,
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
FederationGetQueryService,
],
exports: [
CaService,
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
FederationGetQueryService,
],
})
export class FederationModule {}
@@ -0,0 +1,348 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
createPgliteDb,
missionTasks,
missions,
projects,
runPgliteMigrations,
teams,
users,
type Db,
type DbHandle,
} from '@mosaicstack/db';
import type { FederationScopeQueryFilter } from '../../scope.service.js';
import { FederationGetQueryService } from '../get-query.service.js';
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
resource: 'credentials',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: [],
limit: 1,
maxRowsPerQuery: 25,
};
const SUBJECT_USER_ID = 'fed-m3-06-subject';
const OTHER_USER_ID = 'fed-m3-06-other';
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
let dbHandle: DbHandle | undefined;
function makeService() {
return new FederationGetQueryService({} as Db);
}
function makeDbService() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
return new FederationGetQueryService(dbHandle.db);
}
async function seedNotesFixture() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
await dbHandle.db.insert(users).values([
{
id: SUBJECT_USER_ID,
name: 'Federation Subject',
email: `${SUBJECT_USER_ID}@example.test`,
emailVerified: false,
},
{
id: OTHER_USER_ID,
name: 'Federation Other',
email: `${OTHER_USER_ID}@example.test`,
emailVerified: false,
},
]);
await dbHandle.db.insert(teams).values([
{
id: TEAM_ID,
name: 'FED-M3-06 Team',
slug: 'fed-m3-06-team',
ownerId: SUBJECT_USER_ID,
managerId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_TEAM_ID,
name: 'FED-M3-06 Unauthorized Team',
slug: 'fed-m3-06-unauthorized-team',
ownerId: OTHER_USER_ID,
managerId: OTHER_USER_ID,
},
]);
await dbHandle.db.insert(projects).values([
{
id: PERSONAL_PROJECT_ID,
name: 'FED-M3-06 Personal Project',
ownerId: SUBJECT_USER_ID,
ownerType: 'user',
},
{
id: TEAM_PROJECT_ID,
name: 'FED-M3-06 Team Project',
teamId: TEAM_ID,
ownerType: 'team',
},
{
id: UNAUTHORIZED_PROJECT_ID,
name: 'FED-M3-06 Unauthorized Project',
teamId: UNAUTHORIZED_TEAM_ID,
ownerType: 'team',
},
]);
await dbHandle.db.insert(missions).values([
{
id: PERSONAL_MISSION_ID,
name: 'FED-M3-06 Personal Mission',
projectId: PERSONAL_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: TEAM_MISSION_ID,
name: 'FED-M3-06 Team Mission',
projectId: TEAM_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_MISSION_ID,
name: 'FED-M3-06 Unauthorized Mission',
projectId: UNAUTHORIZED_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
]);
await dbHandle.db.insert(missionTasks).values([
{
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on team mission',
createdAt: new Date('2026-06-24T03:00:00.000Z'),
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
},
{
id: OTHER_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: OTHER_USER_ID,
notes: 'other user note on team mission',
createdAt: new Date('2026-06-24T02:00:00.000Z'),
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
},
{
id: SUBJECT_PERSONAL_NOTE_ID,
missionId: PERSONAL_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on personal mission',
createdAt: new Date('2026-06-24T01:00:00.000Z'),
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
},
{
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
missionId: UNAUTHORIZED_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note outside grant-visible missions',
createdAt: new Date('2026-06-24T04:00:00.000Z'),
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
},
]);
}
describe('FederationGetQueryService', () => {
beforeAll(async () => {
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
await runPgliteMigrations(dbHandle);
await seedNotesFixture();
});
afterAll(async () => {
await dbHandle?.close();
dbHandle = undefined;
});
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
}),
).resolves.toMatchObject({
allowed: false,
reason: 'credentials federation get access is not implemented in M3',
});
});
it('allows personal memory reads without requiring team lookup', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'memory',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
});
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
const service = makeService();
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
(
service as unknown as {
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
}
).listSubjectTeamIds = listSubjectTeamIds;
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'tasks',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
});
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
});
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
const service = makeService();
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
status: 'denied',
reason: 'credentials federation get is not implemented',
});
});
it('fails closed for unsupported resources instead of returning undefined', async () => {
const service = makeService();
await expect(
service.get({
filter: {
...CREDENTIAL_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
},
id: 'row-1',
}),
).resolves.toEqual({
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
});
});
it('does not leak another user mission task note through team-scoped get reads', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: OTHER_TEAM_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('does not return subject notes from missions outside the grant-visible project set', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_TEAM_NOTE_ID,
}),
).resolves.toMatchObject({
status: 'found',
item: {
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
content: 'subject note on team mission',
},
});
});
it('does not return subject personal notes when includePersonal is false', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_PERSONAL_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
});
@@ -0,0 +1,207 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationAuthGuard } from '../../federation-auth.guard.js';
import type {
FederationScopeEvaluationResult,
FederationScopeQueryFilter,
} from '../../scope.service.js';
import { GetController } from '../get.controller.js';
import type { FederationGetQueryResult } from '../get-query.service.js';
const FEDERATION_CONTEXT = {
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
scope: { resources: ['tasks'], max_rows_per_query: 25 },
};
const TASK_FILTER: FederationScopeQueryFilter = {
resource: 'tasks',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: ['team-1'],
limit: 1,
maxRowsPerQuery: 25,
};
function makeRequest(): FastifyRequest {
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
}
function allowedScope(
filter: FederationScopeQueryFilter = TASK_FILTER,
): FederationScopeEvaluationResult {
return { allowed: true, filter };
}
function makeController(opts?: {
scopeResult?: FederationScopeEvaluationResult;
queryResult?: FederationGetQueryResult;
}) {
const scope = {
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
};
const query = {
evaluateReadAccess: vi.fn(),
get: vi.fn().mockResolvedValue(
opts?.queryResult ?? {
status: 'found',
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
},
},
),
};
return {
controller: new GetController(scope as never, query as never),
scope,
query,
};
}
describe('GetController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
});
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
const { controller, scope, query } = makeController();
const response = await controller.get('tasks', 'task-1', makeRequest());
expect(scope.evaluateAccess).toHaveBeenCalledWith({
context: FEDERATION_CONTEXT,
resource: 'tasks',
requestedLimit: 1,
nativeRbac: query,
});
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
expect(response).toEqual({
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
_source: 'local',
},
});
});
it('returns a federation error envelope when auth guard context is missing', async () => {
const { controller, scope, query } = makeController();
await expect(
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
).rejects.toMatchObject({
response: {
error: {
code: 'unauthorized',
message: 'Federation context missing',
},
},
status: 401,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).not.toHaveBeenCalled();
});
it('returns a federation error envelope when scope evaluation denies access', async () => {
const { controller, query } = makeController({
scopeResult: {
allowed: false,
deny: {
code: 'resource_excluded',
stage: 'resource_exclusion',
statusCode: 403,
message: 'Requested federation resource is explicitly excluded by grant scope',
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
},
},
});
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is explicitly excluded by grant scope',
},
},
status: 403,
});
expect(query.get).not.toHaveBeenCalled();
});
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
const { controller } = makeController({ queryResult: { status: 'not_found' } });
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'not_found' } },
status: 404,
});
});
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
const { controller } = makeController({
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
});
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Task is outside the federated scope',
},
},
status: 403,
});
});
it('fails closed when the query layer denies an unsupported resource', async () => {
const unsupportedFilter: FederationScopeQueryFilter = {
...TASK_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
};
const { controller } = makeController({
scopeResult: allowedScope(unsupportedFilter),
queryResult: {
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
},
});
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Unsupported federation get resource: unknown-resource',
},
},
status: 403,
});
});
it('rejects empty ids before evaluating scope', async () => {
const { controller, scope, query } = makeController();
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,311 @@
/**
* Federation get query layer (FED-M3-06).
*
* Read-only DB adapter used by GetController after FederationAuthGuard and
* FederationScopeService have established the subject user, allowed resource,
* native-RBAC intersection, and row cap. Audit writes are intentionally
* deferred to M4.
*/
import { Inject, Injectable } from '@nestjs/common';
import {
and,
eq,
inArray,
insights,
or,
missionTasks,
missions,
preferences,
projects,
tasks,
teamMembers,
type Db,
} from '@mosaicstack/db';
import { DB } from '../../../database/database.module.js';
import type {
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeQueryFilter,
} from '../scope.service.js';
export interface FederationGetQueryRequest {
readonly filter: FederationScopeQueryFilter;
readonly id: string;
}
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
readonly status: 'found';
readonly item: T;
}
export interface FederationGetQueryNotFoundResult {
readonly status: 'not_found';
}
export interface FederationGetQueryDeniedResult {
readonly status: 'denied';
readonly reason: string;
}
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
| FederationGetQueryFoundResult<T>
| FederationGetQueryNotFoundResult
| FederationGetQueryDeniedResult;
type RowObject = Record<string, unknown>;
function firstRow<T>(rows: T[]): T | undefined {
return rows[0];
}
function rowBelongsToAccessibleProjectOrMission(
row: { projectId?: string | null; missionId?: string | null },
projectIds: readonly string[],
missionIds: readonly string[],
): boolean {
return (
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
);
}
@Injectable()
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
constructor(@Inject(DB) private readonly db: Db) {}
async evaluateReadAccess(
request: FederationNativeRbacRequest,
): Promise<FederationNativeRbacResult> {
if (request.resource === 'credentials' || request.resource === 'api_keys') {
return {
allowed: false,
reason: `${request.resource} federation get access is not implemented in M3`,
details: { resource: request.resource },
};
}
if (request.resource === 'memory') {
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
}
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
return { allowed: true, access: { includePersonal: true, teamIds } };
}
async get<T extends RowObject = RowObject>(
request: FederationGetQueryRequest,
): Promise<FederationGetQueryResult<T>> {
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
}
private async getByResource(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
switch (filter.resource) {
case 'tasks':
return this.getTask(filter, id);
case 'notes':
return this.getNote(filter, id);
case 'memory':
return this.getMemory(filter, id);
case 'credentials':
case 'api_keys':
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
default:
return {
status: 'denied',
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
};
}
}
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
const rows = await this.db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, subjectUserId));
return rows.map((row) => row.teamId);
}
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
const clauses = [];
if (filter.includePersonal) {
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
}
if (filter.teamIds.length > 0) {
// Project team ownership follows TeamsService.canAccessProject: team-owned
// rows are authorized through projects.teamId, while ownerId remains the
// user who created/bootstrapped the project.
clauses.push(
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
);
}
if (clauses.length === 0) {
return [];
}
const rows = await this.db
.select({ id: projects.id })
.from(projects)
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
return rows.map((row) => row.id);
}
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
if (projectIds.length === 0) {
return [];
}
const rows = await this.db
.select({ id: missions.id })
.from(missions)
.where(inArray(missions.projectId, [...projectIds]));
return rows.map((row) => row.id);
}
private async getTask(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
await this.db
.select({
id: tasks.id,
title: tasks.title,
description: tasks.description,
status: tasks.status,
priority: tasks.priority,
projectId: tasks.projectId,
missionId: tasks.missionId,
assignee: tasks.assignee,
tags: tasks.tags,
dueDate: tasks.dueDate,
metadata: tasks.metadata,
createdAt: tasks.createdAt,
updatedAt: tasks.updatedAt,
})
.from(tasks)
.where(eq(tasks.id, id))
.limit(1),
);
if (!row) {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
return { status: 'denied', reason: 'Task is outside the federated scope' };
}
return { status: 'found', item: row as RowObject };
}
private async getNote(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
await this.db
.select({
id: missionTasks.id,
missionId: missionTasks.missionId,
taskId: missionTasks.taskId,
userId: missionTasks.userId,
status: missionTasks.status,
content: missionTasks.notes,
createdAt: missionTasks.createdAt,
updatedAt: missionTasks.updatedAt,
})
.from(missionTasks)
.where(eq(missionTasks.id, id))
.limit(1),
);
if (!row || row.content === null || row.content === '') {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
// mission_tasks rows are user-scoped even when the mission belongs to a team.
// Scope-visible missions must intersect with subject ownership; team scope
// narrows mission IDs but never widens note reads to another user's rows.
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
return { status: 'denied', reason: 'Note is outside the federated scope' };
}
const item = { ...row } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
private async getMemory(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const [insightRow, preferenceRow] = await Promise.all([
this.db
.select({
id: insights.id,
userId: insights.userId,
kind: insights.source,
content: insights.content,
category: insights.category,
relevanceScore: insights.relevanceScore,
metadata: insights.metadata,
createdAt: insights.createdAt,
updatedAt: insights.updatedAt,
})
.from(insights)
.where(eq(insights.id, id))
.limit(1)
.then(firstRow),
this.db
.select({
id: preferences.id,
userId: preferences.userId,
kind: preferences.category,
key: preferences.key,
value: preferences.value,
source: preferences.source,
mutable: preferences.mutable,
createdAt: preferences.createdAt,
updatedAt: preferences.updatedAt,
})
.from(preferences)
.where(eq(preferences.id, id))
.limit(1)
.then(firstRow),
]);
const candidates = [insightRow, preferenceRow].filter(
(row): row is NonNullable<typeof row> => row !== undefined,
);
if (candidates.length === 0) {
return { status: 'not_found' };
}
if (!filter.includePersonal) {
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
}
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
if (!accessible) {
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
}
const item = { ...accessible } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
}
@@ -0,0 +1,100 @@
/**
* Federation get verb (FED-M3-06).
*
* POST /api/federation/v1/get/:resource/:id
*
* Pipeline: FederationAuthGuard attaches the active grant context, then
* FederationScopeService enforces grant scope + native RBAC intersection, then
* the read-only query layer fetches one local row and tags it with `_source`.
* Read audit-log writes are deferred to M4; this controller does not persist
* request or response bodies.
*/
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FederationInvalidRequestError,
FederationNotFoundError,
FederationScopeViolationError,
FederationUnauthorizedError,
SOURCE_LOCAL,
type FederationGetResponse,
type SourceTag,
} from '@mosaicstack/types';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
import { FederationScopeService } from '../scope.service.js';
import { FederationGetQueryService } from './get-query.service.js';
type FederatedRow = Record<string, unknown> & SourceTag;
function scopeDenyToHttpException(deny: {
readonly statusCode: 400 | 403;
readonly message: string;
}): HttpException {
const ErrorClass =
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
}
@Controller('api/federation/v1/get')
@UseGuards(FederationAuthGuard)
export class GetController {
constructor(
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
) {}
@Post(':resource/:id')
async get(
@Param('resource') resource: string,
@Param('id') id: string,
@Req() request: FastifyRequest,
): Promise<FederationGetResponse<FederatedRow>> {
if (!request.federationContext) {
throw new HttpException(
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
401,
);
}
if (id.trim().length === 0) {
throw new HttpException(
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
400,
);
}
const scopeResult = await this.scope.evaluateAccess({
context: request.federationContext,
resource,
requestedLimit: 1,
nativeRbac: this.query,
});
if (!scopeResult.allowed) {
throw scopeDenyToHttpException(scopeResult.deny);
}
const result = await this.query.get({ filter: scopeResult.filter, id });
if (result.status === 'not_found') {
throw new HttpException(
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
404,
);
}
if (result.status === 'denied') {
throw new HttpException(
new FederationScopeViolationError(result.reason, {
resource,
id,
grantId: request.federationContext.grantId,
peerId: request.federationContext.peerId,
subjectUserId: request.federationContext.subjectUserId,
}).toEnvelope(),
403,
);
}
return { item: { ...result.item, _source: SOURCE_LOCAL } };
}
}
+15 -5
View File
@@ -1,5 +1,7 @@
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { SessionGCService } from './session-gc.service.js';
import { REDIS } from './gc.tokens.js';
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
providers: [
{
provide: GC_QUEUE_HANDLE,
useFactory: (): QueueHandle => {
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
// On Local tier there is no Redis — skip the ioredis connection entirely.
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
if (config?.queue?.type === 'local') return null;
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: REDIS,
useFactory: (handle: QueueHandle) => handle.redis,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
inject: [GC_QUEUE_HANDLE],
},
SessionGCService,
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
exports: [SessionGCService],
})
export class GCModule implements OnApplicationShutdown {
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
constructor(
@Optional()
@Inject(GC_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> {
await this.handle.close().catch(() => {});
await this.handle?.close().catch(() => {});
}
}
@@ -119,6 +119,19 @@ describe('SessionGCService', () => {
).resolves.toEqual({ allowed: true });
});
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
const result = await localService.collect('local-session');
expect(result.sessionId).toBe('local-session');
expect(result.cleaned.valkeyKeys).toBeUndefined();
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
'local-session',
expect.any(Date),
);
});
it('collect() returns sessionId in result', async () => {
const result = await service.collect('test-session-id');
expect(result.sessionId).toBe('test-session-id');
+15 -8
View File
@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Optional } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -21,7 +21,10 @@ function escapeRedisGlobLiteral(value: string): string {
@Injectable()
export class SessionGCService {
constructor(
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
@Optional()
@Inject(REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(LOG_SERVICE) private readonly logService: LogService,
) {}
@@ -29,8 +32,10 @@ export class SessionGCService {
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
* KEYS is avoided because it blocks the Valkey event loop for the full scan
* duration, which can cause latency spikes under production key volumes.
* Returns an empty population on the Local tier where Redis is disabled.
*/
private async scanKeys(pattern: string): Promise<string[]> {
if (!this.redis) return [];
const collected: string[] = [];
let cursor = '0';
do {
@@ -47,12 +52,14 @@ export class SessionGCService {
async collect(sessionId: string): Promise<GCResult> {
const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
if (this.redis) {
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
}
// 2. PG: demote hot-tier agent logs for this session only.
+9 -3
View File
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
@Injectable()
export class CronService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CronService.name);
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
constructor(
@Inject(SummarizationService) private readonly summarization: SummarizationService,
@@ -26,6 +26,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
) {}
async onModuleInit(): Promise<void> {
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
if (!this.queueService.isEnabled()) {
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
return;
}
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
@@ -39,7 +45,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
await this.summarization.runSummarization();
});
this.registeredWorkers.push(summarizationWorker);
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
// M6-005: Tier management repeatable job
await this.queueService.addRepeatableJob(
@@ -51,7 +57,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
await this.summarization.runTierManagement();
});
this.registeredWorkers.push(tierWorker);
if (tierWorker) this.registeredWorkers.push(tierWorker);
// Retire any repeatable global GC schedule created by older deployments.
// Session cleanup is now triggered only by an authorized session lifecycle operation.
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
import { SystemOverrideService } from './system-override.service.js';
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
describe('SystemOverrideService local tier', () => {
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
const service = new SystemOverrideService(localConfig);
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
await service.set('shared-session', 'first override', firstScope);
await service.set('shared-session', 'second override', secondScope);
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
await service.clear('shared-session', firstScope);
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
});
});
@@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
`${scope.tenantId}:${scope.userId}:${sessionId}`;
@@ -15,16 +17,45 @@ interface OverrideFragment {
addedAt: number;
}
@Injectable()
export class SystemOverrideService {
private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle;
interface LocalOverrideEntry {
condensed: string;
fragments: OverrideFragment[];
}
constructor() {
this.handle = createQueue();
@Injectable()
export class SystemOverrideService implements OnApplicationShutdown {
private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle | null;
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
private readonly localStore = new Map<string, LocalOverrideEntry>();
constructor(
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
}
async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {});
}
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) {
const key = scopedSessionId(sessionId, scope);
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
entry.fragments.push({ text: override, addedAt: Date.now() });
entry.condensed = await this.condenseOverrides(
entry.fragments.map((fragment) => fragment.text),
);
this.localStore.set(key, entry);
this.logger.debug(
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
);
return;
}
// Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
const fragments: OverrideFragment[] = existing
@@ -54,10 +85,14 @@ export class SystemOverrideService {
}
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
if (!this.handle) {
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
}
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
}
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) return;
const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
@@ -65,6 +100,11 @@ export class SystemOverrideService {
}
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) {
this.localStore.delete(scopedSessionId(sessionId, scope));
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
return;
}
await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId, scope),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
import { QueueService } from './queue.service.js';
const localConfig = {
queue: { type: 'local' },
} as MosaicConfig;
describe('QueueService local tier', () => {
it('disables BullMQ and treats queue operations as local no-ops', async () => {
const service = new QueueService(null, localConfig);
expect(service.isEnabled()).toBe(false);
expect(service.getQueue('mosaic-test')).toBeNull();
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
await expect(
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
).resolves.toBeUndefined();
await expect(service.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0);
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
await expect(service.listJobs()).resolves.toEqual([]);
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
});
});
+65 -6
View File
@@ -8,7 +8,9 @@ import {
} from '@nestjs/common';
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
import type { LogService } from '@mosaicstack/log';
import type { MosaicConfig } from '@mosaicstack/config';
import { LOG_SERVICE } from '../log/log.tokens.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import type { JobDto, JobStatus } from './queue-admin.dto.js';
// ---------------------------------------------------------------------------
@@ -108,21 +110,42 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
private readonly connection: ConnectionOptions;
private readonly queues = new Map<string, Queue<MosaicJobData>>();
private readonly workers = new Map<string, Worker<MosaicJobData>>();
/** False on Local tier — BullMQ/Redis operations become no-ops. */
private readonly enabled: boolean;
constructor(
@Optional()
@Inject(LOG_SERVICE)
private readonly logService: LogService | null,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
this.connection = getConnection();
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
this.connection = this.enabled
? getConnection()
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
}
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
isEnabled(): boolean {
return this.enabled;
}
onModuleInit(): void {
this.logger.log('QueueService initialised (BullMQ)');
if (this.enabled) {
this.logger.log('QueueService initialised (BullMQ)');
} else {
this.logger.log(
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
);
}
}
async onModuleDestroy(): Promise<void> {
await this.closeAll();
if (this.enabled) {
await this.closeAll();
}
}
// -------------------------------------------------------------------------
@@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Get or create a BullMQ Queue for the given queue name.
* Returns null on Local tier where BullMQ is disabled.
*/
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
if (!this.enabled) return null;
let queue = this.queues.get(name) as Queue<T> | undefined;
if (!queue) {
queue = new Queue<T>(name, { connection: this.connection });
@@ -144,6 +169,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Add a BullMQ repeatable job (cron-style).
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
* No-op on Local tier.
*/
async addRepeatableJob<T extends MosaicJobData>(
queueName: string,
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
data: T,
cronExpression: string,
): Promise<void> {
const queue = this.getQueue<T>(queueName);
if (!this.enabled) {
this.logger.debug(
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return;
}
const queue = this.getQueue<T>(queueName)!;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (queue as Queue<any>).add(jobName, data, {
repeat: { pattern: cronExpression },
@@ -167,7 +199,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* safe retirement of previously registered system-wide jobs.
*/
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
if (!this.enabled) {
this.logger.debug(
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return 0;
}
const queue = this.getQueue(queueName);
if (!queue) return 0;
const jobs = await queue.getRepeatableJobs();
const matchingJobs = jobs.filter((job) => job.name === jobName);
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
@@ -182,8 +221,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Register a Worker for the given queue name with error handling and
* exponential backoff.
* Returns null on Local tier where BullMQ is disabled.
*/
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
registerWorker<T extends MosaicJobData>(
queueName: string,
handler: JobHandler<T>,
): Worker<T> | null {
if (!this.enabled) {
this.logger.debug(
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
);
return null;
}
const worker = new Worker<T>(
queueName,
async (job) => {
@@ -240,8 +289,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Return queue health statistics for all managed queues.
* Returns an empty healthy result on Local tier.
*/
async getHealthStatus(): Promise<QueueHealthStatus> {
if (!this.enabled) {
return { queues: {}, healthy: true };
}
const queues: QueueHealthStatus['queues'] = {};
let healthy = true;
@@ -272,8 +325,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* List jobs across all managed queues, optionally filtered by status.
* BullMQ jobs are fetched by state type from each queue.
* Returns empty array on Local tier.
*/
async listJobs(status?: JobStatus): Promise<JobDto[]> {
if (!this.enabled) return [];
const jobs: JobDto[] = [];
const states: JobStatus[] = status
? [status]
@@ -300,8 +355,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
* job IDs are not globally unique — they are scoped to their queue.
* Returns an error on Local tier.
*/
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const sep = compositeId.lastIndexOf('__');
if (sep === -1) {
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
@@ -333,6 +390,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Pause a queue by name.
*/
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.pause();
@@ -344,6 +402,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Resume a paused queue by name.
*/
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.resume();
-53
View File
@@ -437,59 +437,6 @@ Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete
---
## Governed fleet credential lifecycle (`mosaic cred`, #1045)
### Problem and objective
Fleet credentials are issued, wired, resolved, granted, validated, rotated, and revoked through unrelated scripts and manual provider actions. The split has produced silent fallback to a human/shared principal, missing runtime identity, cross-estate login resolution, incomplete permission checks, and non-auditable grants. The objective is one mechanical, durable, systemic `mosaic cred` path that decides both what a fleet seat may do and which provider identity it acts as.
### Scope
Phase 1 governs the existing per-identity Gitea token store and Tea login registration. VaultWarden is explicitly out for the agent tier and is not a backend option in this workstream. Certificate-backed identity and short-lived broker-issued credentials remain later phases behind the same caller contract.
### Normative requirements
1. `CRED-REQ-01`: The CLI SHALL expose `provision`, `wire`, `grant`, `get`, `validate`, `whoami`, `list`, `rotate`, `revoke`, and `audit`. Grant and validate SHALL conform to [`docs/credentials/GRANT-VALIDATE-CONTRACT.md`](./credentials/GRANT-VALIDATE-CONTRACT.md).
2. `CRED-REQ-02`: Every provider operation SHALL carry an explicit identity, estate, and host. Estate-to-host mapping SHALL come from strict non-secret configuration. Missing, ambiguous, inferred, or mismatched values SHALL refuse before credential resolution. Machine location SHALL grant no estate authority.
3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither. At mint time, delegated Basic authority SHALL read its provider principal back, the minted token object SHALL read back exact scopes, and both the token binding and exact host-bound Tea record SHALL contain that same minted credential. Runtime `/user` identity remeasurement is required only when the seat token already carries `read:user`; least-privilege tokens SHALL NOT be widened to service the instrument. A wrong-host or absent Tea login SHALL never fall back to a host default.
4. `CRED-REQ-04`: Under fleet context, unset or unresolvable identity SHALL fail closed identically in the git credential helper and API resolver. Interactive shared credentials remain available only through an explicit non-fleet/shared selection; absence SHALL never select them.
5. `CRED-REQ-05`: Token scope, repository permission, and organization/team role are independent layers. Provision, grant, and validate SHALL report each separately from provider evidence. No layer substitutes for another, and a permission widening at one layer SHALL not be described as least privilege because another layer is narrow.
6. `CRED-REQ-06`: Gitea token creation SHALL use an explicit delegated provisioning step because this provider requires Basic Auth. Password-equivalent provisioning material SHALL enter only through a protected control-plane runtime credential channel, never caller bearer storage, argv, ordinary environment, logs, or output.
7. `CRED-REQ-07`: Permission grants SHALL be accepted only after provider read-back of the named direct collaborator permission or, for team grants, organization membership, team membership, team-repository attachment, and subject effective permission.
8. `CRED-REQ-08`: `validate --repo` SHALL compute a side-effect-free write differential by result. One immutable credential resolution SHALL bind the declared subject's provider identity read-back, repository permission, and authenticated Git receive-pack advertisement. A distinct provider-confirmed read-only principal and an unauthenticated caller SHALL both be refused receive-pack in the same evaluation. Principal/handle disagreement SHALL be indeterminate, never refusal or success. The check SHALL create no ref or artifact and SHALL state that it does not prove a particular update will pass branch protection, hooks, races, or content policy.
9. `CRED-REQ-09`: All provider HTTP calls SHALL share one transport implementation for URL/host binding, TLS, User-Agent, content-type, JSON-shape validation, redaction, and bounded responses. A 2xx status alone SHALL never establish identity, scope, permission, grant, or revocation.
10. `CRED-REQ-10`: Operations SHALL return stable machine outcomes `ok`, `refused`, `error`, or `indeterminate`. Policy refusal, local operational failure, and incomplete/inconsistent evidence SHALL remain distinguishable. `provider-unavailable`, `identity-not-measured`, `identity-not-visible`, `identity-not-found`, and `credential-rejected` SHALL remain distinct diagnoses. Validation SHALL report capability from an in-scope probe separately from identity measurement. `/user` 401 is `credential-rejected`/refused; `/user` 403/404 plus successful in-scope capability is `identity-not-measured`, never a dead credential. A returned login mismatch is a binding refusal. No implemented operation may emit `identity-not-found`; that diagnosis requires a separately approved visibility-authorized inventory capability. Security callers SHALL fail closed on every outcome except `ok` without relabelling indeterminate evidence as a denial.
11. `CRED-REQ-11`: No command SHALL print a token, password, authorization header, fingerprint, partial secret, or secret-bearing provider body, including error paths. Secrets SHALL not appear in process argv. Phase-1 file storage SHALL remain private, symlink-safe, regular-file-only, test-overridable, and compatible with existing managed token consumers.
12. `CRED-REQ-12`: Every issue, provision, grant, rotate, revoke, and credential access SHALL be journaled with actor, subject, estate, host, repo/scope, operation, time, and non-secret provider evidence. The durable journal SHALL be opened and fsynced before the first mutation, append each mutation/read-back, and seal only after acceptance. Journal/audit write failure SHALL be fatal; an unsealed journal means incomplete/indeterminate work.
13. `CRED-REQ-13`: `wire` SHALL be idempotent and SHALL update the exact roster-derived `<identity>.env.generated` fleet projection so both identity axes survive restart. It SHALL authenticate the same explicit seat through a protected delegated credential channel and provider identity read-back before mutation, refuse actor/identity/path/roster disagreements, and never authorize from the shared Unix account. It SHALL not write linked-worktree git configuration or silently infer identity from pane/session names.
14. `CRED-REQ-14`: Rotate SHALL verify the new credential/provider identity before retiring the old credential. Revoke SHALL read back provider revocation/denial and preserve an auditable recovery record. A local file deletion or successful HTTP status is not revocation evidence.
15. `CRED-REQ-15`: Before the #1044 fail-closed resolver change is eligible to land, `mosaic cred validate` SHALL resolve every live HOMELAB mosaic-lane seat from `git.mosaicstack.dev` by provider read-back. Any unresolved seat HOLDS the fail-closed change; the implementation may not widen or restore shared fallback.
16. `CRED-REQ-16`: Provider claims SHALL record the estate, instance, endpoint, asserted content type, and decision-relevant object fields. Append-only provider status history SHALL be reduced to latest-per-context where current state is required.
### Acceptance criteria
1. `AC-CRED-01`: Red-first tests prove unset identity, missing token, wrong estate, wrong host, wrong Tea login, and out-of-estate identity produce the same structured refusal class/reason on git and API resolution, with no shared credential read and no provider mutation.
2. `AC-CRED-02`: Provisioning against a provider fixture proves Basic Auth is required, bearer-only token minting is refused, both identity axes register atomically, exact token scopes are read back from the provider token object, and rollback removes partial local registration.
3. `AC-CRED-03`: Direct and team grant tests read all applicable permission layers back from provider objects. Deliberately divergent token scope and repo grant cases cannot return `ok`; organization/team membership and team-repository attachment are additionally acceptance-bearing for team grants. A direct collaborator grant reports organization membership but does not require it, because direct collaborator permission and organization membership are intentionally independent provider layers.
4. `AC-CRED-04`: Validate proves provider identity and the write differential on the intended repository through one credential handle. The subject is accepted, a separately resolved provider-confirmed read-only principal is refused, and an unauthenticated caller is refused in the same invocation. A shared/wrong-principal fallback, independent subject lookups, invalid read-only control, evidence disagreement, unexpected content type/shape, or provider outage returns `indeterminate`, never success or policy refusal. Runtime exact scope is reported independently as `not-measured` when the current seat credential is not authorized to read its provider token object; NOT-MEASURED is neither pass nor failure and does not erase confirmed repository capability. Exact scope is acceptance-bearing at provision/rotate time, where delegated mint authority can read the token object.
5. `AC-CRED-05`: Audit/journal fault injection before and after each mutation proves write failure is fatal, open journals remain visible/recoverable, and no operation can claim success without a sealed journal and provider read-back.
6. `AC-CRED-06`: Adversarial output/argv tests seed distinct secret values through success, refusal, provider-error, parser-error, rollback, rotate, and revoke paths and find zero secret/partial/fingerprint occurrences in stdout, stderr, logs, audit, and child argv.
7. `AC-CRED-07`: Storage tests reject symlinked roots/files, non-regular files, permissive modes, traversal, conflicting concurrent mutation, and production-store leakage into fixture tests. Existing canonical per-seat token consumers continue through the governed adapter.
8. `AC-CRED-08`: `wire` repeated twice is byte-idempotent, produces both required identity-axis values in the exact roster-derived generated environment, survives a fresh fleet projection/restart path, and leaves shared linked-worktree git config untouched. An unauthenticated caller, a caller authenticated as another seat, a caller-selected filename, or a file whose roster identity differs is refused before mutation.
9. `AC-CRED-09`: Rotate validates new identity/capabilities before retiring old material; injected failure leaves the previously valid credential usable and the journal open. Revoke is accepted only when provider read-back proves the credential no longer authenticates/authorizes.
10. `AC-CRED-10`: Every live HOMELAB mosaic-lane seat resolves from `git.mosaicstack.dev` before the #1044 fallback closes. The evidence names the complete seat population, provider endpoint/content type, and unresolved count; non-zero unresolved count blocks landing.
11. `AC-CRED-11`: Baseline typecheck/lint/format/tests, focused auth/permission abuse cases, independent code review, independent security review, and terminal-green HOMELAB Woodpecker CI pass on the exact reviewed head.
12. `AC-CRED-12`: Interim delivery to `next` is reported only as **believed-fixed, pending validation AND pending promotion to `main`**. Issues stay open until #1037 promotes the work and constitutional completion is independently verified.
### Constraints and dependencies
- C1 install-state-machine work merges first. This lane then re-takes base/head-bound measurements without redesigning or reworking code.
- MB-BRAIN-01 (#1051) consumes the grant/validate contract and may proceed against the published interface before implementation merge.
- The branch-model compatibility question for `next` remains escalated. No `done` claim, issue closure, or self-initiated promotion is permitted at the `next` checkpoint.
- `ASSUMPTION:` Phase-1 Gitea support is the only provider implementation in this slice; provider-neutral types preserve later adapters without pretending unimplemented providers are supported.
---
## Architecture
### High-Level System Diagram
-204
View File
@@ -1,204 +0,0 @@
# `mosaic cred grant` / `validate` caller contract v1.5
**Status:** early binding contract for MC-CRED-01 and MB-BRAIN-01. v1.3's anonymous absence classifier was withdrawn as unsound for private users. v1.4 adopted subject-credential validation without admin visibility. v1.5 separates in-scope capability from identity measurement so correctly least-privileged tokens are not widened to service the instrument. This contract may evolve before implementation merge; incompatible changes require an explicit change notice.
## Security model
- Every call carries both `--estate` and `--host`. The configured estate-to-host mapping must match exactly. Host inference, host-adjacent fallback, and cross-estate resolution are forbidden.
- `<identity>` is always explicit. The CLI never substitutes a pane, roster, login, Unix user, or other plausible ambient identity.
- The identity token and the host-bound Tea login are one provisioning unit. Minting authority reads the principal back when the invariant is created and records that binding with the token registration. Runtime validation re-measures identity only when the token already holds `read:user`; it never widens scopes to make the instrument green.
- Grant authority is broker/delegated-provisioner material. It is never supplied as a CLI value, environment value, or bearer token readable by the requesting agent. The broker obtains it from its protected runtime credential channel.
- Commands never print token, password, authorization header, fingerprint, partial secret, or secret-bearing error text. Structured evidence contains provider object fields and endpoint metadata only.
- Every operation opens and fsyncs a durable journal before the first mutation. Journal/audit write failure is fatal. A grant is successful only after provider read-back and a sealed journal.
## Commands
```text
mosaic cred grant <identity> \
--estate <estate> \
--host <host> \
--repo <owner/repo> \
--permission <read|write|admin> \
[--via <collaborator|team>] \
[--team <team>] \
[--read-only-control <identity>] \
[--json]
mosaic cred validate <identity> \
--estate <estate> \
--host <host> \
[--repo <owner/repo>] \
[--require <read|write|admin>] \
[--read-only-control <identity>] \
[--json]
```
Rules:
- `--via collaborator` is the default. It grants a direct repository permission and still reports the organization-membership layer.
- `--via team` requires `--team`; `--team` with collaborator mode is invalid.
- `validate --repo` reports two independent axes: capability from an in-scope repository probe, and identity binding from `/user` only when authorized. Capability may be `confirmed` while identity is `not-measured`; NOT-MEASURED is neither pass nor failure.
- Write validation requires a distinct known-read-only control identity, supplied explicitly or configured in the declared estate. The control identity and its read-only permission are read back from the provider on every invocation; the configured name alone is not evidence.
- `grant` invokes the same validation after mutation. HTTP 2xx and process exit status are never acceptance evidence.
## Machine result
`--json` writes exactly one non-secret JSON object to stdout. Human diagnostics go to stderr. Callers must decide from `outcome`, never by parsing prose.
```json
{
"schemaVersion": 1,
"operation": "grant",
"outcome": "ok",
"exitCode": 0,
"retryable": false,
"subject": {
"identity": "seat-name",
"estate": "estate-name",
"host": "git.example.invalid",
"repo": "owner/repo"
},
"mutation": "applied",
"reason": {
"code": "grant-verified",
"message": "Grant matched all provider read-backs."
},
"evidence": {
"providerIdentity": {
"login": "seat-name",
"endpoint": "GET /api/v1/user",
"contentType": "application/json"
},
"tokenCapabilities": {
"state": "not-measured",
"scopes": [],
"source": "runtime-not-authorized"
},
"repositoryPermission": {
"requested": "write",
"effective": "write",
"endpoint": "GET /api/v1/repos/owner/repo",
"contentType": "application/json"
},
"organizationMembership": {
"state": "present"
},
"teamMembership": {
"state": "not-applicable"
},
"writeDifferential": {
"state": "can-write",
"credentialBinding": "same-resolution",
"transportPrincipal": "seat-name",
"authenticatedReceivePack": "advertised",
"readOnlyControl": {
"identity": "read-only-control",
"providerPermission": "read",
"receivePack": "refused"
},
"unauthenticatedReceivePack": "refused",
"artifactCreated": false,
"proves": "One immutable credential resolution authenticated both the subject identity read-back and write transport; a provider-confirmed read-only principal and an unauthenticated caller were both refused.",
"doesNotProve": "A particular ref update will pass branch protection, hooks, races, or content policy."
}
},
"audit": {
"journalId": "opaque-id",
"state": "sealed"
}
}
```
Fields may be `null` only when their enclosing evidence state explains why. Missing decision-relevant fields make the result `indeterminate`, never `ok`.
## Terminal classes
| Outcome | Exit | Meaning | Mutation guarantee | Caller action |
| --------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `ok` | `0` | Requested property was established from provider objects and all required layers agree. | `validate`: `none`; `grant`: `applied` and read back. | Continue. |
| `refused` | `10` | A complete, authoritative policy/access decision denied the request. Examples: estate-host mismatch, missing explicit identity, provider identity mismatch, explicit permission denial, or cross-estate subject. | `none`; refusal occurs before mutation. | Treat as a stable denial. Do not retry without changing authority/configuration. |
| `error` | `20` | The command contract or local control failed before an access verdict. Examples: invalid arguments, malformed estate registry, insecure credential path, journal cannot be opened/fsynced, or internal invariant failure. | `none` unless `mutation` explicitly says `unknown`; `unknown` is never success. | Repair the tool/configuration. Do not reinterpret as access denial. |
| `indeterminate` | `30` | The requested security property could not be evaluated completely or evidence disagreed. Examples: provider unavailable, wrong content type/shape, permission and receive-pack disagreement, missing post-grant read-back, or unknown mutation acknowledgement. Runtime scope `not-measured` remains a separately reported axis and is neither pass nor failure. | `none`, `applied`, or `unknown`, stated explicitly. Never infer. | Fail closed at the calling gate. Investigate/re-evaluate; do not label the subject refused. |
Parsing/usage errors emitted by Commander remain exit `2` and do not produce a broker verdict. Callers should treat them as integration defects, not access decisions.
## Refusal object
A refusal is intentionally recognizable without prose:
```json
{
"schemaVersion": 1,
"operation": "validate",
"outcome": "refused",
"exitCode": 10,
"retryable": false,
"subject": {
"identity": "external-seat",
"estate": "homelab",
"host": "git.example.invalid",
"repo": "owner/repo"
},
"mutation": "none",
"reason": {
"code": "no-token-for-identity",
"message": "The explicit identity has no credential in the declared estate."
},
"evidence": {
"providerIdentity": null,
"tokenCapabilities": {
"state": "not-measured",
"scopes": [],
"source": "runtime-not-authorized"
},
"repositoryPermission": null,
"organizationMembership": null,
"teamMembership": null,
"writeDifferential": null
},
"audit": {
"journalId": "opaque-id",
"state": "sealed"
}
}
```
The git credential helper and API resolver must map the same subject/estate/host failure to the same `reason.code` and terminal class. MB-BRAIN-01 may assert this parity. A caller does not need to know which resolver path was used.
## Required reason codes
Stable v1 codes:
- refusal: `identity-required`, `estate-required`, `estate-host-mismatch`, `cross-estate-resolution`, `no-token-for-identity`, `tea-login-missing`, `tea-login-host-mismatch`, `provider-identity-mismatch`, `credential-rejected`, `permission-denied`, `organization-membership-required`, `team-membership-required`
- error: `invalid-input`, `estate-registry-invalid`, `insecure-credential-source`, `journal-unavailable`, `internal-invariant`
- indeterminate: `provider-unavailable`, `identity-not-visible`, `identity-not-measured`, `identity-not-found`, `unexpected-content-type`, `unexpected-provider-shape`, `scope-not-evaluable`, `permission-evidence-disagrees`, `transport-principal-mismatch`, `read-only-control-invalid`, `readback-missing`, `mutation-state-unknown`, `concurrent-mutation`, `mutation-lock-unavailable`, `team-scope-changed-during-grant`, `wire-audit-incomplete`
`provider-unavailable` means no usable provider answer was available. `identity-not-measured` means `/user` was scope-forbidden while an in-scope repository probe confirmed the credential capability; it is `indeterminate` only for the identity axis and must not be represented as a dead credential. `identity-not-visible` and `identity-not-found` are reserved for the unimplemented external inventory capability. `credential-rejected` means the provider rejected the credential itself (Gitea 401), which is a stable `refused` outcome. A 403 on `/user` is not credential rejection when an in-scope probe succeeds.
No anonymous or visibility-unprivileged 404 is admissible evidence of absence. `identity-not-found` requires, in the same invocation: (1) the visibility credential's own `/user` object read back as the configured authority with provider-admin visibility; (2) target lookup performed with that same authority; (3) a known-present PRIVATE control returning JSON 200 with matching login and `visibility=private`; and (4) a generated absent negative control returning JSON 404 under that same authority. Missing authority or any non-discriminating control yields `identity-not-visible`, never absence. A public positive control cannot certify private subjects.
No currently implemented operation may emit `identity-not-found`: the required governed inventory capability was deliberately declined and runtime validation must not acquire standing admin visibility. For `validate`, `/user` 401 means `credential-rejected`; `/user` 403/404 triggers the in-scope capability probe and, when that succeeds, identity is `identity-not-measured`; JSON 200 with a mismatched login is a binding refusal. A future inventory operation must meet every precondition above and receive an explicit privilege decision before making `identity-not-found` reachable.
Unknown future reason codes must still carry one of the four stable `outcome` values.
## Side-effect-free write differential
For Gitea v1, `validate --repo` resolves the subject credential exactly once into an immutable in-memory credential handle. The provider `/user` read-back, authenticated repository object, and Git smart-HTTP `git-receive-pack` advertisement all consume that same handle; callers may not perform independent lookups for those steps. The command also probes a separately resolved, provider-confirmed read-only control principal and repeats the request unauthenticated.
`can-write` requires all of the following:
1. provider `/user` login obtained with the subject credential handle equals `<identity>`;
2. authenticated repository object obtained with that same handle reports write-capable permission;
3. receive-pack obtained with that same handle returns the exact advertisement content type and protocol preamble;
4. the transport evidence records the same declared principal as the identity read-back; any handle/principal seam disagreement is `transport-principal-mismatch` and therefore `indeterminate`, never refused;
5. a distinct known-read-only credential resolves to its declared control identity, its provider repository object reports no write permission, and receive-pack is refused;
6. the unauthenticated control is refused and does not return a receive-pack advertisement;
7. estate, host, and repository in every request equal the declared subject.
The read-only control varies the mechanism under accusation: principal selection. The unauthenticated arm remains as a separate control proving authentication is required; it cannot establish which principal authenticated the subject probe. A missing, write-capable, identity-mismatched, or otherwise invalid read-only control makes the result `indeterminate`.
No ref is updated and no repository artifact is created. This proves that the declared subject credential—not merely some authenticated credential—can enter the write transport for that repository, while a provider-confirmed read-only principal and an unauthenticated caller cannot. It does not prove any specific branch update would survive branch protection, hooks, concurrent changes, or content policy.
## Grant read-back
A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant serializes governed mutations per provider team and enumerates the team's complete repository attachment set both before and after mutation. It refuses before mutation when the team is already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). If the post-mutation set is not exactly the requested repository, it returns `indeterminate` (`team-scope-changed-during-grant`) and compensates only state proven absent before the locked invocation: a newly introduced subject membership and/or requested repository attachment. Both compensations require provider absence read-back and are journaled; the operation never reports success from the stale pre-check. The grant then requires provider read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another.
@@ -0,0 +1,63 @@
# npm `@next` prerelease lane
Status: **IMPLEMENTED**
## Current behavior
`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch.
The lane is fast-by-default:
1. Install framework files from the `next` source archive.
2. Resolve the Gitea npm registry `next` dist-tag for the globally installed packages:
```bash
npm view @mosaicstack/gateway@next version
npm view @mosaicstack/mosaic@next version
```
3. Require both resolved versions to share the same `next.<pipeline>` suffix, then install the exact resolved versions.
4. If either `@next` package is missing, unreachable, mismatched, or fails to install, fall back to the source-build path at `next`.
`--next` never hard-fails solely because the prerelease npm dist-tag is unavailable.
## Published packages
The `next` publish pipeline publishes non-private `@mosaicstack/*` packages to the Mosaic Gitea npm registry:
```text
https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
```
Observed `next` dist-tags after enabling the pipeline:
```text
@mosaicstack/mosaic@next -> 0.0.49-next.1633
@mosaicstack/gateway@next -> 0.0.7-next.1633
```
The gateway also publishes a Docker image as `gateway:sha-<short>` on `next` merges. The installer fast path uses the npm gateway package when available; the Docker image is for deployed gateway/runtime harness flows.
## Explicit source lanes
Source builds remain available and are still the authority for explicit ref validation:
- `--dev` always builds from source.
- `--ref <ref>` / `MOSAIC_REF=<ref>` wins over `--next` and uses the source path for that exact ref.
## Pipeline shape
1. Trigger on `next` merges.
2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`<target-stable>-next.<CI_PIPELINE_NUMBER>`).
3. Build and publish non-private packages in CI.
4. Publish to the Mosaic Gitea npm registry with dist-tag `next`.
5. Keep `latest` untouched; only main/release promotion can update `latest`.
6. Publish gateway Docker images from `next` as `gateway:sha-<short>` only.
## Guardrails
- `@next` is mutable prerelease convenience, not a deployment pin.
- Stable installs continue to use `@latest`.
- Contributor validation remains available through `--dev --ref <branch>`.
- Pipeline output traces every prerelease package back to the source commit on `next`.
- The installer falls back to source rather than hard-failing on prerelease registry issues.
+11
View File
@@ -195,6 +195,17 @@ pnpm format:check && pnpm typecheck && pnpm lint
A pre-push hook enforces this mechanically.
### CI Publish Channels
Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate:
| Source | npm packages | Gateway image |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-<short>` plus `gateway:latest` on `main`, and the release tag on tag events |
| `next` push/manual | CI-computed prereleases, `<target-stable>-next.<CI_PIPELINE_NUMBER>`, published with `npm publish --tag next` | `gateway:sha-<short>` only |
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
---
## Adding New Agent Tools
+12 -2
View File
@@ -175,8 +175,18 @@ Or use the direct URL:
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
non-interactive use:
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
Install lanes:
| Lane | Command | Source |
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
Flags for non-interactive use:
```bash
--yes # Accept all defaults
@@ -0,0 +1,71 @@
# #1019 — Zero-timeout queue-guard harness race
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
- **Branch:** `fix/1019-ci-queue-timeout-harness`
- **Owner:** `be-coder-08`
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
## Objective
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
## Scope
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
## Acceptance criteria
1. RED deterministically reproduces deadline pre-emption before the provider call.
2. Every case that intends status classification positively proves provider observation.
3. Pending observes `pending` before deterministic virtual-time expiration.
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
5. The exact CI-base image passes the final harness repeatedly with zero failures.
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
## Plan
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
## Budget
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
## Evidence
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log`**100 pass / 0 fail / 100 total**.
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
## Review remediation — semantic timeout vs. liveness bound
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
Remediation:
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
Post-review evidence:
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
## 60% context hold
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
-82
View File
@@ -1,82 +0,0 @@
# MC-CRED-01 / stack #1045 scratchpad
Last updated: 2026-08-05
## Objective
Deliver the governed `mosaic cred` identity boundary for issue, scope, validation, rotation, and revocation across explicitly declared estates. The trunk-only ruling superseded the original `next` checkpoint: the branch is rebased onto `origin/main` and its PR target is `main`. Linked issues remain **believed-fixed, pending jarvis validation** after merge.
## Requirements sources
- Charter: `/home/hermes/agent-work/tl-mosaic/CHARTER-MC-CRED-01-be-coder-06.md`
- Stack issues: #1045, #1043, #1044, #1047, #1049, #1013, #1007; promotion #1037; consumer #1051
- Remote spec: `jason.woltje/jarvis-brain` origin/main `b7687d51f4efe52e43dbcd6dc95b5554b3332957`
- Greenfield PRD v3 addenda: INV-B durable journal, INV-C visible failure diagnostics, INV-D supported fixture
- Binding doctrine: `/src/jarvis-brain/infra/fleet/FLEET-DOCTRINE.md`
## Plan
1. Publish grant/validate v1 caller contract for MB-BRAIN-01.
2. Add repo PRD requirements and preregister acceptance tests.
3. Implement explicit estate registry, secure current file-store adapter, durable operation journal/audit, provider transport, and terminal result types.
4. Implement `grant` and side-effect-free `validate`; then provision/wire/get/whoami/list/rotate/revoke/audit.
5. Make git and API resolver refusals identical and fail closed under fleet context.
6. Reconcile live HOMELAB seats through each subject credential's own `/user`; #1044 hold is lifted, and its fail-closed change carries the pre-registered mechanism evidence (resolver refusal marker, same-run marker positive control, confirmed-lane negative arm).
7. Run baseline/situational tests, independent code review and mandatory independent security review, CI on the exact head, then open the PR directly against `main` without closing issues or claiming Jarvis validation.
8. C1 merges first. After any base/head move, re-derive merge-base, commit set, diff, CI, reviews, and provider measurements from the replacement SHA.
## Budget
No explicit token cap supplied. Working cap: keep implementation in one package plus shipped framework resolver changes and required docs/tests; avoid unrelated wrapper defect fixes and VaultWarden redesign. Escalate only if a charter requirement is technically unsatisfiable.
## Decisions
- VaultWarden is out for the agent tier per the charter verdict; phase 1 governs the existing per-identity file store.
- Estate is explicit input and must match a configured host mapping; target host is never inferred from machine location.
- Grant authority and basic-auth provisioning material are delegated control-plane credentials, never caller bearer material and never CLI argument/output.
- `ok`, `refused`, `error`, and `indeterminate` are distinct machine outcomes. Security callers fail closed on all but `ok`, while retaining the semantic distinction.
- Gitea write-differential resolves the subject once and binds provider identity, repository permission, and receive-pack to the same in-memory credential handle. It adds a distinct provider-confirmed read-only-principal control plus the unauthenticated control, with no ref update. The live HOMELAB negative-control subject is `tl-mosaic`, verified read-only on `mosaicstack/stack`; code and contract remain principal-agnostic.
## Progress
- [x] Mode/intake/core guides/skills/doctrine loaded.
- [x] Spec repository READ confirmed under be-coder-06 from provider object.
- [x] Target-branch conflict resolved by the trunk-only ruling; the lane was rebased from `next` onto `origin/main`.
- [x] Canonical remote PRD v3 addenda re-read at new head.
- [x] Required issues read via Mosaic wrapper.
- [x] Early grant/validate contract v1 published at `docs/credentials/GRANT-VALIDATE-CONTRACT.md`.
- [x] Contract v1.1 binds transport to the same resolved principal and adds a provider-confirmed read-only-principal control.
- [x] Contract v1.2 distinguishes provider outage, absent identity, and rejected credential.
- [x] Contract v1.3 positive-controlled anonymous visibility; subsequently withdrawn as unsound for private identities.
- [x] Contract v1.4 implements ruling (b): subject credential's own `/user`, no admin/inventory authority, no implemented `identity-not-found` path.
- [x] PRD update.
- [x] Red-first principal-bound validate, estate-registry, file-store, provider-transport, and journal tests.
- [x] Implementation: validate, direct/team grant, protected delegated authority, provision/wire/get/whoami/list/rotate/revoke/audit, reverse registry, and fleet fail-closed resolver paths.
- [x] Review hardening: rotation returns visible open journals; team evidence records absent objects accurately; team scope is checked before/after under a host-qualified OS advisory lock with verified compensation; `wire` authenticates the exact seat/path/roster binding and preserves post-rename mutation semantics.
- [ ] Independent code/security approvals on the final exact head (Codex advisory iterations are not independent approval).
- [ ] Final exact-head CI and provider evidence.
## Tests and evidence
Baseline after workspace build: package typecheck passed; Vitest 81/81 files and 1,514/1,514 tests passed. The package shell suite reached a pre-existing tracked #973 Bash 5.2 BASH_LINENO incompatibility and exited 97 before wake tests; this is baseline, not introduced by MC-CRED.
Red-first evidence:
- principal-bound validate module absent → focused suite red;
- incremental v1.1 run: write-capable, identity-mismatched, and receive-pack-admitted read-only controls each returned `ok`, causing 3/13 tests to fail for the exact control defect; after the control checks, 13/13 passed;
- read validation absent → 2 tests failed `evaluateGiteaReadValidation is not a function`; after implementation, 15/15 validate tests passed;
- estate registry, secure file resolver, Gitea transport, and audit journal each failed first because the module did not exist, then passed focused behavior suites.
Current focused evidence: 77/77 across 11 credential/command suites; package lint, typecheck, formatting, and build are green. Full package Vitest reached 1,578 passing tests and three unrelated CLI-smoke failures caused solely by the installed-version update banner writing to stderr. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and atomic; grant mutation/read-back state is journaled.
Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation.
Live validation v1.4 (subject credential's own `/user`, no admin): population 13; CONFIRMED 8; CREDENTIAL-REJECTED 4 (`coder-mos1`, `coder-mos2`, `f10-coder`, `merge-gate`); MISMATCH 1 (`mos-admin` token authenticates as `Mos`); NOT-MEASURED 0. The four false v1.2 `identity-not-found` sealed journals remain immutable and are explicitly superseded by four sealed correction journals. Evidence: `/home/hermes/agent-work/be-coder-06/live-validation-v1.4/`.
Write differential for be-coder-06 passed with the configured read-only control and unauthenticated arm. Unit evidence proves the control arm invalidates validation when write-capable, identity-mismatched, or receive-pack-admitted.
## Risks/blockers
- The full CLI surface is broad; protect scope by sharing one provider/registry/journal core rather than per-command scripts.
- Gitea exact token-scope read-back may require delegated Basic Auth. If a bearer-only validation path cannot obtain an exact provider token object, return `indeterminate` rather than claim a scope.
- #1044 hold is LIFTED. The four least-privilege credentials are capability-confirmed and identity-not-measured, not dead. Fleet fail-closed paths now refuse with stable reason markers and never enter shared fallback under `MOSAIC_AGENT_NAME`; interactive callers retain explicit shared behavior. Runtime mismatch coverage remains limited to tokens holding `read:user`; future mints close identity binding at creation without widening seat scopes.
- C1 PR #1054 must first be rebuilt from only its four commits on `main`; the retargeted head `8b067839` carries 13 unrelated `next` commits and is not merge-eligible. MC-CRED remains sequenced after the clean C1 merge.
@@ -0,0 +1,38 @@
# Scratchpad — FED-M3-06 get verb
## Objective
Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads.
## Scope
- `apps/gateway/src/federation/server/verbs/get.controller.ts`
- `apps/gateway/src/federation/server/verbs/get-query.service.ts`
- Unit coverage for controller pipeline + query service RBAC guardrails
- Register controller/service in `FederationModule`
## Plan
1. Mirror the list verb pipeline: `FederationAuthGuard``FederationScopeService` → read-only query service.
2. Return one `_source: "local"` tagged item on success.
3. Return federation error envelopes:
- `404 not_found` when the resource id does not exist.
- `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection.
- `400 invalid_request` for malformed ids/scope requests.
4. Keep read audit persistence deferred to M4; no body or response persistence in M3.
## Verification Evidence
- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services.
- Review-change coverage added for comment 15971:
- get note access now requires subject ownership AND authorized mission intersection.
- missing federation context returns structured `401 unauthorized` envelope.
- unsupported get resources fail closed with structured denial.
- PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion.
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes).
- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes).
- `pnpm build` — pass (23 successful tasks before review changes).
- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes).
- `pnpm lint` — pass (23 successful tasks; re-run after review changes).
- `pnpm format:check` — pass (re-run after review changes).
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.
@@ -0,0 +1,82 @@
# B1 / @next Durable Publish Pipeline — Design
## Objective
Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds.
Every merge to `next` publishes:
1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`.
2. **Gateway container image** tagged only as `gateway:sha-<short>`.
The existing stable release behavior remains isolated to `main` / tags.
## Registry verification
Target registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`.
Pre-implementation checks:
- `npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json` returned a dist-tags object (`latest: 0.0.48`).
- `npm view @mosaicstack/mosaic@latest version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` resolved `0.0.48`.
- `@next` currently returns 404 because no `next` dist-tag exists yet; this is expected before the first next prerelease publish.
Pipeline design includes a post-publish verification that `npm view @mosaicstack/mosaic@next version` resolves to the exact CI-computed prerelease version. If Gitea fails to honor the `next` dist-tag, the pipeline fails closed.
## Version scheme
The prerelease version is computed at publish time only; no `package.json` version changes are committed.
For each non-private `@mosaicstack/*` package:
```text
<target-stable>-next.<CI_PIPELINE_NUMBER>
```
Where:
- `CI_PIPELINE_NUMBER` is Woodpecker's monotonic pipeline number.
- `target-stable` is the package's current committed stable version with the patch component incremented.
- Example: `@mosaicstack/mosaic` `0.0.48` publishes as `0.0.49-next.1626`.
- Example: `@mosaicstack/gateway` `0.0.6` publishes as `0.0.7-next.1626`.
Rationale:
- npm semver sorts `0.0.49-next.1627` above `0.0.49-next.1626`.
- The prerelease does not overtake the future stable `0.0.49`.
- The monotonic pipeline number avoids conflicts across repeated `next` merges.
## Branch and tag guardrails
| Pipeline path | Branch/event | Publishes | Forbidden |
| --------------------- | ------------------------------ | ------------------------------------------------------- | ---------------------- |
| stable npm publish | `main` push/manual or tag | package versions already committed in package manifests | `@next` dist-tag |
| next npm publish | `next` push/manual only | CI-computed prereleases with `--tag next` | `latest` dist-tag |
| gateway image | `main` push/manual or tag | `sha-<short>` + `latest` on main + tag on tag events | next prerelease npm |
| gateway image | `next` push/manual only | `sha-<short>` only | `latest` |
| appservice/web images | `main` push/manual or tag only | existing stable image behavior | next image publication |
The pipeline has explicit branch checks inside the publish commands as a second fail-closed layer beyond Woodpecker `when` clauses.
## Implementation plan
1. Widen `.woodpecker/publish.yml` top-level `when` to include `next` so the publish pipeline runs on next merges.
2. Keep existing `publish-npm` on `main` / tags only.
3. Add `publish-next-npm` for `next` push/manual only:
- configure Gitea npm auth from existing `gitea_token` secret as `NPM_TOKEN`;
- preflight registry dist-tag metadata;
- compute prerelease versions in CI by temporarily editing package manifests in the workspace;
- run `pnpm publish ... --tag next` against non-private `@mosaicstack/*` packages;
- verify `@mosaicstack/mosaic@next` resolves to the computed version.
4. Split image `when` anchors:
- `image_build_when` includes `next` and is used by `build-gateway`;
- `main_image_build_when` keeps appservice/web on main/tags only.
5. Keep gateway next image destinations to `sha-<short>` only; no `latest` on next.
## Risk controls
- Auth/registry failures are fatal.
- No manual image build/push path is introduced.
- No production `latest` tags are touched from `next`.
- No `@latest` npm dist-tags are touched from `next`.
- All changes live in CI config and docs; no runtime source behavior changes.
+34
View File
@@ -0,0 +1,34 @@
# B2 — Fresh-install skills sync path
## Problem
Greenfield wizard on `next` reported:
```text
Skills sync script not found at ~/.config/mosaic/bin/mosaic-sync-skills
Skills: install failed
```
## Diagnosis
The framework install migration removed the legacy `~/.config/mosaic/bin/` directory and now installs framework helper scripts under:
```text
~/.config/mosaic/tools/_scripts/
```
`packages/mosaic/src/stages/finalize.ts` still resolved wizard helper scripts from `mosaicHome/bin`, so wizard-selected skills failed even though `mosaic-sync-skills` was present in the current framework layout.
## Fix
- Resolve framework helper scripts through `tools/_scripts/<name>` first.
- Keep a legacy `bin/<name>` fallback for pre-migration installs.
- Point missing-script warnings at the current `tools/_scripts` layout.
- Update the finalize skills test fixture to model the fresh framework layout.
- Update framework README examples from legacy `bin/` helper paths to `tools/_scripts/`.
## Verification
- Unit: `pnpm --filter @mosaicstack/mosaic test -- finalize-skills`
- Gates: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build`
- Fresh path: ran `packages/mosaic/framework/install.sh` with a temp `MOSAIC_HOME` and `MOSAIC_SYNC_ONLY=1`; verified `tools/_scripts/mosaic-sync-skills` exists, legacy `bin/mosaic-sync-skills` does not, and the script installs a selected fake `lint` skill into Mosaic + Pi runtime skill directories.
@@ -0,0 +1,36 @@
# B3 — Wizard completion ordering
## Problem
The wizard printed the success summary / `Mosaic is ready.` during `finalizeStage`, before the gateway configuration stage had completed its daemon health check. If the gateway health gate later failed, the user could see a success claim followed by a gateway failure.
## Diagnosis
`finalizeStage` handled both mutation work and terminal success messaging. Wizard paths then ran `gatewayConfigStage` and `gatewayBootstrapStage` afterward:
1. finalize writes config, links runtime assets, syncs skills, runs doctor;
2. finalize prints `Installation Summary` + `Mosaic is ready.`;
3. gateway config starts/waits for daemon health;
4. gateway bootstrap runs.
The summary needed to be deferred until after the gateway readiness gates.
## Fix
- `finalizeStage` now returns a `showSummary()` callback and supports `deferSummary`.
- Wizard/quick-start paths call finalize with `deferSummary: true`.
- `showSummary()` is called only after gateway config reports ready and bootstrap completes, or immediately when the caller explicitly skips gateway setup.
- If gateway health/config reports not ready, the wizard returns/aborts without printing the success summary.
- Folded in adjacent runtime install hint fix for Pi: `curl -fsSL https://pi.dev/install.sh | sh`.
## Verification
- Added unified-wizard coverage for summary-after-health and no-summary-on-health-failure.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard finalize-skills`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: one low finding on the requested Pi `curl | sh` install hint; no security finding in the wizard completion-ordering change.
+36
View File
@@ -0,0 +1,36 @@
# B4 — Wizard step deduplication
## Problem
Greenfield wizard testing showed completed wizard steps could be executed again after the menu marked them `[done]`. In practice this made the Providers/API-key flow and Skills flow appear twice in one wizard run.
There was a second related API-key duplication path: when the Providers step was completed with no key, `gatewayConfigStage` still prompted for `ANTHROPIC_API_KEY` during Finish because it only skipped the gateway API-key prompt when `providerKey` was non-empty.
## Diagnosis
- `runMenuLoop` labeled completed sections with `[done]`, but still dispatched the selected step again if the user selected that row.
- Quick Start ran Providers and Skills but did not mark those sections complete in `completedSections`.
- `runFinishPath`/`quickStartPath` defaulted `providerType` to `none` for gateway config, which made it impossible for `gatewayConfigStage` to distinguish:
- provider step completed and user intentionally skipped the key, vs.
- provider step was never run.
## Fix
- Added a shared menu section key helper and a completed-step guard in `runMenuLoop`.
- Completed menu steps now log a skip message instead of re-running their stage.
- Quick Start marks Providers and Skills complete after running them.
- Finish/Quick Start now pass `state.providerType` as-is to gateway config instead of defaulting to `none`.
- `gatewayConfigStage` treats `providerType: 'none'` as an explicit completed provider setup with no key and skips the second gateway API-key prompt.
## Verification
- Added unified wizard regression coverage asserting repeated Providers/Skills menu selections only execute each stage once.
- Added gateway config coverage asserting `providerType: 'none'` does not prompt for a gateway API key and writes no API key env var.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard gateway-config`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: no findings.
@@ -0,0 +1,60 @@
# FED-M3-10 — Federation M3 Integration Tests
## Objective
Add single-gateway gateway integration tests for M3 acceptance #6 and #7.
## Branch / base
- Branch: `feat/federation-m3-integration`
- Base: `origin/next` (`838701bd` after M3-06/#683 merge)
- PR base when unblocked: `next`
## Scope
- Real PostgreSQL via `@mosaicstack/db`.
- Mocked TLS context / Fastify request shim for `FederationAuthGuard`.
- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`.
- Gated by `FEDERATED_INTEGRATION=1`.
- No federation harness dependency.
## Fixture notes
Aligned with the B2 seed design vocabulary:
- `tasks` visibility uses personal `projects` + `missions` chain.
- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission.
- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result.
- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers.
## Current implementation
Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering:
1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope.
2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope.
3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks.
4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded.
5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope.
## Verification
- `pnpm --filter @mosaicstack/types build` — PASS.
- `pnpm --filter @mosaicstack/db build` — PASS.
- `pnpm --filter @mosaicstack/storage build` — PASS.
- `pnpm --filter @mosaicstack/brain build` — PASS.
- `pnpm --filter @mosaicstack/queue build` — PASS.
- `pnpm --filter @mosaicstack/config build` — PASS.
- `pnpm --filter @mosaicstack/auth build` — PASS.
- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped).
- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`.
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS; approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS; risk level none, no findings.
## Push / PR
- #683 landed in `next`; branch rebased onto `origin/next` before push.
- CI is serialized; run queue guard before push.
@@ -0,0 +1,40 @@
# Installer `--next` fast npm lane — 2026-06-25
## Scope
Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback.
## Registry reality check
Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
Verified before implementation:
- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`.
- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`.
- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`.
- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`.
Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-<short>` remains the deployment/harness artifact; the npm gateway package is valid for the installer global package path.
## Behavior
- `--next` with no explicit ref:
1. framework archive from `next`;
2. resolve `@mosaicstack/gateway@next` and `@mosaicstack/mosaic@next`;
3. require both resolved versions to share the same `next.<pipeline>` suffix;
4. install the exact resolved package versions;
5. set `MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1` so wizard does not overwrite the prerelease gateway;
6. if either package is missing/unreachable/mismatched/fails, fall back to existing source build at `next`.
- `--dev` remains pure source build.
- explicit `--ref` / `MOSAIC_REF` still wins over `--next` and uses the source path for that exact ref.
## Install detail
The installer writes the scoped npmrc mapping (`@mosaicstack:registry=...`) and then runs npm install without overriding npm's default registry. Passing `--registry=<gitea>` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior.
## Verification notes
- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning.
- Wired the installer harness into `pnpm test` via `pnpm run test:installer`.
- Real temp-prefix fast install succeeded with `@mosaicstack/[email protected]` and `@mosaicstack/[email protected]`.
@@ -0,0 +1,35 @@
# Scratchpad — installer `--next` lane
## Objective
Add a prerelease installer lane for the permanent `next` integration branch.
## Scope
- `tools/install.sh`
- README/install documentation
- Follow-up design note for future npm `@next` prerelease publishing
## Plan
1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`.
2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`.
3. Update installer source display/help text.
4. Document three lanes:
- stable npm `@latest`
- prerelease `--next`
- contributor `--dev --ref X`
5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight.
## Verification
- `bash -n tools/install.sh` — pass.
- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass.
- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`.
- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins.
- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins.
- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path).
- `pnpm typecheck` — pass (41 successful tasks).
- `pnpm lint` — pass (23 successful tasks).
- `pnpm format:check` — pass.
- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure.
@@ -0,0 +1,93 @@
# W-B — Measure Pi's real tool registry
- **Task / internal ref:** W-B from the lease-remediation orchestrator brief (no matching `docs/TASKS.md` row; workers do not modify that file)
- **Objective:** identify the exact tool names emitted as `event.toolName` by the installed Pi runtime and compare them with the broker's Pi read-only carve-out.
- **Scope:** measurement and report only; no broker or runtime source changes. W-C is out of scope.
- **Budget:** no explicit token cap; constrained to this scratchpad and one local commit.
- **Installed runtime:** `@earendil-works/pi-coding-agent` / `pi` `0.84.1`.
## Method
I created a throwaway extension at `/tmp/measure-pi-tool-registry.ts` (not in the worktree). On `session_start` it recorded `pi.getAllTools()` and `pi.getActiveTools()`; on every `tool_call` it appended the exact `event.toolName`. I then launched an isolated, ephemeral Pi session with all built-ins explicitly selected:
```text
PI_OFFLINE=1 pi --mode print --no-session --no-approve \
--no-context-files --no-skills --no-prompt-templates --no-extensions \
-e /tmp/measure-pi-tool-registry.ts \
--tools read,bash,edit,write,grep,find,ls <deterministic probe prompt>
```
The prompt exercised file read, content search, file search, directory listing, shell execution, file write, and file edit. Pi exited `0`; every selected tool produced one `tool_call`. The write/edit control artifact ended with exact content `after`, proving the mutating calls executed in order.
This runtime observation was cross-checked against the installed distribution's canonical registry at `dist/core/tools/index.js:17`, which declares the same seven names. The gate consumes the measured field directly at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:368`.
## Exact distinct built-in set
The installed Pi built-in registry is exactly:
```text
{bash, edit, find, grep, ls, read, write}
```
| Tool | Runtime registry observation | `tool_call` observation | Installed definition |
| --- | --- | --- | --- |
| `read` | `<builtin:read>` | observed once | `dist/core/tools/read.js:138` |
| `bash` | `<builtin:bash>` | observed once | `dist/core/tools/bash.js:231` |
| `edit` | `<builtin:edit>` | observed once | `dist/core/tools/edit.js:170` |
| `write` | `<builtin:write>` | observed once | `dist/core/tools/write.js:138` |
| `grep` | `<builtin:grep>` | observed once | `dist/core/tools/grep.js:79` |
| `find` | `<builtin:find>` | observed once | `dist/core/tools/find.js:79` |
| `ls` | `<builtin:ls>` | observed once | `dist/core/tools/ls.js:61` |
The raw distinct `event.toolName` result was:
```json
["bash", "edit", "find", "grep", "ls", "read", "write"]
```
Pi registers all seven, but its default active set is only `read`, `bash`, `edit`, and `write` (`dist/core/sdk.js:132`). The probe explicitly activated all seven so the three search/list tools could be observed at the hook.
## Positive control
The known `read` tool was the control. The method surfaced it twice:
1. `pi.getAllTools()` returned `read` with source path `<builtin:read>`.
2. Reading `/tmp/pi-registry-probe/seed.txt`, which contained `CONTROL_TOKEN`, produced one hook record with `event.toolName === "read"`.
The control was therefore positive; the seven-name result is measured, not an empty-probe inference.
## Carve-out comparison and collision result
The broker currently declares `{"read", "grep", "find", "ls"}` at `packages/mosaic/framework/tools/lease-broker/daemon.py:54`.
- `read`: real built-in.
- `grep`: real built-in.
- `find`: real built-in.
- `ls`: real built-in.
All four carve-out names are exact, case-sensitive Pi tool names.
The general execution/writing tool names are `bash`, `edit`, and `write`. Their intersection with the carve-out is empty:
```text
{bash, edit, write} ∩ {read, grep, find, ls} = ∅
```
Therefore no general shell-exec or file-mutating Pi tool shares a name with a carve-out entry. `grep` and `find` may invoke constrained search helpers internally, but neither exposes an arbitrary command interface; the arbitrary command tool is distinctly named `bash`.
The Mosaic extension separately registers the non-built-in custom tool `mosaic_context_recover` at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:379`; the broker handles that identity through its dedicated recovery exemption rather than the read-only set (`daemon.py:722`). Unknown or third-party custom tools are not part of Pi's built-in seven-name registry and remain outside the carve-out.
## Verification evidence
- `pi --version``0.84.1`.
- Isolated probe exit → `0`.
- Runtime `getAllTools()` count → `7`, all with `sourceInfo.source === "builtin"`.
- Distinct hook names → `bash`, `edit`, `find`, `grep`, `ls`, `read`, `write`.
- Hook counts → exactly one call for each of the seven names.
- Mutation artifact after `write` then `edit` → exact content `after`.
- Installed registry source → `allToolNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"])`.
## Risks / limitations
- The probe deliberately disabled all other extensions, so extension-defined third-party tools were excluded from the built-in registry measurement. The production gate still receives those names and treats names outside the broker carve-out as mutating/fail-closed.
- Explicit `--tools` activation was required to exercise `grep`, `find`, and `ls`; this does not imply they are active in Pi's default four-tool configuration.
@@ -0,0 +1,99 @@
# PR merge squash message field
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
- **Owner:** `be-coder-08`
- **Branch:** `fix/pr-merge-message-field`
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
## Objective
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
## Binding requirements
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 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
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
- 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.
## Canonical delivery plan
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 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.
## Remediation and current review state
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 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 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 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`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
## Fixture preflight
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
## Fixture result
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
- Trailer-shaped lines, verbatim and in order:
1. `Co-authored-by: be-coder-07 <[email protected]>`
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
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.
+2 -1
View File
@@ -10,7 +10,8 @@
"clean:generated": "node scripts/clean-generated.mjs",
"typecheck": "pnpm preflight && turbo run typecheck",
"test:checkout": "node --test scripts/*.test.mjs",
"test": "pnpm test:checkout && turbo run test",
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
"test:installer": "bash tools/install-next-lane.test.sh",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "node scripts/install-hooks.mjs"
@@ -11,9 +11,37 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { createConfigService } from '../../src/config/config-service.js';
import type { SelectOption } from '../../src/prompter/interface.js';
import type { MenuSection, WizardState } from '../../src/types.js';
const gatewayConfigMock = vi.fn();
const gatewayBootstrapMock = vi.fn();
const providerSetupMock = vi.fn();
const skillsSelectMock = vi.fn();
class SequencedMenuPrompter extends HeadlessPrompter {
constructor(
answers: Record<string, string | boolean | string[]>,
private readonly menuChoices: string[],
) {
super(answers);
}
override async select<T>(opts: {
message: string;
options: SelectOption<T>[];
initialValue?: T;
}): Promise<T> {
if (opts.message === 'What would you like to configure?') {
const next = this.menuChoices.shift();
if (!next) throw new Error('No queued menu choice left');
const match = opts.options.find((o) => String(o.value) === next);
if (!match) throw new Error(`Queued menu choice not available: ${next}`);
return match.value;
}
return super.select(opts);
}
}
vi.mock('../../src/stages/gateway-config.js', () => ({
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
@@ -23,6 +51,14 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
}));
vi.mock('../../src/stages/provider-setup.js', () => ({
providerSetupStage: (...args: unknown[]) => providerSetupMock(...args),
}));
vi.mock('../../src/stages/skills-select.js', () => ({
skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args),
}));
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
import { runWizard } from '../../src/wizard.js';
@@ -44,6 +80,16 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
}
gatewayConfigMock.mockReset();
gatewayBootstrapMock.mockReset();
providerSetupMock.mockReset();
skillsSelectMock.mockReset();
providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.providerType = 'none';
state.completedSections?.add('providers' satisfies MenuSection);
});
skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.selectedSkills = [];
state.completedSections?.add('skills' satisfies MenuSection);
});
// Pretend we're on an interactive TTY so the wizard's headless-abort
// branch does not call `process.exit(1)` during these tests.
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
@@ -98,8 +144,12 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
});
it('does not invoke bootstrap when config stage reports not ready', async () => {
gatewayConfigMock.mockResolvedValue({ ready: false });
it('prints the success summary only after gateway health succeeds', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
p.log('Gateway is healthy.');
return { ready: true, host: 'localhost', port: 14242 };
});
gatewayBootstrapMock.mockResolvedValue({ completed: true });
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
@@ -118,6 +168,43 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
skipGatewayNpmInstall: true,
});
const logs = prompter.getLogs();
const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.'));
const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary'));
const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.'));
expect(healthIndex).toBeGreaterThanOrEqual(0);
expect(summaryIndex).toBeGreaterThan(healthIndex);
expect(readyIndex).toBeGreaterThan(summaryIndex);
});
it('does not claim success when gateway health reports not ready', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
p.warn('Gateway did not become healthy within 30 seconds.');
return { ready: false };
});
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGatewayNpmInstall: true,
});
const logs = prompter.getLogs();
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
});
@@ -143,4 +230,34 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(gatewayConfigMock).not.toHaveBeenCalled();
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
});
it('does not re-run completed provider or skills menu steps', async () => {
const prompter = new SequencedMenuPrompter(
{
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
},
['providers', 'providers', 'skills', 'skills', 'finish'],
);
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGateway: true,
});
expect(providerSetupMock).toHaveBeenCalledTimes(1);
expect(skillsSelectMock).toHaveBeenCalledTimes(1);
expect(prompter.getLogs()).toEqual(
expect.arrayContaining([
expect.stringContaining('Providers [done] is already complete; skipping.'),
expect.stringContaining('Skills [done] is already complete; skipping.'),
]),
);
});
});
+23 -10
View File
@@ -43,6 +43,16 @@ The installer:
- Runs a health audit
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are validating a branch before release | Build-from-source at the requested git ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` wins and uses the source path.
## First Run
After install, open a new terminal (or `source ~/.bashrc`) and run:
@@ -108,8 +118,8 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
├── STANDARDS.md ← Machine-wide standards
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.)
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links)
├── runtime/ ← Runtime adapters + runtime-specific references
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
│ ├── codex/ ← instructions.md, RUNTIME.md
@@ -174,7 +184,9 @@ The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --next # Prerelease lane: npm @next, source fallback
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
@@ -184,10 +196,11 @@ The installer rejects unrecognized flags or positional arguments before making c
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
```bash
mosaic sync # Full canonical catalog sync
mosaic skill list # Show registered, missing, dangling, and foreign entries
mosaic skill register <name> # Register or repair one canonical Claude link
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
mosaic sync # Full canonical catalog sync
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
mosaic skill list # Show registered, missing, dangling, and foreign entries
mosaic skill register <name> # Register or repair one canonical Claude link
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
```
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
@@ -197,8 +210,8 @@ M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosai
## Health Audit
```bash
mosaic doctor # Standard audit
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
mosaic doctor # Standard audit
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
```
## MCP Registration
@@ -209,8 +222,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it
To verify or re-register manually:
```bash
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check
```
### Claude Code MCP Registration
@@ -0,0 +1 @@
Mosaic lease promotion was processed mechanically; no action is needed.
@@ -32,6 +32,18 @@
]
}
],
"UserPromptSubmit": [
{
"matcher": "^/mosaic-promote$",
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
"timeout": 15
}
]
}
],
"PreToolUse": [
{
"matcher": ".*",
@@ -81,8 +93,8 @@
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
"timeout": 3
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
"timeout": 15
},
{
"type": "command",
@@ -233,6 +233,14 @@ for runtime_file in \
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
done
if [[ -d "$MOSAIC_HOME/runtime/claude/commands" ]]; then
mkdir -p "$HOME/.claude/commands"
for command_file in "$MOSAIC_HOME/runtime/claude/commands/"*; do
[[ -f "$command_file" ]] || continue
copy_file_managed "$command_file" "$HOME/.claude/commands/$(basename "$command_file")"
done
fi
# OpenCode runtime adapter (thin pointer to AGENTS.md)
opencode_adapter="$MOSAIC_HOME/runtime/opencode/AGENTS.md"
if [[ -f "$opencode_adapter" ]]; then
@@ -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"
@@ -499,17 +499,8 @@ get_gitea_url_for_host() {
# Resolve a Gitea API token for the given host.
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
_trace_credential_resolution() {
[[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]] || return 0
local reason="$1" identity="$2" host="$3" source="$4"
local shared_path_entered=false
[[ "$_resolution_path" == shared ]] && shared_path_entered=true
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \
"$reason" "$identity" "$host" "$_resolution_path" "$shared_path_entered" "$source" >&2
}
get_gitea_token() {
local host="$1" _resolution_path=unresolved
local host="$1"
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local cred_loader="$script_dir/../_lib/credentials.sh"
@@ -525,16 +516,6 @@ get_gitea_token() {
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
_ident_src="git config mosaic.gitIdentity"
fi
if [[ -n "$_ident" && ! "$_ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity=<invalid> host=%s shared_path_entered=false source=%s\n' \
"$host" "$_ident_src" >&2
return 1
fi
if [[ -n "${MOSAIC_AGENT_NAME:-}" && -n "$_ident" && "$_ident" != "$MOSAIC_AGENT_NAME" ]]; then
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=%s\n' \
"$_ident" "$MOSAIC_AGENT_NAME" "$host" "$_ident_src" >&2
return 1
fi
if [[ -n "$_ident" ]]; then
local _idpfx=""
case "$host" in
@@ -543,23 +524,8 @@ get_gitea_token() {
esac
if [[ -n "$_idpfx" ]]; then
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
if [[ -e "$_idcred" || -L "$_idcred" ]]; then
local _resolved_token
_resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \
"$HOME/.config/mosaic/secrets/gitea-tokens" "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1
_resolution_path=identity
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
printf '%s\n' "$_resolved_token"
return 0
fi
if [[ -e "$_idtok" || -L "$_idtok" ]]; then
local _resolved_token
_resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" \
"$HOME/.config/mosaic/secrets/gitea-tokens" "$_idtok") || return 1
_resolution_path=identity
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
printf '%s\n' "$_resolved_token"
if [[ -r "$_idtok" ]]; then
cat "$_idtok"
return 0
fi
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
@@ -568,21 +534,12 @@ get_gitea_token() {
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
# caller aborts loudly rather than acting as the wrong identity.
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=%s path=%s\n' \
"$_ident" "$host" "$_ident_src" "$_idtok" >&2
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2
return 1
fi
fi
# Fleet automation never borrows a shared human/default credential. An
# explicit interactive caller may still reach the shared paths below, but
# a fleet process must name an identity and resolve that identity exactly.
if [[ -n "${MOSAIC_AGENT_NAME:-}" ]]; then
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity=<unset> host=%s shared_path_entered=false source=MOSAIC_AGENT_NAME\n' \
"$host" >&2
return 1
fi
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
if [[ -f "$cred_loader" ]]; then
local token
@@ -614,8 +571,6 @@ get_gitea_token() {
echo "${GITEA_TOKEN:-}"
)
if [[ -n "$token" ]]; then
_resolution_path=shared
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
echo "$token"
return 0
fi
@@ -624,8 +579,6 @@ get_gitea_token() {
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
if [[ -n "${GITEA_TOKEN:-}" ]]; then
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
_resolution_path=shared
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" environment
echo "$GITEA_TOKEN"
return 0
fi
@@ -637,8 +590,6 @@ get_gitea_token() {
local token
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
if [[ -n "$token" ]]; then
_resolution_path=shared
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" git-credentials
echo "$token"
return 0
fi
@@ -24,79 +24,29 @@ while IFS= read -r line; do
username=*) username_in=${line#username=};;
esac
done
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
trace_resolution() {
[ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ] || return 0
reason="$1" trace_identity="$2" trace_host="$3" source="$4"
shared_path_entered=false
[ "$resolution_path" = shared ] && shared_path_entered=true
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \
"$reason" "$trace_identity" "$trace_host" "$resolution_path" "$shared_path_entered" "$source" >&2
}
resolution_path=unresolved
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
# survives across non-persistent shells) > git-supplied username (credential.username
# / URL). When the resolved identity has a matching per-agent token, use it instead of
# the shared account. Backward-compatible: nothing resolvable → shared token.
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
*) idpfx="";;
esac
ident="$MOSAIC_GIT_IDENTITY"
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
[ -z "$ident" ] && ident="$username_in"
if [[ -n "$ident" && ! "$ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity=<invalid> host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2
exit 1
fi
if [ -n "$idpfx" ] && [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then
echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
"$ident" "$MOSAIC_AGENT_NAME" "$host" >&2
exit 1
fi
if [ -n "$ident" ]; then
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
*) idpfx="";;
esac
if [ -n "$idpfx" ]; then
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
if [ -e "$idcred" ] || [ -L "$idcred" ]; then
token=$(python3 "$script_dir/resolve-credential-envelope.py" \
"$HOME/.config/mosaic/secrets/gitea-tokens" "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1
resolution_path=identity
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
if [ -r "$idtok" ]; then
echo "username=${ident}"
echo "password=${token}"
echo "password=$(cat "$idtok")"
exit 0
fi
if [ -e "$idtok" ] || [ -L "$idtok" ]; then
token=$(python3 "$script_dir/resolve-legacy-token.py" \
"$HOME/.config/mosaic/secrets/gitea-tokens" "$idtok") || exit 1
resolution_path=identity
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
echo "username=${ident}"
echo "password=${token}"
exit 0
fi
if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then
echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=git-credential-mosaic path=%s\n' \
"$ident" "$host" "$idtok" >&2
exit 1
fi
fi
fi
if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -z "$ident" ]; then
case "$host" in
git.uscllc.com|git.mosaicstack.dev)
echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity=<unset> host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2
exit 1
;;
esac
fi
case "$host" in
git.uscllc.com) svc=gitea-usc;;
git.mosaicstack.dev) svc=gitea-mosaicstack;;
@@ -105,11 +55,10 @@ esac
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
# of where the framework installer places tools/ under $HOME — mirrors
# detect-platform.sh's own cred_loader resolution in this same directory.
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../_lib/credentials.sh
source "$script_dir/../_lib/credentials.sh"
load_credentials "$svc" >/dev/null 2>&1 || exit 0
resolution_path=shared
trace_resolution shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
# GITEA_USER is not populated by load_credentials (it only exports
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
# git-over-HTTP auth authenticates from the token itself (the password field),
+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"
;;
*)
@@ -1,80 +0,0 @@
#!/usr/bin/env python3
"""Fail-closed reader for one governed Mosaic credential envelope."""
import hashlib
import json
import os
import stat
import sys
MAX_BYTES = 64 * 1024
EXPECTED_KEYS = {
"schemaVersion",
"identity",
"estate",
"host",
"providerLogin",
"tokenName",
"scopes",
"createdAt",
"tokenDigest",
"token",
}
def refuse(message: str) -> None:
print(f"credential envelope refused: {message}", file=sys.stderr)
raise SystemExit(1)
if len(sys.argv) != 6:
refuse("expected governed root, path, identity, estate, and host")
root, path, identity, estate, host = sys.argv[1:]
if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root):
refuse("credential is not a direct child of the governed root")
if not estate:
refuse("explicit estate is required")
parent = os.path.dirname(path)
try:
parent_stat = os.stat(parent, follow_symlinks=False)
except OSError:
refuse("credential directory unavailable")
if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode):
refuse("credential directory is not a real directory")
if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022:
refuse("credential directory owner or mode is unsafe")
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
except OSError:
refuse("credential file unavailable or symbolic")
try:
file_stat = os.fstat(fd)
if not stat.S_ISREG(file_stat.st_mode):
refuse("credential is not a regular file")
if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077:
refuse("credential owner or mode is unsafe")
content = os.read(fd, MAX_BYTES + 1)
if len(content) > MAX_BYTES:
refuse("credential exceeds size limit")
finally:
os.close(fd)
try:
value = json.loads(content)
except (UnicodeDecodeError, json.JSONDecodeError):
refuse("credential is not valid JSON")
if not isinstance(value, dict) or set(value) != EXPECTED_KEYS:
refuse("credential schema is not exact")
if (
value.get("schemaVersion") != 1
or value.get("identity") != identity
or value.get("estate") != estate
or value.get("host") != host
or value.get("providerLogin") != identity
):
refuse("credential binding does not match requested identity, estate, host, and principal")
token = value.get("token")
if not isinstance(token, str) or not token or any(ch.isspace() for ch in token):
refuse("credential token is invalid")
if value.get("tokenDigest") != hashlib.sha256(token.encode()).hexdigest():
refuse("credential digest does not match token")
sys.stdout.write(token + "\n")
@@ -1,51 +0,0 @@
#!/usr/bin/env python3
"""Fail-closed reader for one legacy per-seat token file."""
import os
import stat
import sys
MAX_BYTES = 16 * 1024
def refuse(message: str) -> None:
print(f"legacy credential refused: {message}", file=sys.stderr)
raise SystemExit(1)
if len(sys.argv) != 3:
refuse("expected governed root and token path")
root, path = sys.argv[1:]
if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root):
refuse("credential is not a direct child of the governed root")
parent = os.path.dirname(path)
try:
parent_stat = os.stat(parent, follow_symlinks=False)
except OSError:
refuse("credential directory unavailable")
if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode):
refuse("credential directory is not a real directory")
if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022:
refuse("credential directory owner or mode is unsafe")
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
except OSError:
refuse("credential file unavailable or symbolic")
try:
file_stat = os.fstat(fd)
if not stat.S_ISREG(file_stat.st_mode):
refuse("credential is not a regular file")
if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077:
refuse("credential owner or mode is unsafe")
content = os.read(fd, MAX_BYTES + 1)
if len(content) > MAX_BYTES:
refuse("credential exceeds size limit")
finally:
os.close(fd)
try:
token = content.decode("utf-8").strip()
except UnicodeDecodeError:
refuse("credential is not UTF-8")
if not token or any(ch.isspace() for ch in token):
refuse("credential token is invalid")
sys.stdout.write(token + "\n")
@@ -9,10 +9,51 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
STATUS_OBSERVED="$WORK_DIR/status-observed"
CLOCK_LOG="$WORK_DIR/clock.log"
WATCHDOG_PYTHON="/usr/bin/python3"
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
WATCHDOG_TIMEOUT_SEC=5
WATCHDOG_EXIT=90
FEATURE_BRANCH="fix/rm-03-fixture"
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
exit 1
fi
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
cat > "$WATCHDOG_SCRIPT" <<'PY'
import os
import signal
import subprocess
import sys
if len(sys.argv) < 3:
raise SystemExit(2)
timeout_seconds = float(sys.argv[1])
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
try:
return_code = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
print(
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
"before completing its intended path",
file=sys.stderr,
)
raise SystemExit(90)
if return_code < 0:
raise SystemExit(128 - return_code)
raise SystemExit(return_code)
PY
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
@@ -33,6 +74,9 @@ printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
case "$url" in
*/branches/*)
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
while :; do :; done
fi
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
exit 7
fi
@@ -44,6 +88,7 @@ case "$url" in
fi
;;
*/status)
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
case "${MOSAIC_STUB_STATUS_MODE:?}" in
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
@@ -63,7 +108,31 @@ case "$url" in
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
esac
SH
chmod +x "$STUB_DIR/curl"
cat > "$STUB_DIR/date" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
echo "unexpected date invocation: $*" >&2
exit 2
fi
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
printf '1002\n'
else
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
printf '1000\n'
fi
SH
cat > "$STUB_DIR/sleep" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
SH
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
run_guard() {
local status_mode="$1"
@@ -83,13 +152,46 @@ run_guard() {
export GITEA_URL=https://git.example.test
export MOSAIC_STUB_STATUS_MODE="$status_mode"
fi
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
# Provider observation is the synchronization event. The one-second
# timeout is subject semantics under virtual time, never a wall wait.
# The absolute Python runtime uses an internal monotonic wait and kills
# the subject's isolated process group. Neither operation can resolve
# to the virtual date/sleep stubs at the front of PATH.
local subject_rc
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
subject_rc=0
else
subject_rc=$?
fi
return "$subject_rc"
)
}
failures=0
assert_provider_observed() {
local name="$1" require_expiration="${2:-0}"
if [[ ! -e "$STATUS_OBSERVED" ]]; then
echo "FAIL $name: status provider was not observed" >&2
failures=$((failures + 1))
fi
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
failures=$((failures + 1))
fi
if [[ "$require_expiration" -eq 1 ]]; then
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
echo "FAIL $name: pending path did not expire after provider observation" >&2
failures=$((failures + 1))
fi
fi
}
run_assertion() {
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
local output rc
@@ -124,6 +226,13 @@ run_assertion() {
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
if [[ "$status_mode" != "credential-unresolvable" ]]; then
if [[ "$status_mode" == "pending" ]]; then
assert_provider_observed "$name" 1
else
assert_provider_observed "$name"
fi
fi
}
set -e
@@ -140,6 +249,27 @@ run_assertion large-payload not126 large-success 'state=terminal-success'
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
# Positive liveness control: a subject mutant hangs before the branch lookup
# can reach the status provider. Only the independent real-clock watchdog may
# terminate it, and its failure must be distinct from subject timeout rc=124.
set +e
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
watchdog_rc=$?
set -e
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
failures=$((failures + 1))
fi
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
printf '%s\n' "$watchdog_output" >&2
failures=$((failures + 1))
fi
if [[ -e "$STATUS_OBSERVED" ]]; then
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
failures=$((failures + 1))
fi
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
failures=$((failures + 1))
@@ -160,6 +290,7 @@ if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1))
fi
assert_provider_observed merge-provider-unreachable
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
@@ -223,6 +354,7 @@ if [[ "$audit_failure_output" != *"audit"* ]]; then
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
failures=$((failures + 1))
fi
assert_provider_observed audit-unavailable
if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
@@ -16,7 +16,6 @@
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
@@ -35,8 +34,6 @@ mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
"$REPO_DIR"
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
cp "$SCRIPT_DIR/resolve-credential-envelope.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-credential-envelope.py"
cp "$SCRIPT_DIR/resolve-legacy-token.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-legacy-token.py"
chmod +x "$HELPER"
git -C "$REPO_DIR" init -q
@@ -87,22 +84,6 @@ git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(run_helper "git.mosaicstack.dev" "")
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/shared-trace.stderr")
err=$(cat "$WORK_DIR/shared-trace.stderr")
if [[ "$err" != *"resolution_path=shared"* || "$err" != *"shared_path_entered=true"* ]]; then
echo "FAIL: shared credential materialization did not emit its computed path" >&2
fail=1
fi
set +e
out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-unset.stderr")
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet unset identity did not stop at the resolver marker" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
@@ -112,21 +93,6 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
out=$(run_helper "git.mosaicstack.dev" "agentA")
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentA MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/identity-trace.stderr")
err=$(cat "$WORK_DIR/identity-trace.stderr")
if [[ "$err" != *"resolution_path=identity"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: identity credential did not emit its computed path" >&2
fail=1
fi
set +e
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet identity override was not refused before token resolution" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
@@ -154,16 +120,6 @@ out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
set +e
out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=no-such-agent 2>"$WORK_DIR/fleet-missing.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-missing.stderr")
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=no-token-for-identity"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet missing token did not stop at the resolver marker" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
# host prefix (gitea-usc- vs gitea-mosaicstack-).
@@ -179,45 +135,14 @@ assert_eq "host-scoped token path (cross-host must not leak): username" "usernam
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
# ---------------------------------------------------------------------------
# 7. Governed envelopes use the same binding, owner, mode, and digest checks.
# ---------------------------------------------------------------------------
envelope="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.credential.json"
python3 - "$envelope" <<'PY'
import hashlib, json, sys
secret = "agentE-envelope-token"
json.dump({
"schemaVersion": 1, "identity": "agentE", "estate": "homelab",
"host": "git.mosaicstack.dev", "providerLogin": "agentE",
"tokenName": "mosaic-agentE-1", "scopes": ["write:repository"],
"createdAt": "2026-08-05T00:00:00.000Z",
"tokenDigest": hashlib.sha256(secret.encode()).hexdigest(), "token": secret,
}, open(sys.argv[1], "w", encoding="utf-8"))
PY
chmod 600 "$envelope"
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab)
assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')"
echo -n "must-not-fallback-legacy" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.token"
chmod 640 "$envelope"
set +e
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr")
rc=$?
set -e
if [[ "$rc" -eq 0 || "$out" == *"password="* ]]; then
echo "FAIL: permissive envelope was consumed" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 8. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
# remotes, e.g. github.com via a different credential helper).
# ---------------------------------------------------------------------------
out=$(run_helper "github.com" "agentA")
assert_eq "unknown host: no output" "" "$out"
out=$(run_helper "github.com" "github-user" MOSAIC_AGENT_NAME=agentA)
assert_eq "unknown host in fleet context: no output" "" "$out"
# ---------------------------------------------------------------------------
# 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
# protocol: this helper only implements get).
# ---------------------------------------------------------------------------
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
@@ -28,7 +28,6 @@
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
@@ -86,23 +85,7 @@ call_get_gitea_token() {
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(call_get_gitea_token "git.mosaicstack.dev")
assert_eq "interactive shared fallback (no identity)" "shared-mosaicstack-token" "$out"
# Fleet context with no explicit identity refuses before the shared path. This
# is the marker-emission positive control for the fail-closed mechanism.
set +e
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/stderr-fleet-unset.tmp")
rc=$?
set -e
err=$(cat "$WORK_DIR/stderr-fleet-unset.tmp")
if [[ "$rc" -eq 0 || -n "$out" ]]; then
echo "FAIL: fleet unset identity must refuse with empty stdout" >&2
fail=1
fi
if [[ "$err" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet unset identity did not emit the stable resolver refusal marker: $err" >&2
fail=1
fi
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
# ---------------------------------------------------------------------------
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
@@ -110,17 +93,8 @@ fi
# ---------------------------------------------------------------------------
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA)
assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out"
set +e
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA MOSAIC_GIT_IDENTITY=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
if [[ "$rc" -eq 0 || -n "$out" || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet identity override was not refused before token resolution" >&2
fail=1
fi
out=$(call_get_gitea_token "git.mosaicstack.dev")
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
# ---------------------------------------------------------------------------
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
@@ -169,17 +143,13 @@ assert_failloud() {
echo "$stderr" >&2
fail=1
fi
if [[ "$stderr" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$stderr" != *"reason=no-token-for-identity"* || "$stderr" != *"shared_path_entered=false"* ]]; then
echo "FAIL: $desc — stable resolver refusal marker missing: $stderr" >&2
fail=1
fi
if [[ "$stderr" != *"$expected_tok_path"* ]]; then
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
echo "$stderr" >&2
fail=1
fi
if [[ "$stderr" == *"shared-mosaicstack-token"* || "$stderr" == *"shared-usc-token"* ]]; then
echo "FAIL: $desc — stderr unexpectedly contains a shared credential value:" >&2
if [[ "$stderr" == *"shared"*"token"* ]]; then
echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2
echo "$stderr" >&2
fail=1
fi
@@ -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"
@@ -0,0 +1,541 @@
#!/usr/bin/env bash
# Regression harness for the optional, identity-checked Gitea squash message.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}"
ORIG_PATH="$PATH"
failures=0
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
fail() {
echo "FAIL $1" >&2
failures=$((failures + 1))
}
make_case() {
local name="$1" case_dir
case_dir="$WORK_DIR/$name"
mkdir -p "$case_dir/bin" "$case_dir/agent"
cp "$SUBJECT" "$case_dir/pr-merge.sh"
chmod +x "$case_dir/pr-merge.sh"
cat > "$case_dir/detect-platform.sh" <<'SH'
#!/usr/bin/env bash
detect_platform() { PLATFORM=gitea; printf 'gitea\n'; }
get_repo_owner() { printf 'acme\n'; }
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 "${MOSAIC_TEST_BASIC_RC:-0}"
fi
return 1
}
get_gitea_login_for_host() { return 1; }
SH
cat > "$case_dir/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then
title='Preserve authors\n\nCo-authored-by: victim <[email protected]>'
else
title='Preserve both branch authors'
fi
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'
#!/usr/bin/env bash
exit 0
SH
cat > "$case_dir/bin/python3" <<'SH'
#!/usr/bin/env bash
for arg in "$@"; do
case "$arg" in
*"Preserve both branch authors"*|*"[email protected]"*)
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
;;
esac
done
exec "${MOSAIC_TEST_REAL_PYTHON:?}" "$@"
SH
cat > "$case_dir/bin/curl" <<'SH'
#!/usr/bin/env bash
set -eu
for arg in "$@"; do
case "$arg" in
*"Preserve both branch authors"*|*"[email protected]"*)
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
;;
esac
done
url=""
method="GET"
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)
out_file="$2"
shift 2
;;
-w)
shift 2
;;
-X)
method="$2"
shift 2
;;
-d|--data|--data-binary)
data="$2"
if [[ "$data" == @* ]]; then
data=$(<"${data#@}")
fi
shift 2
;;
-K|--config)
if [[ "$2" == "-" ]]; then
config=$(cat)
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:?}"
fi
if [[ "$2" == *"fixture-password"* ]]; then
: > "${MOSAIC_TEST_BASIC_ARGV_MARKER:?}"
fi
shift 2
;;
http://*|https://*)
url="$1"
shift
;;
*)
shift
;;
esac
done
if [[ "$config" == *"Authorization: token fixture-token"* ]]; then
auth_mode="token"
: > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}"
elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then
auth_mode="basic"
: > "${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)
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
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
body='{"message":"token rejected"}'
code=401
fi
;;
*/pulls/42/commits*)
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified)
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}]'
;;
unsafe-identity)
body='[{"sha":"unsafe\n\u001b[31m","commit":{"author":{"name":"Unsafe","email":"not-an-email"}},"author":{"login":"unsafe"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
;;
single)
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
;;
*)
echo "unknown commits mode" >&2
exit 2
;;
esac
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
body='{"message":"token rejected"}'
code=401
fi
;;
*/pulls/42/merge)
body='{}'
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "merge" && "$auth_mode" == "token" ]]; then
body='{"message":"token rejected"}'
code=401
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "provider-error" ]]; then
body='{"message":"branch policy rejected\n\u001b[31m"}'
code=409
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "forbidden" ]]; then
body='{"message":"permission denied"}'
code=403
else
printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}"
fi
;;
*/users/*)
body='{"message":"not found"}'
code=404
;;
*)
body='{"message":"unexpected URL"}'
code=500
;;
esac
if [[ -n "$out_file" ]]; then
printf '%s' "$body" > "$out_file"
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" \
"$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" "$case_dir/bin/python3"
printf '%s\n' "$case_dir"
}
run_case() {
local case_dir="$1" mode="$2"
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" \
MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \
MOSAIC_TEST_BASIC_CONFIG_MARKER="$case_dir/basic-via-config" \
MOSAIC_TEST_TOKEN_RESOLUTION_LOG="$case_dir/token-resolution.log" \
MOSAIC_TEST_BASIC_RESOLUTION_LOG="$case_dir/basic-resolution.log" \
MOSAIC_TEST_METADATA_ARGV_MARKER="$case_dir/metadata-in-argv" \
MOSAIC_TEST_REAL_PYTHON="$(command -v python3)" \
AGENT_WORK_ROOT="$case_dir/agent" \
PATH="$case_dir/bin:$ORIG_PATH" \
"$case_dir/pr-merge.sh" -n 42 "$@"
}
# Verified multi-author path: the non-poster trailer is built from one commit's
# linked author.login and that same commit's author email. No /users lookup.
verified_dir=$(make_case verified)
set +e
verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
verified_rc=$?
set -e
if [[ "$verified_rc" -ne 0 ]]; then
fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output"
elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then
fail "verified multi-author merge did not reach the API payload"
else
python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields"
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload == {
"Do": "squash",
"head_commit_id": "2222222222222222222222222222222222222222",
"MergeTitleField": "Preserve both branch authors",
"MergeMessageField": "Co-authored-by: alice <[email protected]>",
}, payload
PY
fi
[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config"
[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv"
[[ ! -e "$verified_dir/metadata-in-argv" ]] || fail "verified path placed PR title or contributor email in child argv"
[[ "$(wc -l < "$verified_dir/token-resolution.log")" -eq 1 ]] || fail "verified path did not bind inspection and merge to one credential resolution"
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
# 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" -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" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)"
# 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" -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" ]] || 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.
null_dir=$(make_case null-login)
set +e
null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1)
null_rc=$?
set -e
[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK"
[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic"
[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact"
[[ "$null_output" == *"[email protected]"* ]] || fail "null-login author omitted the commit email fact"
[[ "$null_output" == *'\n\x1b[31m'* ]] || fail "null-login author diagnostic did not escape control characters"
[[ "$null_output" != *$'\033'* ]] || fail "null-login author diagnostic emitted a raw terminal escape"
[[ "$(printf '%s\n' "$null_output" | wc -l)" -eq 1 ]] || fail "null-login author diagnostic permitted newline injection"
[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal"
[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API"
# Every provider-derived field in alternate BLOCK diagnostics is log-safe too,
# including an invalid non-head SHA that contains control characters.
unsafe_dir=$(make_case unsafe-identity)
set +e
unsafe_output=$(run_case "$unsafe_dir" unsafe-identity --co-author-trailers --escalate-to tl-mosaic 2>&1)
unsafe_rc=$?
set -e
[[ "$unsafe_rc" -ne 0 ]] || fail "unsafe identity expected a non-zero BLOCK"
[[ "$unsafe_output" == *"unusable linked identity"* ]] || fail "unsafe identity omitted its BLOCK reason"
[[ "$unsafe_output" == *'\n\x1b[31m'* ]] || fail "unsafe identity SHA did not escape control characters"
[[ "$unsafe_output" != *$'\033'* ]] || fail "unsafe identity diagnostic emitted a raw terminal escape"
[[ "$(printf '%s\n' "$unsafe_output" | wc -l)" -eq 1 ]] || fail "unsafe identity diagnostic permitted newline injection"
[[ ! -e "$unsafe_dir/merge-payload.json" ]] || fail "unsafe identity BLOCK still reached the merge API"
# The provider PR title cannot add an unchecked trailer outside the constructed
# message field: multi-line and trailer-shaped titles block before mutation.
title_dir=$(make_case title-injection)
set +e
title_output=$(MOSAIC_TEST_TITLE_MODE=injection \
run_case "$title_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
title_rc=$?
set -e
[[ "$title_rc" -ne 0 ]] || fail "title trailer injection unexpectedly passed"
[[ "$title_output" == *"not one printable, non-trailer line"* ]] || fail "title injection refusal lost its diagnostic"
[[ ! -e "$title_dir/merge-payload.json" ]] || fail "title injection reached the merge API"
# Provider failures remain diagnosable after their temporary response file is
# removed, but provider-controlled control characters stay log-safe.
error_dir=$(make_case provider-error)
set +e
error_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=provider-error \
run_case "$error_dir" single 2>&1)
error_rc=$?
set -e
[[ "$error_rc" -ne 0 ]] || fail "provider error unexpectedly passed"
[[ "$error_output" == *"HTTP 409"* ]] || fail "provider error omitted the HTTP status"
[[ "$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.
forbidden_dir=$(make_case forbidden)
set +e
forbidden_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=forbidden \
run_case "$forbidden_dir" single 2>&1)
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
# without a named principal is refused before any provider operation.
principal_dir=$(make_case missing-principal)
set +e
principal_output=$(run_case "$principal_dir" verified --co-author-trailers 2>&1)
principal_rc=$?
set -e
[[ "$principal_rc" -ne 0 ]] || fail "co-author mode without a named principal unexpectedly passed"
[[ "$principal_output" == *"requires --escalate-to with a named principal"* ]] || fail "missing-principal refusal lost its diagnostic"
[[ ! -e "$principal_dir/merge-payload.json" ]] || fail "missing-principal refusal reached the merge API"
# A trailing value-taking option receives a stable CLI diagnostic instead of a
# set -u unbound-variable crash.
value_dir=$(make_case missing-principal-value)
set +e
value_output=$(run_case "$value_dir" verified --co-author-trailers --escalate-to 2>&1)
value_rc=$?
set -e
[[ "$value_rc" -ne 0 ]] || fail "missing --escalate-to value unexpectedly passed"
[[ "$value_output" == *"--escalate-to requires one principal name"* ]] || fail "missing --escalate-to value lost its diagnostic"
[[ "$value_output" != *"unbound variable"* ]] || fail "missing --escalate-to value crashed under set -u"
[[ ! -e "$value_dir/merge-payload.json" ]] || fail "missing --escalate-to value reached the merge API"
# Negative control: ordinary single-author merge remains byte-for-byte payload
# compatible and hardcoded to squash, with no optional message fields.
single_dir=$(make_case single)
set +e
single_output=$(run_case "$single_dir" single 2>&1)
single_rc=$?
set -e
if [[ "$single_rc" -ne 0 ]]; then
fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output"
elif [[ ! -s "$single_dir/merge-payload.json" ]]; then
fail "ordinary single-author merge did not reach the API payload"
else
python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed"
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
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"
[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv"
[[ "$(wc -l < "$single_dir/token-resolution.log")" -eq 1 ]] || fail "ordinary path did not use exactly one credential resolution"
# Squash is not defaultable: an explicit non-squash method must remain refused.
method_dir=$(make_case method-refusal)
set +e
method_output=$(run_case "$method_dir" single -m merge 2>&1)
method_rc=$?
set -e
[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed"
[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic"
[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API"
if [[ "$failures" -ne 0 ]]; then
echo "pr-merge message-field regression failed ($failures assertions)" >&2
exit 1
fi
echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)"
@@ -39,7 +39,7 @@ MAX_FRAME: Final = 64 * 1024
MAX_STATE: Final = 4 * 1024 * 1024
MAX_PENDING_TOKENS: Final = 256
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
MAX_LEASE_TTL_SECONDS: Final = 300
MAX_LEASE_TTL_SECONDS: Final = 3600
STATE_VERSION: Final = 1
READ_DEADLINE_SECONDS: Final = 1.0
HANDLE_QUEUE_TIMEOUT_SECONDS: Final = 1.0
@@ -50,8 +50,8 @@ LEASE_PENDING: Final = "PENDING_VERIFICATION"
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
LEASE_VERIFIED: Final = "VERIFIED"
READ_ONLY_TOOLS: Final = {
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
"pi": frozenset({"read", "grep", "find", "ls"}),
"claude": frozenset({"Read", "Grep", "Glob"}),
"pi": frozenset({"read", "ls"}),
}
RECOVERY_TOOL: Final = "mosaic_context_recover"
@@ -8,7 +8,9 @@ import json
import os
import socket
import sys
import time
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime, timezone
from pathlib import Path
from typing import Final
@@ -53,6 +55,48 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
return value
def _self_starttime() -> str | None:
"""Field 22 of our own /proc stat — the anchor starttime the broker records.
Read past the comm field's parens, since a process name may contain them.
"""
try:
raw = Path(f"/proc/{os.getpid()}/stat").read_text()
return raw.rsplit(")", 1)[1].split()[19]
except (OSError, IndexError, ValueError):
return None
def _append_launch_record(environ: Mapping[str, str], record: dict[str, object]) -> None:
"""Append one NDJSON event to the #797 Runtime Session Ledger.
`fleet/run/sessions/` is operator-classified in framework-manifest.txt and is
already covered by test-upgrade-manifest-guard.sh, so an upgrade can neither
overwrite nor prune it. Files 0600 under a 0700 dir, matching what that guard
asserts.
Never raises: a launch must not be denied over bookkeeping. But it also never
fails silently a missing record is exactly the kind of gap that made the
2026-08-06 MUTATOR_UNVERIFIED investigation cost a day.
"""
try:
mosaic_home = environ.get("MOSAIC_HOME") or str(Path.home() / ".config" / "mosaic")
directory = Path(mosaic_home) / "fleet" / "run" / "sessions"
directory.mkdir(parents=True, exist_ok=True)
os.chmod(directory, 0o700)
framed = {
"seq": time.time_ns() // 1_000_000,
"ts": datetime.now(timezone.utc).isoformat(),
**record,
}
path = directory / "events.ndjson"
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
with os.fdopen(descriptor, "w") as handle:
handle.write(json.dumps(framed, separators=(",", ":")) + "\n")
except (OSError, ValueError, TypeError) as error:
print(f"[mosaic] WARNING: launch record not written: {error}", file=sys.stderr)
def main(
argv: Sequence[str] | None = None,
*,
@@ -94,8 +138,9 @@ def main(
# silent pass and never folded into the generic registration-failure
# branch.
try:
activation_capability = probe_activation_capability(source_environment)
assert_activation_capability_matches(
probe_activation_capability(source_environment),
activation_capability,
expected_activation_capability,
)
except VersionCouplingError as version_error:
@@ -128,6 +173,32 @@ def main(
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
return 1
# Immutable launch record, half two. `mosaic` wrote `session.launch` with the
# config/provenance it knows; only this process knows the broker session id
# and the activation capability it just asserted. os.execvpe preserves the
# PID, so this PID is BOTH the anchor pid and the join key back to that
# record. Never fatal — bookkeeping must not deny a launch — but never
# silent either.
_append_launch_record(
source_environment,
{
"kind": "lease.register",
# Joins back to `mosaic`'s session.launch record. NOT pid: execRuntime()
# spawns rather than execs, so this process is a CHILD of mosaic with a
# different pid. This pid IS the broker anchor pid (os.execvpe below
# preserves it), which is a separate and still-useful fact.
"launch_id": source_environment.get("MOSAIC_LAUNCH_ID"),
"pid": os.getpid(),
"runtime": arguments.runtime,
"session_id": session_id,
"runtime_generation": generation,
"generation_file": str(generation_file),
"anchor_starttime": _self_starttime(),
"activation_capability": activation_capability,
"command": Path(command[0]).name,
},
)
environment = dict(source_environment)
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""Lease promotion client — the half the enforcement toolkit never shipped.
The enforcement half (``daemon.py`` + ``mutator-gate.py``) ships and denies. The
promotion half has no production caller anywhere in the package: as of 0.0.48,
0.0.49 and 0.0.50-next.2207, ``begin_verification`` / ``observe_receipt`` /
``promote_lease`` are invoked only by ``broker-test-client.ts``, the acceptance
spec, unit tests, and two probes under ``docs/``. Consequence: **no lease on any
host can reach VERIFIED**, so every mutator is denied ``MUTATOR_UNVERIFIED`` by a
gate nothing can satisfy.
THE PROTOCOL (``daemon.py:578-754``)
------------------------------------
1. ``begin_verification`` broker revokes, mints a challenge, and returns the
exact ``receipt`` text the MODEL must emit
2. *the model emits that text verbatim as its ENTIRE latest message*
3. the runtime adapter ships that message to the daemon-owned observer socket
4. ``observe_receipt`` -> ``PENDING_PROMOTION``
5. ``promote_lease`` -> ``VERIFIED``
THIS MODULE IMPLEMENTS 1, 4 AND 5 NEVER 2
-------------------------------------------
Step 2 is the security property, not a formality. ``is_verbatim_receipt`` uses
``hmac.compare_digest`` against the exact minted string explicitly "not a
transcript substring" (``receipt_challenge.py``). Promotion therefore requires a
live model that received the challenge in its context and echoed it exactly.
``receipt-observer-client.py`` will post ANY string as the latest assistant
message. A promotion client that posted its own receipt would satisfy the broker
while proving nothing a gate-disabler indistinguishable from a working fix
unless someone looks for it. **This module never posts a receipt.** Emitting it
belongs to the runtime adapter, where a real model turn happens.
The construction binds the exact normative source bytes. ``h_source`` /
``h_payload`` are derived by the framework's own
``normative_fragments.build_payload`` rather than reimplemented: the broker
derives them the same way and any divergence yields ``PAYLOAD_BINDING_MISMATCH``.
There must be exactly one implementation.
WHAT THE BINDING DOES *NOT* PROVE
---------------------------------
It is tempting to read a VERIFIED lease as "this agent is running THIS law".
**It does not mean that**, and writing it down that way is how the belief spread.
The broker holds no reference copy of any normative source and never opens one;
it recomputes ``h_source`` / ``h_payload`` from the fragment bytes THIS CLIENT
sent and compares them to the binding THIS CLIENT sent (``daemon.py:602-616``).
Both sides of that comparison originate here, so it detects corruption in
transit and nothing else. What the binding actually asserts is "the client
claims these bytes, self-consistently".
Making it mean the stronger thing requires the broker to re-read the on-disk
sources itself, against a manifest the agent cannot rewrite i.e. broker code
attestation under its own uid. Until then, do not cite a VERIFIED lease as
evidence of law integrity.
Usage
-----
lease_promote.py --begin # prints the receipt the MODEL must emit
lease_promote.py --complete <challenge> # after the adapter observed it
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import socket
import sys
from pathlib import Path
from typing import Final
# Isolated (`python -I`) adapter invocations must still import co-located
# framework modules; never depend on the caller's PYTHONPATH.
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
if _MODULE_DIRECTORY not in sys.path:
sys.path.insert(0, _MODULE_DIRECTORY)
from normative_fragments import NormativeFragment, build_payload # noqa: E402
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 3.0
SCHEMA_VERSION: Final = 1
MANIFEST_VERSION: Final = 1
GENERATOR_VERSION: Final = "mosaic/lease_promote@1"
DEFAULT_TTL_SECONDS: Final = 3600
# Normative sources whose exact bytes bind the lease, in binding order. Order is
# load-bearing: ``h_source`` frames the resolved sequence, so reordering changes
# the derivation. Never fabricate a source that is not on disk.
FRAGMENT_SOURCES: Final = (
"CONSTITUTION.md",
"AGENTS.md",
"SOUL.md",
"USER.md",
"STANDARDS.md",
"TOOLS.md",
)
# Framework-owned sources, reconciled on every upgrade — `install.sh:76`
# FRAMEWORK_OWNED and `config/file-adapter.ts` FRAMEWORK_OWNED_FILES — plus the
# per-runtime contract shipped under `framework/runtime/<runtime>/`. A deployment
# missing one of these is broken, not minimal, so their absence is refused rather
# than silently dropped from the binding.
#
# SOUL.md and USER.md are deliberately excluded: install.sh does not seed them
# ("intentionally NOT seeded here — they are generated by `mosaic init`"), so a
# fresh install legitimately lacks both. TOOLS.md is user-seeded on first install
# only. Absence of those three is reported, not fatal.
REQUIRED_SOURCES: Final = frozenset({"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"})
class IncompleteBinding(RuntimeError):
"""A source that must bind this lease could not be read.
**Never downgrade this to a skip.** The broker recomputes the hashes from the
fragments it is sent, so an omitted fragment is internally consistent and
``PAYLOAD_BINDING_MISMATCH`` cannot fire a partial law promotes exactly like
a complete one, and nothing downstream can tell the difference. Dropping an
unreadable source therefore does not degrade the binding, it forges a smaller
one. Fail here, where the omission is still visible.
"""
def mosaic_home() -> Path:
return Path(os.environ.get("MOSAIC_HOME") or Path.home() / ".config" / "mosaic")
def broker_socket() -> Path:
value = os.environ.get("MOSAIC_LEASE_BROKER_SOCKET")
if value:
return Path(value)
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
if runtime_dir:
return Path(runtime_dir) / "mosaic-lease" / "broker.sock"
return Path(f"/run/user/{os.getuid()}/mosaic-lease/broker.sock")
def session_identity() -> tuple[str, int, str]:
"""Session id, CURRENT generation, runtime.
The generation file wins over the env var, matching ``lease_generation.py``.
Sending a generation HIGHER than the broker's would revoke this session's own
authority (``daemon.py:342-344``), so this never guesses.
"""
session_id = os.environ["MOSAIC_LEASE_SESSION_ID"]
runtime = os.environ["MOSAIC_LEASE_RUNTIME"]
state_file = os.environ.get("MOSAIC_LEASE_GENERATION_FILE")
if state_file:
try:
return session_id, int(Path(state_file).read_text().strip()), runtime
except (OSError, ValueError):
pass
return session_id, int(os.environ["MOSAIC_RUNTIME_GENERATION"]), runtime
def build_construction(runtime: str) -> tuple[dict[str, object], object]:
"""Assemble the wire construction and derive its hashes with the sole builder."""
runtime_contract = f"runtime/{runtime}/RUNTIME.md"
sources = list(FRAGMENT_SOURCES) + [runtime_contract]
required = REQUIRED_SOURCES | {runtime_contract}
wire_fragments: list[dict[str, str]] = []
objects: list[NormativeFragment] = []
absent: list[str] = []
for source_id in sources:
try:
content = (mosaic_home() / source_id).read_bytes()
except FileNotFoundError:
# Genuinely not on disk. Legitimate only for operator-owned sources.
if source_id in required:
raise IncompleteBinding(
f"required normative source is absent: {source_id}"
) from None
absent.append(source_id)
continue
except OSError as exc:
# The path resolves but will not read — EACCES, EIO, EISDIR, ELOOP.
# That is an anomaly for EVERY source, optional ones included: an
# unreadable file is not an un-configured one, and treating it as
# absent is what lets a permission change quietly shrink the law.
raise IncompleteBinding(
f"normative source is present but unreadable: {source_id} "
f"({type(exc).__name__})"
) from exc
digest = hashlib.sha256(content).hexdigest()
wire_fragments.append(
{
"source_id": source_id,
"content_base64": base64.b64encode(content).decode("ascii"),
"expected_sha256": digest,
}
)
objects.append(NormativeFragment(source_id, content, digest))
if not wire_fragments:
raise IncompleteBinding("no normative sources found — refusing an empty binding")
# Absence is legitimate here but never invisible. The omission is already
# baked into h_source (the framed source sequence differs), but nothing
# compares h_source to an expected value, so this line is the only place a
# human learns the binding was narrower than the full set.
if absent:
print(
f"lease_promote: binding omits absent operator sources: {', '.join(absent)}",
file=sys.stderr,
)
result = build_payload(
manifest_version=MANIFEST_VERSION,
generator_version=GENERATOR_VERSION,
fragments=objects,
)
if result.injectionDecision != "ACCEPTED" or not result.promotion:
raise RuntimeError(f"construction refused locally: {result.source_reason}")
return (
{
"manifest_version": MANIFEST_VERSION,
"generator_version": GENERATOR_VERSION,
"fragments": wire_fragments,
},
result,
)
def broker_request(payload: dict[str, object]) -> dict[str, object]:
raw = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
if len(raw) > MAX_FRAME:
raise ValueError(
f"request too large ({len(raw)} bytes); broker frame cap is {MAX_FRAME}"
)
response = bytearray()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(BROKER_TIMEOUT_SECONDS)
connection.connect(str(broker_socket()))
connection.sendall(raw)
connection.shutdown(socket.SHUT_WR)
while len(response) <= MAX_FRAME:
chunk = connection.recv(4096)
if not chunk:
break
response.extend(chunk)
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
raise ValueError("invalid broker reply")
value = json.loads(response)
if not isinstance(value, dict):
raise ValueError("invalid broker reply")
return value
def begin(
ttl_seconds: int = DEFAULT_TTL_SECONDS,
compaction_epoch: int = 0,
request_epoch: int = 0,
) -> dict[str, object]:
"""Step 1. Returns the broker reply, including the exact ``receipt`` text."""
session_id, generation, runtime = session_identity()
construction, derived = build_construction(runtime)
return broker_request(
{
"action": "begin_verification",
"session_id": session_id,
"runtime_generation": generation,
"runtime": runtime,
"ttl_seconds": ttl_seconds,
"binding": {
"compaction_epoch": compaction_epoch,
"request_epoch": request_epoch,
"h_source": derived.h_source,
"h_payload": derived.h_payload,
"schema_version": SCHEMA_VERSION,
},
"construction": construction,
}
)
def complete(challenge: str) -> dict[str, object]:
"""Steps 4-5. Assumes the model already emitted the receipt and the adapter
shipped it to the observer socket."""
session_id, generation, _ = session_identity()
observed = broker_request(
{
"action": "observe_receipt",
"session_id": session_id,
"runtime_generation": generation,
"receipt_challenge": challenge,
}
)
if observed.get("ok") is not True or observed.get("state") != "PENDING_PROMOTION":
return {"stage": "observe_receipt", **observed}
promoted = broker_request(
{
"action": "promote_lease",
"session_id": session_id,
"runtime_generation": generation,
"receipt_challenge": challenge,
}
)
return {"stage": "promote_lease", **promoted}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Mosaic lease promotion client.")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--begin",
action="store_true",
help="mint a challenge; prints the receipt the MODEL must emit verbatim",
)
group.add_argument(
"--complete",
metavar="CHALLENGE",
help="observe the emitted receipt and promote the lease",
)
parser.add_argument("--ttl-seconds", type=int, default=DEFAULT_TTL_SECONDS)
arguments = parser.parse_args(argv)
try:
if arguments.begin:
print(json.dumps(begin(ttl_seconds=arguments.ttl_seconds), indent=2))
else:
print(json.dumps(complete(arguments.complete), indent=2))
except KeyError as exc:
print(f"missing lease environment: {exc}; not a lease-gated session", file=sys.stderr)
return 2
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""Claude UserPromptSubmit hook for operator-triggered lease promotion."""
from __future__ import annotations
import fcntl
import importlib.util
import json
import os
import secrets
import stat
import subprocess
import sys
import time
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Final, TextIO
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
if _MODULE_DIRECTORY not in sys.path:
sys.path.insert(0, _MODULE_DIRECTORY)
from receipt_challenge import receipt_for # noqa: E402
_observer_spec = importlib.util.spec_from_file_location(
"mosaic_receipt_observer_client", Path(__file__).resolve().with_name("receipt-observer-client.py")
)
if _observer_spec is None or _observer_spec.loader is None:
raise RuntimeError("unable to load receipt observer client")
_observer_module = importlib.util.module_from_spec(_observer_spec)
_observer_spec.loader.exec_module(_observer_module)
observer_request = _observer_module.observer_request
MAX_FRAME: Final = 64 * 1024
PENDING_MAX_AGE_SECONDS: Final = 60 * 60
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
PROMOTION_PROMPT: Final = "/mosaic-promote"
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
PENDING_DIRECTORY: Final = "mosaic-lease"
AUTHORIZATION_DIRECTORY: Final = "authorizations"
AUTHORIZATION_TTL_SECONDS: Final = 60
LEASE_TTL_SECONDS: Final = 60 * 60
LOCK_FILE: Final = "promotion.lock"
RESULT_FILE: Final = "last-result.json"
EXPECTED_BEGIN_KEYS: Final = frozenset(
{"ok", "state", "receipt_challenge", "receipt", "binding"}
)
EXPECTED_BINDING_KEYS: Final = frozenset(
{
"compaction_epoch",
"request_epoch",
"h_source",
"h_payload",
"runtime_generation",
"schema_version",
}
)
class PromotionAlreadyInProgress(RuntimeError):
pass
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
value: dict[str, object] = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate promoter JSON key")
value[key] = item
return value
def read_hook_input(stream: object) -> dict[str, object]:
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
raise ValueError("invalid UserPromptSubmit input")
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
if not isinstance(value, dict):
raise ValueError("invalid UserPromptSubmit input")
return value
def emit_context(stream: TextIO, message: str) -> None:
json.dump(
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": message,
}
},
stream,
separators=(",", ":"),
)
stream.write("\n")
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
if not runtime_dir.is_absolute():
raise ValueError("XDG_RUNTIME_DIR must be absolute")
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
raise ValueError("invalid lease session id")
return runtime_dir, f"pending-{session_id}"
def open_pending_directory(runtime_dir: Path) -> int:
directory_flags = (
os.O_RDONLY
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_DIRECTORY", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
runtime_descriptor = os.open(runtime_dir, directory_flags)
try:
runtime_metadata = os.fstat(runtime_descriptor)
if (
not stat.S_ISDIR(runtime_metadata.st_mode)
or runtime_metadata.st_uid != os.getuid()
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
):
raise ValueError("unsafe XDG runtime directory")
try:
os.mkdir(PENDING_DIRECTORY, mode=0o700, dir_fd=runtime_descriptor)
except FileExistsError:
pass
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
finally:
os.close(runtime_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISDIR(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o700
):
os.close(descriptor)
raise ValueError("unsafe promotion pending directory")
return descriptor
def acquire_lock(directory_descriptor: int) -> int:
flags = (
os.O_RDWR
| os.O_CREAT
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o600
):
os.close(descriptor)
raise ValueError("unsafe promotion lock file")
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as error:
os.close(descriptor)
raise PromotionAlreadyInProgress() from error
return descriptor
def sweep_stale_pending(directory_descriptor: int, current_time: float) -> None:
cutoff = current_time - PENDING_MAX_AGE_SECONDS
removed = False
with os.scandir(directory_descriptor) as entries:
for candidate in entries:
if not (
candidate.name.startswith("pending-")
or candidate.name.startswith(".pending-")
):
continue
try:
metadata = candidate.stat(follow_symlinks=False)
if metadata.st_mtime < cutoff and not stat.S_ISDIR(metadata.st_mode):
os.unlink(candidate.name, dir_fd=directory_descriptor)
removed = True
except FileNotFoundError:
continue
if removed:
os.fsync(directory_descriptor)
def consume_authorization(directory_descriptor: int, session_id: str, wall_clock: float) -> str | None:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
authorization_descriptor = os.open(AUTHORIZATION_DIRECTORY, flags, dir_fd=directory_descriptor)
except FileNotFoundError:
return None
try:
metadata = os.fstat(authorization_descriptor)
if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
raise ValueError("unsafe promotion authorization directory")
name = f"{session_id}.auth"
try:
descriptor = os.open(name, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=authorization_descriptor)
except FileNotFoundError:
return None
try:
token_metadata = os.fstat(descriptor)
if not stat.S_ISREG(token_metadata.st_mode) or token_metadata.st_uid != os.getuid() or stat.S_IMODE(token_metadata.st_mode) != 0o600 or token_metadata.st_size <= 0 or token_metadata.st_size > MAX_FRAME:
raise ValueError("unsafe promotion authorization")
raw = os.read(descriptor, MAX_FRAME + 1)
finally:
os.close(descriptor)
os.unlink(name, dir_fd=authorization_descriptor)
os.fsync(authorization_descriptor)
token = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
if not isinstance(token, dict) or set(token) != {"nonce", "seat", "session_id", "expires_at", "ts"}:
return None
nonce = token.get("nonce")
expires_at = token.get("expires_at")
issued_at = token.get("ts")
if token.get("session_id") != session_id or not isinstance(token.get("seat"), str) or not isinstance(nonce, str) or len(nonce) != 64 or any(char not in "0123456789abcdef" for char in nonce) or type(expires_at) not in (int, float) or type(issued_at) not in (int, float) or expires_at <= wall_clock or expires_at > issued_at + AUTHORIZATION_TTL_SECONDS:
return None
return nonce
finally:
os.close(authorization_descriptor)
def write_result(directory_descriptor: int, attempt_id: str, verified: bool, reason: str | None, session_id: str, wall_clock: float) -> None:
temporary = f".{RESULT_FILE}.tmp-{secrets.token_hex(8)}"
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=directory_descriptor)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
json.dump({"attempt_id": attempt_id, "expires_at_wallclock": wall_clock + LEASE_TTL_SECONDS if verified else None, "reason": reason, "session_id": session_id, "ts": wall_clock, "verified": verified}, stream, separators=(",", ":"), sort_keys=True)
stream.flush(); os.fsync(stream.fileno())
os.replace(temporary, RESULT_FILE, src_dir_fd=directory_descriptor, dst_dir_fd=directory_descriptor)
os.fsync(directory_descriptor)
finally:
os.close(descriptor)
def write_pending(directory_descriptor: int, name: str, challenge: str) -> None:
temporary = f".{name}.tmp-{secrets.token_hex(8)}"
flags = (
os.O_WRONLY
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
stream.write(challenge)
stream.flush()
os.fsync(stream.fileno())
os.replace(
temporary,
name,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
)
os.fsync(directory_descriptor)
except Exception:
try:
os.unlink(temporary, dir_fd=directory_descriptor)
except FileNotFoundError:
pass
raise
finally:
os.close(descriptor)
def parse_begin_reply(
completed: subprocess.CompletedProcess[str],
) -> tuple[str, dict[str, object] | None]:
if completed.returncode != 0:
return f"PROMOTER_EXIT_{completed.returncode}", None
try:
value = json.loads(
completed.stdout,
object_pairs_hook=reject_duplicate_json_keys,
)
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
return "INVALID_PROMOTER_REPLY", None
if not isinstance(value, dict):
return "INVALID_PROMOTER_REPLY", None
if value.get("ok") is False and set(value) == {"ok", "code"}:
code = value.get("code")
return code if isinstance(code, str) and code else "PROMOTION_BEGIN_REFUSED", value
if set(value) != EXPECTED_BEGIN_KEYS or value.get("ok") is not True:
return "INVALID_PROMOTER_REPLY", None
if value.get("state") != "PENDING_VERIFICATION":
return "INVALID_PROMOTER_REPLY", None
challenge = value.get("receipt_challenge")
receipt = value.get("receipt")
binding = value.get("binding")
if (
not isinstance(challenge, str)
or len(challenge) != 64
or any(character not in "0123456789abcdef" for character in challenge)
or not isinstance(receipt, str)
or not isinstance(binding, dict)
or set(binding) != EXPECTED_BINDING_KEYS
):
return "INVALID_PROMOTER_REPLY", None
integer_fields = (
"compaction_epoch",
"request_epoch",
"runtime_generation",
"schema_version",
)
if any(type(binding.get(field)) is not int or binding[field] < 0 for field in integer_fields):
return "INVALID_PROMOTER_REPLY", None
if not all(
isinstance(binding.get(field), str)
and len(binding[field]) == 64
and all(character in "0123456789abcdef" for character in binding[field])
for field in ("h_source", "h_payload")
):
return "INVALID_PROMOTER_REPLY", None
if not secrets.compare_digest(
receipt.encode("utf-8"),
receipt_for(challenge, binding).encode("utf-8"),
):
return "INVALID_PROMOTER_REPLY", None
return "", value
def main(
*,
environ: Mapping[str, str] | None = None,
stdin: object | None = None,
stdout: TextIO | None = None,
stderr: TextIO | None = None,
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
now: Callable[[], float] = time.time,
) -> int:
source_environment = os.environ if environ is None else environ
input_stream = sys.stdin if stdin is None else stdin
output_stream = sys.stdout if stdout is None else stdout
error_stream = sys.stderr if stderr is None else stderr
try:
hook_input = read_hook_input(input_stream)
except (OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
print(f"Mosaic promotion trigger ignored invalid hook input: {error}", file=error_stream)
return 0
if hook_input.get("prompt") != PROMOTION_PROMPT:
return 0
directory_descriptor: int | None = None
lock_descriptor: int | None = None
try:
runtime_dir, pending_name = session_pending_name(source_environment)
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
directory_descriptor = open_pending_directory(runtime_dir)
lock_descriptor = acquire_lock(directory_descriptor)
wall_clock = now()
nonce = consume_authorization(directory_descriptor, session_id, wall_clock)
if nonce is None:
write_result(directory_descriptor, "0" * 64, False, "NOT_AUTHORIZED", session_id, wall_clock)
print("Mosaic promotion denied: NOT_AUTHORIZED.", file=error_stream)
return 0
sweep_stale_pending(directory_descriptor, wall_clock)
completed = run([sys.executable, "-I", "-S", "-B", str(PROMOTER), "--begin"], check=False, capture_output=True, text=True, env=dict(source_environment), timeout=PROMOTER_TIMEOUT_SECONDS)
code, reply = parse_begin_reply(completed)
if code or reply is None:
write_result(directory_descriptor, nonce, False, code or "PROMOTION_BEGIN_FAILED", session_id, now())
return 0
challenge = str(reply["receipt_challenge"])
observation = observer_request(
Path(source_environment["MOSAIC_RECEIPT_OBSERVER_SOCKET"]),
{"action": "record_runtime_observation", "session_id": session_id, "runtime_generation": int(source_environment["MOSAIC_RUNTIME_GENERATION"]), "runtime": "claude", "latest_assistant_message": reply["receipt"]},
)
if set(observation) != {"ok"} or observation.get("ok") is not True:
write_result(directory_descriptor, challenge, False, "OBSERVATION_REJECTED", session_id, now())
return 0
completion = run([sys.executable, "-I", "-S", "-B", str(PROMOTER), "--complete", challenge], check=False, capture_output=True, text=True, env=dict(source_environment), timeout=PROMOTER_TIMEOUT_SECONDS)
try:
outcome = json.loads(completion.stdout, object_pairs_hook=reject_duplicate_json_keys)
except (json.JSONDecodeError, ValueError):
outcome = None
if completion.returncode == 0 and isinstance(outcome, dict) and outcome.get("stage") == "promote_lease" and outcome.get("ok") is True and outcome.get("state") == "VERIFIED":
write_result(directory_descriptor, challenge, True, None, session_id, now())
else:
reason = outcome.get("code") if isinstance(outcome, dict) and isinstance(outcome.get("code"), str) else "PROMOTION_INCOMPLETE"
write_result(directory_descriptor, challenge, False, reason, session_id, now())
except PromotionAlreadyInProgress:
print("Mosaic promotion denied: PROMOTION_ALREADY_IN_PROGRESS.", file=error_stream)
except (KeyError, OSError, RecursionError, ValueError, subprocess.SubprocessError) as error:
print(f"Mosaic promotion begin failed: {type(error).__name__}: {error}", file=error_stream)
finally:
if lock_descriptor is not None:
os.close(lock_descriptor)
if directory_descriptor is not None:
os.close(directory_descriptor)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""Claude Stop hook that completes a pending operator-triggered promotion."""
from __future__ import annotations
import fcntl
import json
import os
import secrets
import stat
import subprocess
import sys
import time
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Final, NamedTuple, TextIO
MAX_FRAME: Final = 64 * 1024
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
LEASE_TTL_SECONDS: Final = 60 * 60
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
PENDING_DIRECTORY: Final = "mosaic-lease"
LOCK_FILE: Final = "promotion.lock"
RESULT_FILE: Final = "last-result.json"
TERMINAL_FAILURE_CODES: Final = frozenset(
{
"RECEIPT_REPLAY",
"RECEIPT_MISMATCH",
"INVALID_LEASE_TRANSITION",
"PROMOTION_TOKEN_INVALID",
}
)
class PendingChallenge(NamedTuple):
value: str
device: int
inode: int
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
value: dict[str, object] = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate promoter JSON key")
value[key] = item
return value
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
if not runtime_dir.is_absolute():
raise ValueError("XDG_RUNTIME_DIR must be absolute")
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
raise ValueError("invalid lease session id")
return runtime_dir, f"pending-{session_id}"
def open_pending_directory(runtime_dir: Path) -> int | None:
directory_flags = (
os.O_RDONLY
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_DIRECTORY", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
try:
runtime_descriptor = os.open(runtime_dir, directory_flags)
except FileNotFoundError:
return None
try:
runtime_metadata = os.fstat(runtime_descriptor)
if (
not stat.S_ISDIR(runtime_metadata.st_mode)
or runtime_metadata.st_uid != os.getuid()
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
):
raise ValueError("unsafe XDG runtime directory")
try:
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
except FileNotFoundError:
return None
finally:
os.close(runtime_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISDIR(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o700
):
os.close(descriptor)
raise ValueError("unsafe promotion pending directory")
return descriptor
def acquire_lock(directory_descriptor: int) -> int:
flags = (
os.O_RDWR
| os.O_CREAT
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o600
):
os.close(descriptor)
raise ValueError("unsafe promotion lock file")
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(descriptor)
raise
return descriptor
def read_pending(directory_descriptor: int, name: str) -> PendingChallenge | None:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(name, flags, dir_fd=directory_descriptor)
except FileNotFoundError:
return None
try:
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o600
or metadata.st_size <= 0
or metadata.st_size > MAX_FRAME
):
raise ValueError("unsafe promotion pending file")
raw = os.read(descriptor, MAX_FRAME + 1)
finally:
os.close(descriptor)
if len(raw) > MAX_FRAME:
raise ValueError("oversized promotion challenge")
challenge = raw.decode("utf-8")
if (
len(challenge) != 64
or any(character not in "0123456789abcdef" for character in challenge)
):
raise ValueError("invalid promotion challenge")
return PendingChallenge(challenge, metadata.st_dev, metadata.st_ino)
def write_result(
directory_descriptor: int,
attempt_id: str,
verified: bool,
reason: str | None,
session_id: str,
wall_clock: float,
) -> None:
result = {
"attempt_id": attempt_id,
"expires_at_wallclock": wall_clock + LEASE_TTL_SECONDS if verified else None,
"reason": reason,
"session_id": session_id,
"ts": wall_clock,
"verified": verified,
}
temporary = f".{RESULT_FILE}.tmp-{secrets.token_hex(8)}"
flags = (
os.O_WRONLY
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
json.dump(result, stream, separators=(",", ":"), sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
os.replace(
temporary,
RESULT_FILE,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
)
os.fsync(directory_descriptor)
except Exception:
try:
os.unlink(temporary, dir_fd=directory_descriptor)
except FileNotFoundError:
pass
raise
finally:
os.close(descriptor)
def delete_pending_if_unchanged(
directory_descriptor: int,
name: str,
pending: PendingChallenge,
error_stream: TextIO,
) -> None:
quarantine = f".{name}.delete-{secrets.token_hex(8)}"
try:
os.rename(
name,
quarantine,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
)
except FileNotFoundError:
return
except OSError as error:
print(f"Mosaic promotion could not quarantine pending file: {error}", file=error_stream)
return
try:
moved = os.stat(
quarantine,
dir_fd=directory_descriptor,
follow_symlinks=False,
)
if (moved.st_dev, moved.st_ino) == (pending.device, pending.inode):
os.unlink(quarantine, dir_fd=directory_descriptor)
os.fsync(directory_descriptor)
return
print("Mosaic promotion pending file changed; preserving replacement.", file=error_stream)
try:
os.link(
quarantine,
name,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
follow_symlinks=False,
)
except FileExistsError:
print(
f"Mosaic promotion preserved replacement as {quarantine}.",
file=error_stream,
)
else:
os.unlink(quarantine, dir_fd=directory_descriptor)
os.fsync(directory_descriptor)
except OSError as error:
print(f"Mosaic promotion could not resolve pending file: {error}", file=error_stream)
def parse_reply(completed: subprocess.CompletedProcess[str]) -> dict[str, object] | None:
if completed.returncode != 0:
return None
try:
value = json.loads(
completed.stdout,
object_pairs_hook=reject_duplicate_json_keys,
)
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
return None
if not isinstance(value, dict):
return None
if set(value) == {"stage", "ok", "state"}:
if (
value.get("stage") == "promote_lease"
and value.get("ok") is True
and value.get("state") == "VERIFIED"
):
return value
return None
if set(value) == {"stage", "ok", "code"}:
if (
value.get("stage") in {"observe_receipt", "promote_lease"}
and value.get("ok") is False
and isinstance(value.get("code"), str)
and value.get("code")
):
return value
return None
def main(
*,
environ: Mapping[str, str] | None = None,
stderr: TextIO | None = None,
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
now: Callable[[], float] = time.time,
) -> int:
source_environment = os.environ if environ is None else environ
error_stream = sys.stderr if stderr is None else stderr
directory_descriptor: int | None = None
lock_descriptor: int | None = None
try:
runtime_dir, pending_name = session_pending_name(source_environment)
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
directory_descriptor = open_pending_directory(runtime_dir)
if directory_descriptor is None:
return 0
try:
lock_descriptor = acquire_lock(directory_descriptor)
except (BlockingIOError, FileNotFoundError):
print("Mosaic promotion completion deferred: promotion is in progress.", file=error_stream)
return 0
pending = read_pending(directory_descriptor, pending_name)
if pending is None:
return 0
completed = run(
[
sys.executable,
"-I",
"-S",
"-B",
str(PROMOTER),
"--complete",
pending.value,
],
check=False,
capture_output=True,
text=True,
env=dict(source_environment),
timeout=PROMOTER_TIMEOUT_SECONDS,
)
reply = parse_reply(completed)
if reply is not None and reply.get("ok") is True:
write_result(directory_descriptor, pending.value, True, None, session_id, now())
delete_pending_if_unchanged(
directory_descriptor,
pending_name,
pending,
error_stream,
)
print("Mosaic lease promotion completed.", file=error_stream)
return 0
if reply is not None:
code = str(reply["code"])
print(f"Mosaic promotion incomplete: {code}.", file=error_stream)
if code in TERMINAL_FAILURE_CODES:
write_result(directory_descriptor, pending.value, False, code, session_id, now())
delete_pending_if_unchanged(
directory_descriptor,
pending_name,
pending,
error_stream,
)
else:
diagnostic = completed.stderr.strip() or f"promoter exit {completed.returncode}"
print(f"Mosaic promotion retryable failure: {diagnostic}.", file=error_stream)
except (KeyError, OSError, RecursionError, UnicodeError, ValueError, subprocess.SubprocessError) as error:
print(f"Mosaic promotion completion deferred: {type(error).__name__}: {error}", file=error_stream)
finally:
if lock_descriptor is not None:
os.close(lock_descriptor)
if directory_descriptor is not None:
os.close(directory_descriptor)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -22,13 +22,23 @@ from typing import Final
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
BENIGN_OBSERVATION_UNAVAILABLE_CODE: Final = "OBSERVATION_UNAVAILABLE"
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
value: dict[str, object] = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate observer JSON key")
value[key] = item
return value
def read_json(stream: object) -> dict[str, object]:
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
raise ValueError("invalid observer input")
value = json.loads(raw)
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
if not isinstance(value, dict):
raise ValueError("invalid observer input")
return value
@@ -100,9 +110,9 @@ def observer_request(socket_path: Path, request: dict[str, object]) -> dict[str,
if not chunk:
break
response.extend(chunk)
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
if len(response) > MAX_FRAME or response.count(b"\n") != 1 or not response.endswith(b"\n"):
raise ValueError("invalid observer reply")
value = json.loads(response)
value = json.loads(response[:-1], object_pairs_hook=reject_duplicate_json_keys)
if not isinstance(value, dict):
raise ValueError("invalid observer reply")
return value
@@ -119,7 +129,12 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
if arguments.runtime == "claude":
if not arguments.latest_entry:
raise ValueError("Claude observer requires --latest-entry")
message = claude_latest_entry(source)
if "last_assistant_message" in source:
message = source["last_assistant_message"]
if not isinstance(message, str):
raise ValueError("invalid Claude observer input")
else:
message = claude_latest_entry(source)
else:
if arguments.latest_entry:
raise ValueError("Pi observer is message_end only")
@@ -133,10 +148,18 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
"runtime": arguments.runtime,
"latest_assistant_message": message,
})
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
except (KeyError, OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
return 2
return 0 if reply == {"ok": True} else 2
if set(reply) == {"ok"} and reply.get("ok") is True:
return 0
if (
set(reply) == {"ok", "code"}
and reply.get("ok") is False
and reply.get("code") == BENIGN_OBSERVATION_UNAVAILABLE_CODE
):
return 0
return 2
if __name__ == "__main__":
@@ -33,7 +33,7 @@ def is_verbatim_receipt(message: str, challenge: str, binding: dict[str, object]
"""Require the exact one current-cycle receipt, not a transcript substring."""
expected = receipt_for(challenge, binding)
return hmac.compare_digest(message, expected)
return hmac.compare_digest(message.encode("utf-8"), expected.encode("utf-8"))
def latest_assistant_digest(message: str) -> str:
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
# loop — which would make the control a false negative. A root dotfile is
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
STRIPPED="$FW/.install-rollback-control.tmp.sh"
SIGNALED="$FW/.install-signal-control.tmp.sh"
NOEXIT="$FW/.install-noexit-control.tmp.sh"
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
@@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
# A bash signal trap that merely returns lets the script continue past the
# interrupt — restoring the snapshot, then resuming the sync and reporting
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
# fires and ONLY the signal path governs), and assert the shipped installer
# restores AND exits without reporting success. The control strips `exit 1` from
# the trap and shows the buggy resume-to-success.
make_term_shim() {
local dir="$1"
cat > "$dir/cp" <<SHIM
#!/usr/bin/env bash
dest="\${@: -1}"
case "\$dest" in
*/$POISON_REL)
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
exec env PATH="$ORIG_PATH" cp "\$@" ;;
esac
exec env PATH="$ORIG_PATH" cp "\$@"
SHIM
chmod +x "$dir/cp"
# success. The earlier test used a child cp shim to signal its parent, making
# child completion race Bash's interrupted wait. Concurrency is not part of the
# guarded property: sync_framework_keep() runs in the installer's own Bash
# process, and `kill` is a builtin. Generate two installer fixtures that signal
# themselves at the same known mid-sync point. Their TERM handlers emit the same
# observable before diverging, so missing signal delivery fails BOTH arms rather
# than manufacturing a pass. The only semantic difference between fixtures is
# the explicit `exit 1` whose load-bearing behavior this control proves.
TERM_MARKER='[test-control] TERM handler entered'
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
make_signal_installer() {
local output="$1" handler="$2"
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
local target_cp=' cp "$abs" "$dst/$rel"'
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
local inject_close=' fi'
if ! awk \
-v target_trap="$target_trap" -v target_cp="$target_cp" \
-v handler="$handler" -v inject_open="$inject_open" \
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
$0 == target_cp {
print inject_open
print inject_kill
print inject_close
injection_sites++
}
{ print }
$0 == target_trap {
print handler
handler_sites++
}
END {
if (handler_sites != 1 || injection_sites != 1) exit 42
}
' "$INSTALL" > "$output"; then
rm -f "$output"
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
exit 1
fi
chmod +x "$output"
}
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
signal_fixture_ready() {
local fixture="$1" expected_handler="$2"
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
&& grep -Fqx "$expected_handler" "$fixture"
}
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
"signaled_fixture_ready"
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
"noexit_fixture_ready"
chk "[control] removing the explicit TERM exit changes the fixture" \
"! cmp -s '$SIGNALED' '$NOEXIT'"
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
# the selected copy. Echoes "<exit>\t<out>\t<home>".
run_signal_upgrade() {
local installer="$1" H OUT SHIM rc
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
local installer="$1" H OUT rc
H=$(mktemp -d); OUT=$(mktemp)
seed_home "$H"
make_term_shim "$SHIM"
set +e
PATH="$SHIM:$ORIG_PATH" \
PATH="$ORIG_PATH" \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
rm -rf "$SHIM"
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
}
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
chk "[signal] TERM handler observable fires exactly once" \
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
"[ '$rcC' -ne 0 ]"
chk "[signal] restore_snapshot fires on the interrupt" \
@@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
chk "[signal] does NOT resume to report sync success after the interrupt" \
"! grep -q 'file phase complete' '$OUTC'"
# Control: strip `exit 1` from the signal trap → the handler returns, the script
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
"$INSTALL" > "$NOEXIT"
chk "[control] the exit-strip actually changed the installer" \
"! cmp -s '$INSTALL' '$NOEXIT'"
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
chk "[control] TERM handler observable fires exactly once" \
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
chk "[control] without 'exit 1' the handler restores before returning" \
"grep -q 'restoring previous state from snapshot' '$OUTD'"
chk "[control] without 'exit 1' the installer exits zero after resuming" \
"[ '$rcD' -eq 0 ]"
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
"grep -q 'file phase complete' '$OUTD'"
@@ -309,10 +355,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
# Cleanup (generated installer controls are also removed by the EXIT trap).
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
echo
echo "RESULT: $pass passed, $fail failed"
+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/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
+2 -5
View File
@@ -14,8 +14,8 @@ import { registerTelemetryCommand } from './commands/telemetry.js';
import { registerAgentCommand } from './commands/agent.js';
import { registerInteractionCommand } from './commands/interaction.js';
import { registerConfigCommand } from './commands/config.js';
import { registerCredentialCommand } from './commands/cred.js';
import { registerFleetCommand } from './commands/fleet.js';
import { registerPromoteCommand } from './commands/promote.js';
import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js';
import { registerRestoreCommand } from './commands/restore.js';
@@ -371,10 +371,7 @@ registerInteractionCommand(program);
// ─── fleet ─────────────────────────────────────────────────────────────
registerFleetCommand(program);
// ─── credential governance ─────────────────────────────────────────────
registerCredentialCommand(program);
registerPromoteCommand(program);
// ─── config ────────────────────────────────────────────────────────────
-419
View File
@@ -1,419 +0,0 @@
import {
chmod,
mkdtemp,
mkdir,
open,
readFile,
readdir,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
CredentialAuditJournal,
CredentialJournalError,
listCredentialJournals,
} from '../credentials/audit-journal.js';
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
import { FileCredentialStore } from '../credentials/file-credential-store.js';
import { executeCredentialRotate, executeCredentialWire } from './cred.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function fixture(): Promise<{
readonly mosaicHome: string;
readonly registryPath: string;
readonly tokenDirectory: string;
readonly stateRoot: string;
}> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-command-'));
await chmod(cleanup, 0o700);
const mosaicHome = join(cleanup, 'mosaic');
const credentialDirectory = join(mosaicHome, 'cred');
await mkdir(credentialDirectory, { recursive: true, mode: 0o700 });
const registryPath = join(credentialDirectory, 'estates.json');
await writeFile(
registryPath,
JSON.stringify({
version: 1,
estates: [
{
name: 'homelab',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-example',
},
],
},
],
}),
{ mode: 0o600 },
);
return {
mosaicHome,
registryPath,
tokenDirectory: join(mosaicHome, 'secrets', 'gitea-tokens'),
stateRoot: join(cleanup, 'state'),
};
}
describe('credential lifecycle command controls', (): void => {
it('returns the visible open rotation journal when protected authority resolution fails', async (): Promise<void> => {
const paths = await fixture();
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
const store = new FileCredentialStore(paths.tokenDirectory, registry);
await store.put(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat-name',
tokenName: 'old-generation',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('old-token-canary'),
);
const result = await executeCredentialRotate('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: '999',
tokenName: 'new-generation',
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
});
expect(result.outcome).toBe('error');
expect(result.mutation).toBe('none');
expect(result.audit.state).toBe('open');
expect(result.audit.journalId).not.toBeNull();
await expect(listCredentialJournals(paths.stateRoot)).resolves.toContainEqual(
expect.objectContaining({ id: result.audit.journalId, state: 'open' }),
);
});
it('refuses an unauthenticated actor before rewriting another seat environment', async (): Promise<void> => {
const paths = await fixture();
const agents = join(paths.mosaicHome, 'fleet', 'agents');
await mkdir(agents, { recursive: true, mode: 0o700 });
const seatEnvironment = join(agents, 'seat-name.env.generated');
const before = 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n';
await writeFile(seatEnvironment, before, { mode: 0o600 });
const result = await executeCredentialWire('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'intruder-seat',
seatEnv: seatEnvironment,
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
});
expect(result.outcome).toBe('refused');
expect(result.mutation).toBe('none');
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
});
it('authenticates the exact seat and rewrites its roster-derived projection idempotently', async (): Promise<void> => {
const paths = await fixture();
const agents = join(paths.mosaicHome, 'fleet', 'agents');
await mkdir(agents, { recursive: true, mode: 0o700 });
const seatEnvironment = join(agents, 'seat-name.env.generated');
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n', {
mode: 0o600,
});
const authorityPath = join(cleanup!, 'authority.json');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'authority-canary',
}),
{ mode: 0o600 },
);
vi.stubGlobal(
'fetch',
async (): Promise<Response> =>
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const invoke = async () => {
const authority = await open(authorityPath, 'r');
try {
return await executeCredentialWire('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
seatEnv: seatEnvironment,
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
});
} finally {
await authority.close();
}
};
const first = await invoke();
const afterFirst = await readFile(seatEnvironment, 'utf8');
const second = await invoke();
const afterSecond = await readFile(seatEnvironment, 'utf8');
expect(first.outcome).toBe('ok');
expect(second.outcome).toBe('ok');
expect(afterSecond).toBe(afterFirst);
expect(afterSecond).toContain('MOSAIC_GIT_IDENTITY=seat-name\n');
expect(afterSecond).toContain('MOSAIC_CREDENTIAL_ESTATE=homelab\n');
expect(afterSecond).toContain('GITEA_LOGIN=seat-name--git.example.invalid\n');
});
it.each(['recordMutation', 'seal'] as const)(
'reports an applied wire as indeterminate when audit %s fails after rename',
async (method): Promise<void> => {
const paths = await fixture();
const agents = join(paths.mosaicHome, 'fleet', 'agents');
await mkdir(agents, { recursive: true, mode: 0o700 });
const seatEnvironment = join(agents, 'seat-name.env.generated');
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
const authorityPath = join(cleanup!, 'authority.json');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'authority-canary',
}),
{ mode: 0o600 },
);
vi.stubGlobal(
'fetch',
async (): Promise<Response> =>
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.spyOn(CredentialAuditJournal.prototype, method).mockRejectedValueOnce(
new CredentialJournalError('journal-unavailable', 'injected audit failure'),
);
const authority = await open(authorityPath, 'r');
try {
const result = await executeCredentialWire('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
seatEnv: seatEnvironment,
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
});
expect(result.outcome).toBe('indeterminate');
expect(result.mutation).toBe('applied');
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
} finally {
await authority.close();
}
},
);
it('refuses to overwrite a roster projection replaced after validation', async (): Promise<void> => {
const paths = await fixture();
const agents = join(paths.mosaicHome, 'fleet', 'agents');
await mkdir(agents, { recursive: true, mode: 0o700 });
const seatEnvironment = join(agents, 'seat-name.env.generated');
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
const authorityPath = join(cleanup!, 'authority.json');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'authority-canary',
}),
{ mode: 0o600 },
);
vi.stubGlobal(
'fetch',
async (): Promise<Response> =>
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const authority = await open(authorityPath, 'r');
try {
const result = await executeCredentialWire('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
seatEnv: seatEnvironment,
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
wireBeforeRename: async (): Promise<void> => {
const replacement = join(agents, 'replacement');
await writeFile(replacement, 'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n', { mode: 0o600 });
await rename(replacement, seatEnvironment);
},
});
expect(result.outcome).toBe('error');
expect(result.mutation).toBe('none');
expect(await readFile(seatEnvironment, 'utf8')).toBe(
'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n',
);
} finally {
await authority.close();
}
});
it('reports directory-sync failure after rename as applied and indeterminate', async (): Promise<void> => {
const paths = await fixture();
const agents = join(paths.mosaicHome, 'fleet', 'agents');
await mkdir(agents, { recursive: true, mode: 0o700 });
const seatEnvironment = join(agents, 'seat-name.env.generated');
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
const authorityPath = join(cleanup!, 'authority.json');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'authority-canary',
}),
{ mode: 0o600 },
);
vi.stubGlobal(
'fetch',
async (): Promise<Response> =>
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const authority = await open(authorityPath, 'r');
try {
const result = await executeCredentialWire('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
seatEnv: seatEnvironment,
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
wireDirectorySync: async (): Promise<void> => {
throw new Error('injected directory sync failure');
},
});
expect(result.outcome).toBe('indeterminate');
expect(result.mutation).toBe('applied');
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
} finally {
await authority.close();
}
});
it('removes a temporary projection when directory revalidation fails before rename', async (): Promise<void> => {
const paths = await fixture();
const agents = join(paths.mosaicHome, 'fleet', 'agents');
await mkdir(agents, { recursive: true, mode: 0o700 });
const seatEnvironment = join(agents, 'seat-name.env.generated');
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
const authorityPath = join(cleanup!, 'authority.json');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'authority-canary',
}),
{ mode: 0o600 },
);
vi.stubGlobal('fetch', async (): Promise<Response> => {
await chmod(agents, 0o777);
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
const authority = await open(authorityPath, 'r');
try {
const result = await executeCredentialWire('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
seatEnv: seatEnvironment,
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
});
expect(result.outcome).toBe('error');
expect(await readdir(agents)).toEqual(['seat-name.env.generated']);
} finally {
await authority.close();
await chmod(agents, 0o700);
}
});
it('refuses a caller-selected seat filename that is not bound to the requested identity', async (): Promise<void> => {
const paths = await fixture();
const agents = join(paths.mosaicHome, 'fleet', 'agents');
await mkdir(agents, { recursive: true, mode: 0o700 });
const seatEnvironment = join(agents, 'other-seat.env.generated');
const before = 'MOSAIC_AGENT_NAME=other-seat\nMOSAIC_AGENT_CLASS=coder\n';
await writeFile(seatEnvironment, before, { mode: 0o600 });
const result = await executeCredentialWire('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: '999',
seatEnv: seatEnvironment,
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
});
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('credential-binding-mismatch');
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
});
});
File diff suppressed because it is too large Load Diff
+175 -8
View File
@@ -14,9 +14,11 @@ import {
readdirSync,
realpathSync,
rmSync,
appendFileSync,
} from 'node:fs';
import { createHash, randomBytes } from 'node:crypto';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { homedir, hostname } from 'node:os';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
import {
@@ -42,6 +44,163 @@ const RUNTIME_LABELS: Record<RuntimeName, string> = {
pi: 'Pi',
};
// ─── Harness home isolation ──────────────────────────────────────────────────
// Mosaic-launched runtimes read config from a dedicated home under the mosaic
// tree — never the operator's base install. A bare `claude` / `pi` therefore
// keeps its own config AND its own auth, and stays a working break-glass no
// matter what mosaic does to its own tree.
//
// These paths are manifest-UNKNOWN, which resolves to operator ownership
// (framework-manifest.txt rule 3, #791), so a keep-mode `mosaic update` can
// neither overwrite nor prune them. Overwrite-mode install still would.
//
// opencode has no dedicated config-dir variable and follows XDG, so isolating it
// sets XDG_CONFIG_HOME for that process tree. That is blunter than the other
// three: it also relocates XDG lookups for anything opencode spawns.
const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
claude: 'CLAUDE_CONFIG_DIR',
pi: 'PI_CODING_AGENT_DIR',
codex: 'CODEX_HOME',
opencode: 'XDG_CONFIG_HOME',
};
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
function harnessHome(runtime: RuntimeName): string {
return join(MOSAIC_HOME, `.${runtime}`);
}
/**
* Env overlay pointing a runtime at its mosaic-owned home. The directory is
* created on demand so a first launch does not fail on a missing path.
*/
function harnessEnv(runtime: RuntimeName): Record<string, string> {
const key = HARNESS_HOME_ENV[runtime];
if (!key) return {};
const home = harnessHome(runtime);
mkdirSync(home, { recursive: true });
return { [key]: home };
}
// ─── Launch record (immutable provenance) ────────────────────────────────────
// MANDATORY and MECHANICAL: every launch appends one record of what the agent
// actually launched with, written before exec. No model involvement, no opt-out.
//
// WHY LAUNCH-TIME AND NOT INSPECT-LATER: pi rewrites its own argv to a bare
// `pi`, so /proc/<pid>/cmdline DESTROYS the launch evidence. That has already
// produced a confident wrong diagnosis ("this agent bypassed the launcher"),
// disproved only by the parent process's argv and only because the parent had
// not yet exited. A record written before exec is the only place this survives.
//
// Lands in fleet/run/sessions/ — the #797 Runtime Session Ledger path, already
// operator-classified in framework-manifest.txt and already covered by
// test-upgrade-manifest-guard.sh, so an upgrade can neither overwrite nor prune
// it.
//
// CORRELATION is by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime()
// uses spawnSync, so the runtime is a CHILD with a different pid.
// launch-runtime.py appends the matching `lease.register` event.
//
// NEVER records a credential value: env is captured as PRESENT NAMES ONLY, and
// oversized argv values (the composed system prompt) become a digest + length.
const LAUNCH_LEDGER_DIR = join(MOSAIC_HOME, 'fleet', 'run', 'sessions');
const CLI_VERSION: string | null = (() => {
try {
// Resolved RELATIVELY: the package `exports` map does not expose
// package.json, so '@mosaicstack/mosaic/package.json' throws
// ERR_PACKAGE_PATH_NOT_EXPORTED. Same relative depth from src/ and dist/.
return (createRequire(import.meta.url)('../../package.json') as { version: string }).version;
} catch {
return null;
}
})();
interface NormativeFragmentDigest {
source_id: string;
sha256: string | null;
bytes: number | null;
missing?: boolean;
}
function sha256Of(value: string | Buffer): string {
return createHash('sha256').update(value).digest('hex');
}
/**
* Hash the normative sources injected into the agent. This is "what the agent
* IS" and it is the same fragment set the lease broker hashes for promotion,
* so an unexpected digest here is a mechanically detectable red flag rather than
* a matter of judgement.
*/
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
const candidates: Array<[string, string]> = [
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
['USER.md', join(MOSAIC_HOME, 'USER.md')],
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
];
return candidates.map(([sourceId, path]) => {
try {
const bytes = readFileSync(path);
return { source_id: sourceId, sha256: sha256Of(bytes), bytes: bytes.length };
} catch {
return { source_id: sourceId, sha256: null, bytes: null, missing: true };
}
});
}
/** argv with oversized values replaced by a digest, so the record stays small
* and never inlines injected content verbatim. */
function redactArgv(argv: string[]): string[] {
return argv.map((a) =>
typeof a === 'string' && a.length > 256
? `<redacted sha256:${sha256Of(a).slice(0, 16)} bytes:${a.length}>`
: a,
);
}
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
try {
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
// Correlation id for the lease.register half. Set into process.env so it
// propagates through every `...process.env` / `...baseEnv` spread below.
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
process.env['MOSAIC_LAUNCH_ID'] = launchId;
const record = {
seq: Date.now(),
kind: 'session.launch',
launch_id: launchId,
ts: new Date().toISOString(),
host: hostname(),
pid: process.pid,
runtime,
mode: yolo ? 'yolo' : 'normal',
cwd: process.cwd(),
cli_version: CLI_VERSION,
config_home: harnessHome(runtime),
config_home_isolated: true,
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
argv: redactArgv(cliArgs),
normative_fragments: normativeFragmentDigests(runtime),
// names only — values are never recorded
mosaic_env_present: Object.keys(process.env)
.filter((k) => k.startsWith('MOSAIC_'))
.sort(),
};
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
mode: 0o600,
});
} catch (err) {
// Never block a launch on bookkeeping — but never fail silently either.
console.error(
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// ─── Pre-flight checks ──────────────────────────────────────────────────────
function checkMosaicHome(): void {
@@ -105,11 +264,11 @@ interface SettingsAudit {
function auditClaudeSettings(): SettingsAudit {
const warnings: string[] = [];
const settingsPath = join(homedir(), '.claude', 'settings.json');
const settingsPath = join(harnessHome('claude'), 'settings.json');
const settings = readJson(settingsPath);
if (!settings) {
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
warnings.push(`${settingsPath} not found — hooks and plugins will be missing`);
return { warnings };
}
@@ -561,7 +720,9 @@ function skillRealPath(dir: string): string {
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
* skills dir and the project-local one relative to the launch cwd. */
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
// PI_CODING_AGENT_DIR replaces ~/.pi/agent (not ~/.pi), so skills live at
// <home>/skills — there is no extra 'agent' segment under the isolated home.
return [join(harnessHome('pi'), 'skills'), join(cwd, '.pi', 'skills')];
}
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
@@ -764,12 +925,13 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('claude', cliArgs, yolo);
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
break;
}
case 'codex': {
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md'));
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
@@ -777,14 +939,17 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execRuntime('codex', cliArgs);
recordLaunch('codex', cliArgs, yolo);
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
break;
}
case 'opencode': {
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
console.log(`[mosaic] Launching ${label}${modeStr}...`);
execRuntime('opencode', args);
recordLaunch('opencode', args, yolo);
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
break;
}
@@ -799,6 +964,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('pi', cliArgs, yolo);
execLeaseGatedRuntime('pi', cliArgs);
break;
}
@@ -835,6 +1001,7 @@ function execLeaseGatedRuntime(
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
{
...baseEnv,
...harnessEnv(runtime),
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
},
@@ -0,0 +1,298 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import type { FleetRoster } from './fleet.js';
import { TmuxPromotionTransport } from '../fleet/promotion-transport.js';
import {
promoteSeat,
registerPromoteCommand,
type PromotionBreadcrumbStore,
type PromotionResult,
type PromotionTransport,
} from './promote.js';
const attemptId = 'a'.repeat(64);
const target = {
bundle: 'local',
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
};
function transport(): PromotionTransport {
return {
resolve: vi.fn(async () => target),
sendPromotion: vi.fn(async () => {}),
};
}
describe('mosaic promote', () => {
it('accepts only a fresh result correlated to this attempt', async () => {
const promotionTransport = transport();
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
readResult: vi
.fn()
.mockResolvedValueOnce({
attempt_id: 'c'.repeat(64),
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
})
.mockResolvedValueOnce({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
}),
};
const result = await promoteSeat('claude-seat', {
clock: () => 1_000,
sleep: async () => {},
store,
timeoutMs: 1,
transport: promotionTransport,
});
expect(result).toEqual({
bundle: 'local',
expiresAtWallclock: 4_600,
reason: null,
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
status: 'VERIFIED',
});
expect(promotionTransport.sendPromotion).toHaveBeenCalledWith(target);
expect(store.readResult).toHaveBeenCalledTimes(2);
});
it('accepts a fresh result for this session when completion consumed the pending nonce', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
})),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result.status).toBe('VERIFIED');
});
it('returns UNVERIFIED within the command bound when a tmux runner wedges', async () => {
vi.useFakeTimers();
const roster: FleetRoster = {
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
defaults: { workingDirectory: '~/src' },
runtimes: {},
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
transport: 'tmux',
version: 1,
};
const promotionTransport = new TmuxPromotionTransport({
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner: async () => new Promise(() => {}),
});
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => null),
};
try {
let observedResult: PromotionResult | undefined;
void promoteSeat('claude-seat', {
store,
transport: promotionTransport,
}).then((result) => {
observedResult = result;
});
await vi.advanceTimersByTimeAsync(5_000);
expect(observedResult).toMatchObject({
reason: 'RESOLVE_FAILED: Promotion transport command timed out after 5000ms.',
seat: 'claude-seat',
status: 'UNVERIFIED',
});
} finally {
vi.useRealTimers();
}
});
it('prints VERIFIED with the resolved seat, session, bundle, and wall-clock expiry', async () => {
const promotionTransport = transport();
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: Number.MAX_SAFE_INTEGER,
verified: true,
})),
};
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
const program = new Command().exitOverride();
registerPromoteCommand(program, {
mintAuthorization: async () => {},
store,
transport: promotionTransport,
});
try {
await program.parseAsync(['node', 'mosaic', 'promote', 'claude-seat']);
expect(output).toHaveBeenCalledWith(
`VERIFIED seat=claude-seat session=${target.sessionId} bundle=local expiry=1970-01-01T01:16:40.000Z`,
);
expect(process.exitCode).not.toBe(1);
} finally {
output.mockRestore();
process.exitCode = undefined;
}
});
it('prints UNVERIFIED and exits 1 when delivery fails', async () => {
const promotionTransport: PromotionTransport = {
resolve: vi.fn(async () => target),
sendPromotion: vi.fn(async () => {
throw new Error('tmux unavailable');
}),
};
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => null),
};
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
const program = new Command().exitOverride();
registerPromoteCommand(program, {
mintAuthorization: async () => {},
store,
transport: promotionTransport,
});
try {
process.exitCode = undefined;
await program.parseAsync(['node', 'mosaic', 'promote', 'claude-seat']);
expect(output).toHaveBeenCalledWith(
`UNVERIFIED seat=claude-seat session=${target.sessionId} bundle=local expiry=none reason=DELIVERY_FAILED: tmux unavailable`,
);
expect(process.exitCode).toBe(1);
} finally {
output.mockRestore();
process.exitCode = undefined;
}
});
it('rejects a stale result even when its nonce matches', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_000,
verified: true,
})),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result).toEqual({
bundle: 'local',
expiresAtWallclock: null,
reason: 'PROMOTION_TIMEOUT',
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
status: 'UNVERIFIED',
});
});
it('does not accept a result for a pending attempt that existed before send', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => attemptId),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
})),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result.status).toBe('UNVERIFIED');
expect(store.readResult).not.toHaveBeenCalled();
});
it('returns UNVERIFIED after a bounded timeout without reading stdin', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => null),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result).toEqual({
bundle: 'local',
expiresAtWallclock: null,
reason: 'PROMOTION_TIMEOUT',
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
status: 'UNVERIFIED',
});
expect(promotionTransport.sendPromotion).toHaveBeenCalledOnce();
expect(store.readResult).toHaveBeenCalledTimes(2);
});
});
+369
View File
@@ -0,0 +1,369 @@
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { constants } from 'node:fs';
import { mkdir, open, rename } from 'node:fs/promises';
import { join } from 'node:path';
import type { Command } from 'commander';
import {
TmuxPromotionTransport,
type PromotionTarget,
type PromotionTransport,
} from '../fleet/promotion-transport.js';
export type { PromotionTransport } from '../fleet/promotion-transport.js';
import { resolveFleetPaths, type CommandRunner } from './fleet.js';
const ATTEMPT_ID_PATTERN = /^[a-f0-9]{64}$/;
const DEFAULT_POLL_INTERVAL_MS = 250;
const DEFAULT_TIMEOUT_MS = 30_000;
const SUBPROCESS_TIMEOUT_MS = 4_500;
const PENDING_DIRECTORY = 'mosaic-lease';
const AUTHORIZATION_DIRECTORY = 'authorizations';
const AUTHORIZATION_TTL_SECONDS = 60;
const RESULT_FILE = 'last-result.json';
export interface PromotionBreadcrumb {
attempt_id: string;
expires_at_wallclock: number | null;
reason: string | null;
session_id: string;
ts: number;
verified: boolean;
}
export interface PromotionBreadcrumbStore {
readAttemptId(sessionId: string): Promise<string | null>;
readResult(): Promise<PromotionBreadcrumb | null>;
}
export interface PromotionResult {
bundle: string;
expiresAtWallclock: number | null;
reason: string | null;
seat: string;
sessionId: string;
status: 'VERIFIED' | 'UNVERIFIED';
}
export interface PromoteSeatOptions {
clock?: () => number;
pollIntervalMs?: number;
sleep?: (milliseconds: number) => Promise<void>;
store: PromotionBreadcrumbStore;
target?: PromotionTarget;
timeoutMs?: number;
transport: PromotionTransport;
}
export interface PromoteCommandDeps {
mintAuthorization?: (target: PromotionTarget) => Promise<void>;
mosaicHome?: string;
runner?: CommandRunner;
store?: PromotionBreadcrumbStore;
transport?: PromotionTransport;
}
/** Private, local result store shared with the in-seat completion hook. */
export class FilePromotionBreadcrumbStore implements PromotionBreadcrumbStore {
constructor(private readonly runtimeDirectory = defaultRuntimeDirectory()) {}
async readAttemptId(sessionId: string): Promise<string | null> {
if (!ATTEMPT_ID_PATTERN.test(sessionId)) return null;
const content = await readPrivateFile(
join(this.runtimeDirectory, PENDING_DIRECTORY, `pending-${sessionId}`),
);
const attemptId = content?.trim();
return attemptId !== undefined && ATTEMPT_ID_PATTERN.test(attemptId) ? attemptId : null;
}
async readResult(): Promise<PromotionBreadcrumb | null> {
const content = await readPrivateFile(
join(this.runtimeDirectory, PENDING_DIRECTORY, RESULT_FILE),
);
if (content === null) return null;
try {
return parseBreadcrumb(JSON.parse(content) as unknown);
} catch {
return null;
}
}
}
/** Drives one bounded, non-interactive in-seat promotion attempt. */
export async function promoteSeat(
seat: string,
options: PromoteSeatOptions,
): Promise<PromotionResult> {
const clock = options.clock ?? wallClockSeconds;
const sleep = options.sleep ?? defaultSleep;
const timeoutMs = normalizeTimeout(options.timeoutMs);
const pollIntervalMs = normalizePollInterval(options.pollIntervalMs);
let target: PromotionTarget;
try {
target = options.target ?? (await options.transport.resolve(seat));
} catch (error: unknown) {
return unverifiedUnresolvedSeat(seat, `RESOLVE_FAILED: ${errorMessage(error)}`);
}
const previousAttemptId = await options.store.readAttemptId(target.sessionId);
const preSendTimestamp = clock();
try {
await options.transport.sendPromotion(target);
} catch (error: unknown) {
return unverified(target, `DELIVERY_FAILED: ${errorMessage(error)}`);
}
const deadline = preSendTimestamp + timeoutMs / 1_000;
let attemptId: string | null = null;
while (true) {
const currentAttemptId = await options.store.readAttemptId(target.sessionId);
if (currentAttemptId !== null && currentAttemptId !== previousAttemptId) {
attemptId = currentAttemptId;
}
if (attemptId !== null || previousAttemptId === null) {
// Completion can consume a first attempt's nonce before this poll observes it.
// In that branch, correlation degrades to session_id + fresh timestamp, which
// is acceptable for this 0600, same-UID local trust boundary.
const breadcrumb = await options.store.readResult();
if (
breadcrumb !== null &&
breadcrumb.session_id === target.sessionId &&
(attemptId === null || breadcrumb.attempt_id === attemptId) &&
breadcrumb.ts > preSendTimestamp
) {
return {
bundle: target.bundle,
expiresAtWallclock: breadcrumb.expires_at_wallclock,
reason: breadcrumb.reason,
seat: target.seat,
sessionId: target.sessionId,
status: breadcrumb.verified ? 'VERIFIED' : 'UNVERIFIED',
};
}
}
if (clock() >= deadline) return unverified(target, 'PROMOTION_TIMEOUT');
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - clock()) * 1_000));
}
}
export function registerPromoteCommand(program: Command, deps: PromoteCommandDeps = {}): void {
const mosaicHome = deps.mosaicHome ?? resolveFleetPaths().mosaicHome;
const transport =
deps.transport ?? new TmuxPromotionTransport({ mosaicHome, runner: deps.runner ?? runCommand });
const store = deps.store ?? new FilePromotionBreadcrumbStore();
program
.command('promote <seat>')
.description('Promote a Claude fleet seat and report the correlated lease result')
.option(
'--timeout <ms>',
`Bounded result wait in milliseconds (default: ${DEFAULT_TIMEOUT_MS})`,
)
.action(async (seat: string, opts: { timeout?: string }) => {
if (
process.env['MOSAIC_LEASE_SESSION_ID'] !== undefined &&
deps.mintAuthorization === undefined
) {
console.error('mosaic promote must run outside a lease-gated seat.');
process.exitCode = 1;
return;
}
let target: PromotionTarget;
try {
target = await transport.resolve(seat);
await (deps.mintAuthorization ?? mintAuthorization)(target);
} catch (error: unknown) {
console.error(`mosaic promote authorization failed: ${errorMessage(error)}`);
process.exitCode = 1;
return;
}
const result = await promoteSeat(seat, {
store,
target,
timeoutMs: parseOptionTimeout(opts.timeout),
transport,
});
const expiry =
result.expiresAtWallclock === null
? 'none'
: new Date(result.expiresAtWallclock * 1_000).toISOString();
const reason = result.reason === null ? '' : ` reason=${result.reason}`;
console.log(
`${result.status} seat=${result.seat} session=${result.sessionId} bundle=${result.bundle} expiry=${expiry}${reason}`,
);
if (result.status === 'UNVERIFIED') process.exitCode = 1;
});
}
async function mintAuthorization(target: PromotionTarget): Promise<void> {
const directory = join(defaultRuntimeDirectory(), PENDING_DIRECTORY, AUTHORIZATION_DIRECTORY);
await mkdir(directory, { mode: 0o700, recursive: true });
const token = {
expires_at: wallClockSeconds() + AUTHORIZATION_TTL_SECONDS,
nonce: randomBytes(32).toString('hex'),
seat: target.seat,
session_id: target.sessionId,
ts: wallClockSeconds(),
};
const destination = join(directory, `${target.sessionId}.auth`);
const temporary = join(directory, `.${target.sessionId}.${randomBytes(8).toString('hex')}.tmp`);
const handle = await open(
temporary,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
0o600,
);
try {
await handle.chmod(0o600);
await handle.writeFile(JSON.stringify(token));
await handle.sync();
} finally {
await handle.close();
}
await rename(temporary, destination);
}
function defaultRuntimeDirectory(): string {
const configured = process.env['XDG_RUNTIME_DIR'];
if (configured) return configured;
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
return `/run/user/${uid}`;
}
async function readPrivateFile(path: string): Promise<string | null> {
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
} catch {
return null;
}
try {
const metadata = await handle.stat();
if (
!metadata.isFile() ||
metadata.uid !== (typeof process.getuid === 'function' ? process.getuid() : 0) ||
(metadata.mode & 0o077) !== 0
) {
return null;
}
return handle.readFile({ encoding: 'utf8' });
} catch {
return null;
} finally {
await handle.close();
}
}
function parseBreadcrumb(value: unknown): PromotionBreadcrumb | null {
if (!isRecord(value) || Object.keys(value).length !== 6) return null;
const { attempt_id, expires_at_wallclock, reason, session_id, ts, verified } = value;
if (
typeof attempt_id !== 'string' ||
!ATTEMPT_ID_PATTERN.test(attempt_id) ||
typeof verified !== 'boolean' ||
typeof session_id !== 'string' ||
!ATTEMPT_ID_PATTERN.test(session_id) ||
typeof ts !== 'number' ||
!Number.isFinite(ts) ||
(expires_at_wallclock !== null &&
(typeof expires_at_wallclock !== 'number' || !Number.isFinite(expires_at_wallclock))) ||
(reason !== null && typeof reason !== 'string')
) {
return null;
}
return { attempt_id, expires_at_wallclock, reason, session_id, ts, verified };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function normalizeTimeout(value: number | undefined): number {
return value !== undefined && Number.isFinite(value) ? Math.max(0, value) : DEFAULT_TIMEOUT_MS;
}
function normalizePollInterval(value: number | undefined): number {
return value !== undefined && Number.isFinite(value)
? Math.max(1, value)
: DEFAULT_POLL_INTERVAL_MS;
}
function parseOptionTimeout(value: string | undefined): number | undefined {
if (value === undefined) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function unverifiedUnresolvedSeat(seat: string, reason: string): PromotionResult {
return {
bundle: 'unresolved',
expiresAtWallclock: null,
reason,
seat,
sessionId: 'unresolved',
status: 'UNVERIFIED',
};
}
function unverified(target: PromotionTarget, reason: string): PromotionResult {
return {
bundle: target.bundle,
expiresAtWallclock: null,
reason,
seat: target.seat,
sessionId: target.sessionId,
status: 'UNVERIFIED',
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function wallClockSeconds(): number {
return Date.now() / 1_000;
}
function defaultSleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function runCommand(
command: string,
args: string[],
): Promise<{
exitCode: number;
stderr: string;
stdout: string;
}> {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
let settled = false;
const finish = (result: { exitCode: number; stderr: string; stdout: string }): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(result);
};
const timeout = setTimeout(() => {
child.kill('SIGKILL');
finish({
exitCode: 124,
stderr: `Promotion transport subprocess timed out after ${SUBPROCESS_TIMEOUT_MS}ms.`,
stdout,
});
}, SUBPROCESS_TIMEOUT_MS);
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (error: Error) => {
finish({ exitCode: 127, stderr: error.message, stdout });
});
child.on('close', (code: number | null) => {
finish({ exitCode: code ?? 1, stderr, stdout });
});
});
}
@@ -1,57 +0,0 @@
export type CredentialJournalOperation =
| 'provision'
| 'wire'
| 'grant'
| 'get'
| 'validate'
| 'rotate'
| 'revoke'
| 'whoami'
| 'list'
| 'audit';
export interface CredentialJournalContextDto {
readonly operation: CredentialJournalOperation;
readonly actor: string;
readonly identity: string;
readonly estate: string;
readonly host: string;
readonly repo: string | null;
}
export interface CredentialProviderJournalEvidenceDto {
readonly endpoint: string;
readonly contentType: string;
readonly decision: string;
}
export interface CredentialJournalCorrectionDto {
readonly supersedesJournalId: string;
readonly correctedByJournalId: string;
readonly previousReason: string;
readonly correctedReason: string;
readonly previousOutcome?: 'ok' | 'refused' | 'error' | 'indeterminate';
readonly correctedOutcome?: 'ok' | 'refused' | 'error' | 'indeterminate';
}
export interface CredentialPopulationCorrectionDto {
readonly entries: readonly {
readonly identity: string;
readonly supersedesJournalIds: readonly string[];
readonly settledByJournalId: string;
readonly capability: 'confirmed';
readonly identityBinding: 'not-measured';
readonly mechanism: 'identity-scope-forbidden-in-scope-capability-confirmed';
}[];
}
export interface CredentialJournalRuntimeOptionsDto {
readonly id?: string;
readonly now?: () => string;
}
export interface CredentialJournalSummaryDto {
readonly id: string;
readonly state: 'open' | 'sealed';
readonly path: string;
}
@@ -1,184 +0,0 @@
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { CredentialAuditJournal, listCredentialJournals } from './audit-journal.js';
let cleanup: string | undefined;
async function stateRoot(): Promise<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-journal-'));
return join(cleanup, 'state');
}
afterEach(async (): Promise<void> => {
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
describe('credential durable audit journal', (): void => {
it('opens before mutation, appends provider evidence, and seals durably', async (): Promise<void> => {
const root = await stateRoot();
const journal = await CredentialAuditJournal.open(
root,
{
operation: 'grant',
actor: 'provisioner',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
},
{ id: 'journal-id', now: (): string => '2026-08-05T00:00:00.000Z' },
);
await journal.recordIntent('provider-grant');
await journal.recordProviderEvidence({
endpoint: 'GET /api/v1/repos/owner/repo',
contentType: 'application/json',
decision: 'permission-write',
});
const sealedPath = await journal.seal('ok', 'grant-verified');
expect(sealedPath).toMatch(/journal-id\.sealed\.jsonl$/);
const records = (await readFile(sealedPath, 'utf8')).trim().split('\n');
expect(records).toHaveLength(4);
expect(records[0]).toContain('"phase":"opened"');
expect(records[1]).toContain('"phase":"intent"');
expect(records[2]).toContain('"phase":"provider-evidence"');
expect(records[3]).toContain('"phase":"sealed"');
});
it('leaves an unsealed journal visible for recovery', async (): Promise<void> => {
const root = await stateRoot();
const journal = await CredentialAuditJournal.open(root, {
operation: 'rotate',
actor: 'provisioner',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
});
const journals = await listCredentialJournals(root);
expect(journals).toHaveLength(1);
expect(journals[0]?.state).toBe('open');
await journal.closeIncomplete();
});
it('fails fatally when the durable journal root cannot be created', async (): Promise<void> => {
const root = await stateRoot();
await writeFile(root, 'not-a-directory', { mode: 0o600 });
await expect(
CredentialAuditJournal.open(root, {
operation: 'grant',
actor: 'provisioner',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
}),
).rejects.toThrow(/journal-unavailable/);
});
it('rejects secret-shaped evidence instead of writing it', async (): Promise<void> => {
const root = await stateRoot();
const journal = await CredentialAuditJournal.open(root, {
operation: 'validate',
actor: 'seat-name',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
});
await expect(
journal.recordProviderEvidence({
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
decision: 'seeded-secret-canary',
}),
).rejects.toThrow(/unsafe-audit-value/);
const journals = await listCredentialJournals(root);
const source = await readFile(journals[0]?.path ?? '', 'utf8');
expect(source).not.toContain('seeded-secret-canary');
await journal.closeIncomplete();
});
it('refuses a group-writable journal root before opening evidence', async (): Promise<void> => {
const root = await stateRoot();
await mkdir(root, { mode: 0o700 });
await chmod(root, 0o770);
await expect(
CredentialAuditJournal.open(root, {
operation: 'grant',
actor: 'provisioner',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
}),
).rejects.toThrow(/journal-unavailable/);
});
it('supersedes a false sealed classification without editing the original journal', async (): Promise<void> => {
const root = await stateRoot();
const journal = await CredentialAuditJournal.open(
root,
{
operation: 'validate',
actor: 'be-coder-06',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
},
{ id: 'correction-1' },
);
await journal.recordIntent('classification-correction');
await journal.recordCorrection({
supersedesJournalId: 'old-sealed-id',
correctedByJournalId: 'new-validation-id',
previousReason: 'identity-not-found',
correctedReason: 'credential-rejected',
previousOutcome: 'indeterminate',
correctedOutcome: 'refused',
});
const path = await journal.seal('indeterminate', 'credential-rejected');
const source = await readFile(path, 'utf8');
expect(source).toContain('"phase":"classification-correction"');
expect(source).toContain('"supersedesJournalId":"old-sealed-id"');
});
it('records one settled population correction across a classification chain', async (): Promise<void> => {
const root = await stateRoot();
const journal = await CredentialAuditJournal.open(root, {
operation: 'validate',
actor: 'be-coder-06',
identity: 'fleet-reconciliation',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
});
await journal.recordIntent('classification-correction');
await journal.recordPopulationCorrection({
entries: [
{
identity: 'seat-name',
supersedesJournalIds: ['v1-2-id', 'v1-4-id', 'v1-4-1-id'],
settledByJournalId: 'v1-5-id',
capability: 'confirmed',
identityBinding: 'not-measured',
mechanism: 'identity-scope-forbidden-in-scope-capability-confirmed',
},
],
});
const path = await journal.seal('ok', 'classification-corrected');
const source = await readFile(path, 'utf8');
expect(source).toContain('"phase":"population-classification-correction"');
expect(source).toContain('"capability":"confirmed"');
expect(source).toContain('"identityBinding":"not-measured"');
});
});
@@ -1,314 +0,0 @@
import { randomUUID } from 'node:crypto';
import { lstatSync } from 'node:fs';
import { open, readdir, rename } from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import { join } from 'node:path';
import { ensureManagedDirectory } from '../fleet/secure-file.js';
import type {
CredentialJournalContextDto,
CredentialJournalCorrectionDto,
CredentialJournalRuntimeOptionsDto,
CredentialPopulationCorrectionDto,
CredentialJournalSummaryDto,
CredentialProviderJournalEvidenceDto,
} from './audit-journal.dto.js';
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const SAFE_ESTATE = /^[a-z0-9][a-z0-9-]*$/;
const SAFE_HOST = /^[a-z0-9][a-z0-9.-]*$/;
const SAFE_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const SAFE_ENDPOINT = /^(?:GET|PUT|POST|DELETE) \/[A-Za-z0-9_./{}:-]+$/;
const SAFE_CONTENT_TYPE = /^[A-Za-z0-9!#$&^_.+/-]+(?:;[A-Za-z0-9=._+-]+)*$/;
const SAFE_DECISIONS = new Set<string>([
'provider-grant',
'permission-none',
'permission-read',
'permission-write',
'permission-admin',
'identity-verified',
'inventory-authority-verified',
'scope-verified',
'grant-verified',
'revoke-verified',
'rotate-verified',
'validation-requested',
'whoami-requested',
'provision-requested',
'rotate-requested',
'revoke-requested',
'wire-requested',
'get-requested',
'validation-verified',
'team-member-present',
'team-member-absent',
'team-repository-present',
'team-repository-absent',
'team-repository-set-verified',
'organization-member-present',
'organization-member-absent',
'collaborator-grant-applied',
'team-member-applied',
'team-member-rollback-applied',
'team-repository-applied',
'team-repository-rollback-applied',
'transport-write-verified',
'token-mint-applied',
'token-binding-stored',
'tea-login-stored',
'tea-login-removed',
'provision-rollback-verified',
'rotate-rollback-verified',
'token-revoke-applied',
'wire-applied',
'credential-issuance-authorized',
'credential-issued',
'classification-correction',
]);
export class CredentialJournalError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`Credential audit journal failed: code=${code} ${message}`);
this.name = 'CredentialJournalError';
}
}
function assertPrivateDirectory(path: string): void {
const stat = lstatSync(path);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== process.getuid?.() ||
(stat.mode & 0o022) !== 0
) {
throw new CredentialJournalError(
'journal-unavailable',
'journal directory owner or write permissions are unsafe',
);
}
}
function assertContext(context: CredentialJournalContextDto): void {
if (
!SAFE_NAME.test(context.actor) ||
!SAFE_NAME.test(context.identity) ||
!SAFE_ESTATE.test(context.estate) ||
!SAFE_HOST.test(context.host) ||
(context.repo !== null && !SAFE_REPO.test(context.repo))
) {
throw new CredentialJournalError(
'unsafe-audit-value',
'journal context is outside the non-secret allowlist grammar',
);
}
}
function assertEvidence(evidence: CredentialProviderJournalEvidenceDto): void {
if (
!SAFE_ENDPOINT.test(evidence.endpoint) ||
!SAFE_CONTENT_TYPE.test(evidence.contentType) ||
!SAFE_DECISIONS.has(evidence.decision)
) {
throw new CredentialJournalError(
'unsafe-audit-value',
'provider evidence is outside the non-secret allowlist',
);
}
}
async function syncDirectory(path: string): Promise<void> {
const directory = await open(path, 'r');
try {
await directory.sync();
} finally {
await directory.close();
}
}
export class CredentialAuditJournal {
private closed = false;
private constructor(
private readonly handle: FileHandle,
private readonly openPath: string,
private readonly journalsDirectory: string,
private readonly id: string,
private readonly now: () => string,
) {}
static async open(
stateRoot: string,
context: CredentialJournalContextDto,
runtime: CredentialJournalRuntimeOptionsDto = {},
): Promise<CredentialAuditJournal> {
assertContext(context);
const id = runtime.id ?? randomUUID();
if (!SAFE_NAME.test(id)) {
throw new CredentialJournalError('unsafe-audit-value', 'journal id is outside the grammar');
}
const now = runtime.now ?? ((): string => new Date().toISOString());
const journalsDirectory = join(stateRoot, 'journals');
let handle: FileHandle | undefined;
try {
ensureManagedDirectory(stateRoot, journalsDirectory);
assertPrivateDirectory(stateRoot);
assertPrivateDirectory(journalsDirectory);
const openPath = join(journalsDirectory, `${id}.open.jsonl`);
handle = await open(openPath, 'wx', 0o600);
const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now);
await journal.append({ phase: 'opened', at: now(), context });
await syncDirectory(journalsDirectory);
return journal;
} catch (error: unknown) {
if (handle !== undefined) await handle.close().catch((): void => undefined);
if (error instanceof CredentialJournalError) throw error;
throw new CredentialJournalError(
'journal-unavailable',
'durable journal could not be opened and fsynced',
);
}
}
private async append(record: object): Promise<void> {
if (this.closed) {
throw new CredentialJournalError('journal-unavailable', 'journal is already closed');
}
try {
await this.handle.write(`${JSON.stringify(record)}\n`);
await this.handle.sync();
} catch {
throw new CredentialJournalError(
'journal-unavailable',
'durable journal append or fsync failed',
);
}
}
journalId(): string {
return this.id;
}
async recordIntent(decision: string): Promise<void> {
if (!SAFE_DECISIONS.has(decision)) {
throw new CredentialJournalError(
'unsafe-audit-value',
'intent decision is outside the non-secret allowlist',
);
}
await this.append({ phase: 'intent', at: this.now(), decision });
}
async recordProviderEvidence(evidence: CredentialProviderJournalEvidenceDto): Promise<void> {
assertEvidence(evidence);
await this.append({ phase: 'provider-evidence', at: this.now(), evidence });
}
async recordMutation(decision: string): Promise<void> {
if (!SAFE_DECISIONS.has(decision)) {
throw new CredentialJournalError(
'unsafe-audit-value',
'mutation decision is outside the non-secret allowlist',
);
}
await this.append({ phase: 'mutation', at: this.now(), decision });
}
async recordCorrection(correction: CredentialJournalCorrectionDto): Promise<void> {
if (
!SAFE_NAME.test(correction.supersedesJournalId) ||
!SAFE_NAME.test(correction.correctedByJournalId) ||
!SAFE_NAME.test(correction.previousReason) ||
!SAFE_NAME.test(correction.correctedReason) ||
(correction.previousOutcome === undefined) !== (correction.correctedOutcome === undefined)
) {
throw new CredentialJournalError(
'unsafe-audit-value',
'classification correction is outside the non-secret grammar',
);
}
await this.append({ phase: 'classification-correction', at: this.now(), correction });
}
async recordPopulationCorrection(correction: CredentialPopulationCorrectionDto): Promise<void> {
if (
correction.entries.length === 0 ||
correction.entries.some(
(entry): boolean =>
!SAFE_NAME.test(entry.identity) ||
!SAFE_NAME.test(entry.settledByJournalId) ||
entry.supersedesJournalIds.length === 0 ||
entry.supersedesJournalIds.some((id): boolean => !SAFE_NAME.test(id)),
)
) {
throw new CredentialJournalError(
'unsafe-audit-value',
'population correction is outside the non-secret grammar',
);
}
await this.append({
phase: 'population-classification-correction',
at: this.now(),
correction,
});
}
async seal(
outcome: 'ok' | 'refused' | 'error' | 'indeterminate',
reasonCode: string,
): Promise<string> {
if (!SAFE_NAME.test(reasonCode)) {
throw new CredentialJournalError(
'unsafe-audit-value',
'reason code is outside the non-secret grammar',
);
}
await this.append({ phase: 'sealed', at: this.now(), outcome, reasonCode });
await this.handle.close();
this.closed = true;
const sealedPath = join(this.journalsDirectory, `${this.id}.sealed.jsonl`);
try {
await rename(this.openPath, sealedPath);
await syncDirectory(this.journalsDirectory);
return sealedPath;
} catch {
throw new CredentialJournalError(
'journal-unavailable',
'sealed journal could not be committed durably',
);
}
}
async closeIncomplete(): Promise<void> {
if (this.closed) return;
await this.handle.close();
this.closed = true;
}
}
export async function listCredentialJournals(
stateRoot: string,
): Promise<readonly CredentialJournalSummaryDto[]> {
const journalsDirectory = join(stateRoot, 'journals');
let names: string[];
try {
assertPrivateDirectory(stateRoot);
assertPrivateDirectory(journalsDirectory);
names = await readdir(journalsDirectory);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return [];
throw new CredentialJournalError('journal-unavailable', 'journal directory could not be read');
}
return names
.filter((name: string): boolean => /\.(?:open|sealed)\.jsonl$/.test(name))
.sort()
.map((name: string): CredentialJournalSummaryDto => {
const state = name.endsWith('.open.jsonl') ? 'open' : 'sealed';
return {
id: name.replace(/\.(?:open|sealed)\.jsonl$/, ''),
state,
path: join(journalsDirectory, name),
};
});
}
@@ -1,11 +0,0 @@
export interface CredentialBindingMetadataDto {
readonly schemaVersion?: 1;
readonly identity: string;
readonly estate: string;
readonly host: string;
readonly providerLogin: string;
readonly tokenName: string;
readonly scopes: readonly string[];
readonly createdAt: string;
readonly tokenDigest?: string;
}
@@ -1,52 +0,0 @@
import type {
ProviderIdentityEvidenceDto,
ReceivePackEvidenceDto,
RepositoryPermission,
RepositoryPermissionEvidenceDto,
} from './credential-result.dto.js';
export interface ResolvedCredential {
readonly identity: string;
readonly estate: string;
readonly host: string;
readonly resolutionId: string;
readonly secret: Uint8Array;
}
export interface CredentialResolver {
resolve(identity: string, estate: string, host: string): Promise<ResolvedCredential | undefined>;
}
export interface GiteaCredentialProvider {
readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto>;
readRepositoryPermission(
resolved: ResolvedCredential,
repo: string,
): Promise<RepositoryPermissionEvidenceDto>;
probeReceivePack(
resolved: ResolvedCredential | undefined,
repo: string,
): Promise<ReceivePackEvidenceDto>;
}
export interface CredentialEstateRegistry {
matches(estate: string, host: string): boolean;
}
export interface CredentialValidationDependencies {
readonly resolver: CredentialResolver;
readonly provider: GiteaCredentialProvider;
readonly estateRegistry: CredentialEstateRegistry;
}
export interface GiteaReadValidationRequestDto {
readonly identity: string;
readonly estate: string;
readonly host: string;
readonly repo: string;
readonly requiredPermission?: RepositoryPermission;
}
export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto {
readonly readOnlyControlIdentity: string;
}
@@ -1,84 +0,0 @@
export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate';
export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown';
export type RepositoryPermission = 'none' | 'read' | 'write' | 'admin';
export type ReceivePackState = 'advertised' | 'refused';
export interface CredentialReasonDto {
readonly code: string;
readonly message: string;
}
export interface CredentialSubjectDto {
readonly identity: string;
readonly estate: string;
readonly host: string;
readonly repo: string;
}
export interface ProviderIdentityEvidenceDto {
readonly login: string;
readonly endpoint: string;
readonly contentType: string;
}
export interface RepositoryPermissionEvidenceDto {
readonly effective: RepositoryPermission;
readonly endpoint: string;
readonly contentType: string;
}
export interface ReceivePackEvidenceDto {
readonly state: ReceivePackState;
readonly principal: string | null;
readonly resolutionId: string | null;
readonly contentType: string;
}
export interface ReadOnlyControlEvidenceDto {
readonly identity: string;
readonly providerPermission: RepositoryPermission;
readonly receivePack: ReceivePackState;
}
export interface WriteDifferentialEvidenceDto {
readonly state: 'can-write';
readonly credentialBinding: 'same-resolution';
readonly transportPrincipal: string;
readonly authenticatedReceivePack: 'advertised';
readonly readOnlyControl: ReadOnlyControlEvidenceDto;
readonly unauthenticatedReceivePack: 'refused';
readonly artifactCreated: false;
readonly proves: string;
readonly doesNotProve: string;
}
export interface TokenCapabilitiesEvidenceDto {
readonly state: 'measured' | 'not-measured';
readonly scopes: readonly string[];
readonly source: 'provider-token-object' | 'runtime-not-authorized';
}
export interface CredentialValidationEvidenceDto {
readonly providerIdentity: ProviderIdentityEvidenceDto | null;
readonly tokenCapabilities: TokenCapabilitiesEvidenceDto;
readonly repositoryPermission: RepositoryPermissionEvidenceDto | null;
readonly writeDifferential: WriteDifferentialEvidenceDto | null;
}
export interface CredentialAuditResultDto {
readonly journalId: string | null;
readonly state: 'not-started' | 'open' | 'sealed';
}
export interface CredentialValidationResultDto {
readonly schemaVersion: 1;
readonly operation: 'validate' | 'whoami';
readonly outcome: CredentialOutcome;
readonly exitCode: 0 | 10 | 20 | 30;
readonly retryable: boolean;
readonly subject: CredentialSubjectDto;
readonly mutation: CredentialMutationState;
readonly reason: CredentialReasonDto;
readonly evidence: CredentialValidationEvidenceDto;
readonly audit: CredentialAuditResultDto;
}
@@ -1,141 +0,0 @@
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
import type {
CredentialValidationDependencies,
GiteaReadValidationRequestDto,
GiteaWriteValidationRequestDto,
} from './credential-provider.dto.js';
import type {
CredentialValidationResultDto,
RepositoryPermission,
} from './credential-result.dto.js';
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
export interface CredentialValidationServiceOptions {
readonly stateRoot: string;
readonly actor: string;
readonly operation?: 'validate' | 'whoami';
}
function permissionDecision(permission: RepositoryPermission): string {
if (permission === 'none') return 'permission-none';
if (permission === 'admin') return 'permission-admin';
if (permission === 'write') return 'permission-write';
return 'permission-read';
}
async function openValidationJournal(
request: GiteaReadValidationRequestDto,
options: CredentialValidationServiceOptions,
): Promise<CredentialAuditJournal> {
const journal = await CredentialAuditJournal.open(options.stateRoot, {
operation: options.operation ?? 'validate',
actor: options.actor,
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
});
await journal.recordIntent(
options.operation === 'whoami' ? 'whoami-requested' : 'validation-requested',
);
return journal;
}
async function recordAndSealValidation(
journal: CredentialAuditJournal,
validation: CredentialValidationResultDto,
): Promise<CredentialValidationResultDto> {
if (validation.evidence.providerIdentity !== null) {
await journal.recordProviderEvidence({
endpoint: validation.evidence.providerIdentity.endpoint,
contentType: validation.evidence.providerIdentity.contentType,
decision: 'identity-verified',
});
}
if (validation.evidence.repositoryPermission !== null) {
await journal.recordProviderEvidence({
endpoint: validation.evidence.repositoryPermission.endpoint,
contentType: validation.evidence.repositoryPermission.contentType,
decision: permissionDecision(validation.evidence.repositoryPermission.effective),
});
}
await journal.seal(validation.outcome, validation.reason.code);
return {
...validation,
audit: { journalId: journal.journalId(), state: 'sealed' },
};
}
function journalFailureResult(
request: GiteaReadValidationRequestDto,
journal: CredentialAuditJournal,
error: CredentialJournalError,
operation: 'validate' | 'whoami',
): CredentialValidationResultDto {
return {
schemaVersion: 1,
operation,
outcome: 'error',
exitCode: 20,
retryable: false,
subject: {
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
},
mutation: 'none',
reason: {
code: error.code,
message: 'Validation audit persistence failed; inspect the durable open journal.',
},
evidence: {
providerIdentity: null,
tokenCapabilities: {
state: 'not-measured',
scopes: [],
source: 'runtime-not-authorized',
},
repositoryPermission: null,
writeDifferential: null,
},
audit: { journalId: journal.journalId(), state: 'open' },
};
}
export async function runCredentialReadValidation(
request: GiteaReadValidationRequestDto,
dependencies: CredentialValidationDependencies,
options: CredentialValidationServiceOptions,
): Promise<CredentialValidationResultDto> {
const journal = await openValidationJournal(request, options);
try {
const validation = await evaluateGiteaReadValidation(request, dependencies);
return await recordAndSealValidation(journal, {
...validation,
operation: options.operation ?? 'validate',
});
} catch (error: unknown) {
if (error instanceof CredentialJournalError) {
return journalFailureResult(request, journal, error, options.operation ?? 'validate');
}
throw error;
}
}
export async function runCredentialValidation(
request: GiteaWriteValidationRequestDto,
dependencies: CredentialValidationDependencies,
options: CredentialValidationServiceOptions,
): Promise<CredentialValidationResultDto> {
const journal = await openValidationJournal(request, options);
try {
const validation = await evaluateGiteaWriteValidation(request, dependencies);
return await recordAndSealValidation(journal, validation);
} catch (error: unknown) {
if (error instanceof CredentialJournalError) {
return journalFailureResult(request, journal, error, 'validate');
}
throw error;
}
}
@@ -1,97 +0,0 @@
import { mkdtemp, open, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { readDelegatedCredentialFromFd } from './delegated-credential.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
describe('protected delegated credential channel', (): void => {
it('reads authority from an inherited fd number without putting the secret in argv or env', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
const path = join(cleanup, 'authority');
await writeFile(
path,
JSON.stringify({
identity: 'provisioner',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'seeded-authority-canary',
}),
{ mode: 0o600 },
);
const handle = await open(path, 'r');
try {
const resolved = await readDelegatedCredentialFromFd(
handle.fd,
'provisioner',
'homelab',
'git.example.invalid',
);
expect(resolved.identity).toBe('provisioner');
expect(Buffer.from(resolved.secret).toString('utf8')).toBe('seeded-authority-canary');
} finally {
await handle.close();
}
});
it('rejects a regular-file authority fd with group or other access', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
const path = join(cleanup, 'authority');
await writeFile(
path,
JSON.stringify({
identity: 'provisioner',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'seeded-authority-canary',
}),
{ mode: 0o644 },
);
const handle = await open(path, 'r');
try {
await expect(
readDelegatedCredentialFromFd(handle.fd, 'provisioner', 'homelab', 'git.example.invalid'),
).rejects.toMatchObject({ code: 'delegated-authority-unavailable' });
} finally {
await handle.close();
}
});
it('rejects an authority identity or estate mismatch without echoing the secret', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
const path = join(cleanup, 'authority');
await writeFile(
path,
JSON.stringify({
identity: 'other',
estate: 'usc',
host: 'git.example.invalid',
secret: 'seeded-authority-canary',
}),
{ mode: 0o600 },
);
const handle = await open(path, 'r');
try {
let message = '';
try {
await readDelegatedCredentialFromFd(
handle.fd,
'provisioner',
'homelab',
'git.example.invalid',
);
} catch (error: unknown) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toContain('delegated-authority-mismatch');
expect(message).not.toContain('seeded-authority-canary');
} finally {
await handle.close();
}
});
});
@@ -1,122 +0,0 @@
import { randomUUID } from 'node:crypto';
import { createReadStream, fstatSync } from 'node:fs';
import { z } from 'zod';
import type { ResolvedCredential } from './credential-provider.dto.js';
const authoritySchema = z
.object({
identity: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*$/),
estate: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
host: z.string().regex(/^[a-z0-9][a-z0-9.-]*$/),
secret: z
.string()
.min(1)
.max(16 * 1024)
.regex(/^\S+$/),
})
.strict();
export class DelegatedCredentialError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`Delegated credential rejected: code=${code} ${message}`);
this.name = 'DelegatedCredentialError';
}
}
async function readProtectedFd(fd: number): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout((): void => controller.abort(), 5_000);
const chunks: Buffer[] = [];
let total = 0;
try {
const stream = createReadStream(`/proc/self/fd/${fd}`, {
highWaterMark: 4 * 1024,
signal: controller.signal,
});
for await (const chunk of stream) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += bytes.byteLength;
if (total > 32 * 1024) {
stream.destroy();
throw new Error('protected credential payload exceeded the bound');
}
chunks.push(bytes);
}
return Buffer.concat(chunks, total);
} finally {
clearTimeout(timeout);
}
}
export async function readDelegatedCredentialFromFd(
fd: number,
expectedIdentity: string,
expectedEstate: string,
expectedHost: string,
): Promise<ResolvedCredential> {
if (!Number.isSafeInteger(fd) || fd < 3 || fd > 1024) {
throw new DelegatedCredentialError('delegated-authority-unavailable', 'invalid inherited fd');
}
let bytes: Buffer;
try {
const stat = fstatSync(fd);
if (!stat.isFile() && !stat.isFIFO()) {
throw new Error('fd is not a regular file or pipe');
}
const currentUid = process.getuid?.();
if (currentUid === undefined || stat.uid !== currentUid || (stat.mode & 0o077) !== 0) {
throw new Error('fd owner or permissions are unsafe');
}
bytes = await readProtectedFd(fd);
} catch {
throw new DelegatedCredentialError(
'delegated-authority-unavailable',
'protected inherited credential fd could not be read',
);
}
if (bytes.byteLength > 32 * 1024) {
bytes.fill(0);
throw new DelegatedCredentialError(
'delegated-authority-unavailable',
'protected credential payload exceeded the bound',
);
}
let raw: unknown;
try {
raw = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
} catch {
bytes.fill(0);
throw new DelegatedCredentialError(
'delegated-authority-unavailable',
'protected credential payload was invalid',
);
}
bytes.fill(0);
const parsed = authoritySchema.safeParse(raw);
if (!parsed.success) {
throw new DelegatedCredentialError(
'delegated-authority-unavailable',
'protected credential payload did not match the schema',
);
}
if (
parsed.data.identity !== expectedIdentity ||
parsed.data.estate !== expectedEstate ||
parsed.data.host !== expectedHost
) {
throw new DelegatedCredentialError(
'delegated-authority-mismatch',
'protected credential does not match the explicit actor, estate, and host',
);
}
return Object.freeze({
identity: parsed.data.identity,
estate: parsed.data.estate,
host: parsed.data.host,
resolutionId: randomUUID(),
secret: new TextEncoder().encode(parsed.data.secret),
});
}
@@ -1,15 +0,0 @@
export type CredentialProviderKind = 'gitea';
export interface CredentialHostConfigDto {
readonly host: string;
readonly provider: CredentialProviderKind;
readonly apiBaseUrl: string;
readonly tokenPrefix: string;
}
export interface CredentialEstateConfigDto {
readonly name: string;
readonly readOnlyControlIdentity?: string;
readonly inventoryAuthorityIdentity?: string;
readonly hosts: readonly CredentialHostConfigDto[];
}
@@ -1,99 +0,0 @@
import { describe, expect, it } from 'vitest';
import { parseCredentialEstateRegistry } from './estate-registry.js';
const validRegistry = JSON.stringify({
version: 1,
estates: [
{
name: 'homelab',
readOnlyControlIdentity: 'read-control',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-example',
},
],
},
],
});
describe('credential estate registry', (): void => {
it('requires an exact declared estate-host pair', (): void => {
const registry = parseCredentialEstateRegistry(validRegistry);
expect(registry.matches('homelab', 'git.example.invalid')).toBe(true);
expect(registry.matches('usc', 'git.example.invalid')).toBe(false);
expect(registry.matches('homelab', 'other.example.invalid')).toBe(false);
expect(registry.resolveByHost('git.example.invalid')).toMatchObject({
estate: 'homelab',
host: { host: 'git.example.invalid', provider: 'gitea' },
});
expect(registry.resolveByHost('other.example.invalid')).toBeUndefined();
});
it('rejects a provider URL whose host differs from the declared host', (): void => {
const source = validRegistry.replace(
'https://git.example.invalid',
'https://other.example.invalid',
);
expect(() => parseCredentialEstateRegistry(source)).toThrow(/api-host-mismatch/);
});
it('rejects one host assigned to multiple estates', (): void => {
const source = JSON.stringify({
version: 1,
estates: [
{
name: 'homelab',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-example',
},
],
},
{
name: 'other',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-other',
},
],
},
],
});
expect(() => parseCredentialEstateRegistry(source)).toThrow(/duplicate-host/);
});
it('rejects URLs with userinfo, path, query, fragment, or non-HTTPS scheme', (): void => {
for (const apiBaseUrl of [
'http://git.example.invalid',
'https://[email protected]',
'https://git.example.invalid/api',
'https://git.example.invalid?x=1',
'https://git.example.invalid#x',
]) {
const source = validRegistry.replace('https://git.example.invalid', apiBaseUrl);
expect(() => parseCredentialEstateRegistry(source), apiBaseUrl).toThrow(/invalid-api-url/);
}
});
it('requires a configured read-only control for write validation', (): void => {
const registry = parseCredentialEstateRegistry(validRegistry);
const withoutControl = parseCredentialEstateRegistry(
validRegistry.replace('"readOnlyControlIdentity":"read-control",', ''),
);
expect(registry.readOnlyControl('homelab')).toBe('read-control');
expect(() => withoutControl.readOnlyControl('homelab')).toThrow(/read-only-control-missing/);
});
});
@@ -1,166 +0,0 @@
import { z } from 'zod';
import type { CredentialEstateRegistry } from './credential-provider.dto.js';
import type { CredentialEstateConfigDto, CredentialHostConfigDto } from './estate-registry.dto.js';
const NAME = /^[a-z0-9][a-z0-9-]*$/;
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const HOST = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
const hostSchema = z
.object({
host: z.string().regex(HOST),
provider: z.literal('gitea'),
apiBaseUrl: z.string(),
tokenPrefix: z.string().regex(NAME),
})
.strict();
const estateSchema = z
.object({
name: z.string().regex(NAME),
readOnlyControlIdentity: z.string().regex(IDENTITY).optional(),
inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(),
hosts: z.array(hostSchema).min(1),
})
.strict();
const registrySchema = z
.object({
version: z.literal(1),
estates: z.array(estateSchema).min(1),
})
.strict();
export class CredentialEstateRegistryError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`Credential estate registry rejected: code=${code} ${message}`);
this.name = 'CredentialEstateRegistryError';
}
}
function validateApiUrl(host: CredentialHostConfigDto): void {
let url: URL;
try {
url = new URL(host.apiBaseUrl);
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new CredentialEstateRegistryError('invalid-api-url', detail);
}
if (
url.protocol !== 'https:' ||
url.username !== '' ||
url.password !== '' ||
url.pathname !== '/' ||
url.search !== '' ||
url.hash !== ''
) {
throw new CredentialEstateRegistryError(
'invalid-api-url',
'provider API URL must be an HTTPS origin without userinfo, path, query, or fragment',
);
}
if (url.hostname !== host.host) {
throw new CredentialEstateRegistryError(
'api-host-mismatch',
'provider API URL hostname does not equal the declared host',
);
}
}
export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry {
private readonly estates: ReadonlyMap<string, CredentialEstateConfigDto>;
constructor(estates: readonly CredentialEstateConfigDto[]) {
this.estates = new Map(
estates.map(
(estate: CredentialEstateConfigDto): readonly [string, CredentialEstateConfigDto] => [
estate.name,
estate,
],
),
);
}
matches(estate: string, host: string): boolean {
return this.resolve(estate, host) !== undefined;
}
resolve(estate: string, host: string): CredentialHostConfigDto | undefined {
return this.estates
.get(estate)
?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host);
}
resolveByHost(
host: string,
): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined {
for (const [estate, config] of this.estates) {
const match = config.hosts.find(
(candidate: CredentialHostConfigDto): boolean => candidate.host === host,
);
if (match !== undefined) return { estate, host: match };
}
return undefined;
}
inventoryAuthority(estate: string): string {
const identity = this.estates.get(estate)?.inventoryAuthorityIdentity;
if (identity === undefined) {
throw new CredentialEstateRegistryError(
'inventory-authority-missing',
`estate ${estate} has no delegated inventory authority identity`,
);
}
return identity;
}
readOnlyControl(estate: string): string {
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
if (identity === undefined) {
throw new CredentialEstateRegistryError(
'read-only-control-missing',
`estate ${estate} has no provider-confirmed read-only control identity`,
);
}
return identity;
}
}
export function parseCredentialEstateRegistry(source: string): ParsedCredentialEstateRegistry {
let raw: unknown;
try {
raw = JSON.parse(source);
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new CredentialEstateRegistryError('invalid-json', detail);
}
const parsed = registrySchema.safeParse(raw);
if (!parsed.success) {
throw new CredentialEstateRegistryError(
'invalid-schema',
parsed.error.issues[0]?.message ?? 'invalid',
);
}
const estateNames = new Set<string>();
const hostNames = new Set<string>();
for (const estate of parsed.data.estates) {
if (estateNames.has(estate.name)) {
throw new CredentialEstateRegistryError('duplicate-estate', estate.name);
}
estateNames.add(estate.name);
for (const host of estate.hosts) {
validateApiUrl(host);
if (hostNames.has(host.host)) {
throw new CredentialEstateRegistryError('duplicate-host', host.host);
}
hostNames.add(host.host);
}
}
return new ParsedCredentialEstateRegistry(parsed.data.estates);
}
@@ -1,160 +0,0 @@
import { chmod, copyFile, mkdir, symlink, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtemp } from 'node:fs/promises';
import { afterEach, describe, expect, it } from 'vitest';
import { rm } from 'node:fs/promises';
import { parseCredentialEstateRegistry } from './estate-registry.js';
import { FileCredentialResolver, FileCredentialStore } from './file-credential-store.js';
let cleanup: string | undefined;
async function fixtureRoot(): Promise<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-store-'));
const root = join(cleanup, 'tokens');
await mkdir(root, { mode: 0o700 });
return root;
}
function registry(): ReturnType<typeof parseCredentialEstateRegistry> {
return parseCredentialEstateRegistry(
JSON.stringify({
version: 1,
estates: [
{
name: 'homelab',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-example',
},
],
},
],
}),
);
}
afterEach(async (): Promise<void> => {
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
describe('phase-1 governed file credential resolver', (): void => {
it('resolves only the exact estate/host/identity token at a test-overridable root', async (): Promise<void> => {
const root = await fixtureRoot();
await writeFile(join(root, 'gitea-example-seat.token'), 'canary-token', { mode: 0o600 });
const resolver = new FileCredentialResolver(root, registry());
const resolved = await resolver.resolve('seat', 'homelab', 'git.example.invalid');
const wrongEstate = await resolver.resolve('seat', 'usc', 'git.example.invalid');
expect(resolved?.identity).toBe('seat');
expect(Buffer.from(resolved?.secret ?? []).toString('utf8')).toBe('canary-token');
expect(wrongEstate).toBeUndefined();
});
it('rejects a group-writable token directory even when the token file is private', async (): Promise<void> => {
const root = await fixtureRoot();
await writeFile(join(root, 'gitea-example-seat-name.token'), 'private-token', {
mode: 0o600,
});
await chmod(root, 0o770);
const resolver = new FileCredentialResolver(root, registry());
await expect(resolver.resolve('seat-name', 'homelab', 'git.example.invalid')).rejects.toThrow(
/insecure-token-owner/,
);
});
it('rejects a token file with group or other permissions', async (): Promise<void> => {
const root = await fixtureRoot();
const path = join(root, 'gitea-example-seat.token');
await writeFile(path, 'canary-token', { mode: 0o600 });
await chmod(path, 0o640);
const resolver = new FileCredentialResolver(root, registry());
await expect(resolver.resolve('seat', 'homelab', 'git.example.invalid')).rejects.toThrow(
/insecure-token-mode/,
);
});
it('rejects a symlinked token instead of following it', async (): Promise<void> => {
const root = await fixtureRoot();
const target = join(cleanup ?? root, 'outside-token');
await writeFile(target, 'canary-token', { mode: 0o600 });
await symlink(target, join(root, 'gitea-example-seat.token'));
const resolver = new FileCredentialResolver(root, registry());
await expect(resolver.resolve('seat', 'homelab', 'git.example.invalid')).rejects.toThrow(
/symbolic link|unavailable/,
);
});
it('rejects traversal-shaped identities before touching storage', async (): Promise<void> => {
const root = await fixtureRoot();
const resolver = new FileCredentialResolver(root, registry());
await expect(resolver.resolve('../other', 'homelab', 'git.example.invalid')).rejects.toThrow(
/invalid-identity/,
);
});
it('atomically stores, lists, reads binding metadata, and removes a governed credential', async (): Promise<void> => {
const root = await fixtureRoot();
const store = new FileCredentialStore(root, registry());
await store.put(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat',
tokenName: 'mosaic-seat-1',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('new-private-token'),
);
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']);
await expect(
store.readBinding('seat', 'homelab', 'git.example.invalid'),
).resolves.toMatchObject({
providerLogin: 'seat',
tokenName: 'mosaic-seat-1',
});
await expect(
new FileCredentialResolver(root, registry()).resolve(
'seat',
'homelab',
'git.example.invalid',
),
).resolves.toMatchObject({ identity: 'seat' });
await copyFile(
join(root, 'gitea-example-seat.credential.json'),
join(root, 'gitea-example-other.credential.json'),
);
await expect(
new FileCredentialResolver(root, registry()).resolve(
'other',
'homelab',
'git.example.invalid',
),
).rejects.toThrow(/credential-binding-mismatch/);
await unlink(join(root, 'gitea-example-other.credential.json'));
await store.remove('seat', 'homelab', 'git.example.invalid');
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('returns undefined for an absent token without borrowing another identity', async (): Promise<void> => {
const root = await fixtureRoot();
await writeFile(join(root, 'gitea-example-shared.token'), 'shared-canary', { mode: 0o600 });
const resolver = new FileCredentialResolver(root, registry());
const resolved = await resolver.resolve('missing-seat', 'homelab', 'git.example.invalid');
expect(resolved).toBeUndefined();
});
});
@@ -1,504 +0,0 @@
import { createHash, randomUUID } from 'node:crypto';
import { lstatSync } from 'node:fs';
import { open, readdir, rename, unlink } from 'node:fs/promises';
import { join } from 'node:path';
import { z } from 'zod';
import {
ensureManagedDirectory,
readRegularFileSecure,
type SecureFileSnapshot,
} from '../fleet/secure-file.js';
import type { CredentialBindingMetadataDto } from './credential-binding.dto.js';
import type { CredentialResolver, ResolvedCredential } from './credential-provider.dto.js';
import type { ParsedCredentialEstateRegistry } from './estate-registry.js';
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const MAX_TOKEN_BYTES = 16 * 1024;
const bindingSchema = z
.object({
schemaVersion: z.literal(1),
identity: z.string().regex(IDENTITY),
estate: z.string().min(1),
host: z.string().min(1),
providerLogin: z.string().regex(IDENTITY),
tokenName: z.string().regex(IDENTITY),
scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32),
createdAt: z.string().datetime(),
tokenDigest: z
.string()
.regex(/^[a-f0-9]{64}$/)
.optional(),
})
.strict();
const credentialEnvelopeSchema = bindingSchema.extend({
token: z.string().min(1).max(MAX_TOKEN_BYTES).regex(/^\S+$/),
});
export class CredentialStoreError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`Credential store rejected: code=${code} ${message}`);
this.name = 'CredentialStoreError';
}
}
function isMissingFile(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
typeof error.code === 'string' &&
error.code === 'ENOENT'
);
}
function validateSecret(content: Buffer): Uint8Array {
if (content.byteLength === 0 || content.byteLength > MAX_TOKEN_BYTES) {
throw new CredentialStoreError('invalid-token-size', 'token file size is outside bounds');
}
for (const byte of content) {
if (byte <= 0x20 || byte === 0x7f) {
throw new CredentialStoreError(
'invalid-token-bytes',
'token file contains whitespace or control bytes',
);
}
}
return new Uint8Array(content);
}
export class FileCredentialResolver implements CredentialResolver {
constructor(
private readonly tokenDirectory: string,
private readonly estateRegistry: ParsedCredentialEstateRegistry,
) {}
async resolve(
identity: string,
estate: string,
host: string,
): Promise<ResolvedCredential | undefined> {
if (!IDENTITY.test(identity)) {
throw new CredentialStoreError(
'invalid-identity',
'identity is outside the allowlist grammar',
);
}
const hostConfig = this.estateRegistry.resolve(estate, host);
if (hostConfig === undefined) return undefined;
const currentUid = process.getuid?.();
if (currentUid === undefined) {
throw new CredentialStoreError('insecure-token-owner', 'runtime uid is unavailable');
}
const directory = lstatSync(this.tokenDirectory);
if (
!directory.isDirectory() ||
directory.isSymbolicLink() ||
directory.uid !== currentUid ||
(directory.mode & 0o022) !== 0
) {
throw new CredentialStoreError(
'insecure-token-owner',
'token directory ownership or write permissions are unsafe',
);
}
const envelopePath = join(
this.tokenDirectory,
`${hostConfig.tokenPrefix}-${identity}.credential.json`,
);
try {
const envelopeSnapshot = readRegularFileSecure(envelopePath, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
const envelope = credentialEnvelopeSchema.safeParse(
JSON.parse(envelopeSnapshot.content.toString('utf8')),
);
if (
!envelope.success ||
envelopeSnapshot.uid !== process.getuid?.() ||
(envelopeSnapshot.mode & 0o077) !== 0
) {
throw new CredentialStoreError(
'invalid-binding',
'credential envelope failed schema, owner, or mode validation',
);
}
if (
envelope.data.identity !== identity ||
envelope.data.estate !== estate ||
envelope.data.host !== host ||
envelope.data.providerLogin !== identity
) {
throw new CredentialStoreError(
'credential-binding-mismatch',
'credential envelope does not match the requested identity, estate, host, and principal',
);
}
const secret = validateSecret(Buffer.from(envelope.data.token, 'utf8'));
const digest = createHash('sha256').update(secret).digest('hex');
if (envelope.data.tokenDigest !== digest) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'credential envelope digest does not match its token',
);
}
return Object.freeze({
identity,
estate,
host,
resolutionId: randomUUID(),
secret,
});
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
}
const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`);
let snapshot: SecureFileSnapshot;
try {
snapshot = readRegularFileSecure(path, {
root: this.tokenDirectory,
maxBytes: MAX_TOKEN_BYTES,
});
} catch (error: unknown) {
if (isMissingFile(error)) return undefined;
throw error;
}
const bindingPath = join(
this.tokenDirectory,
`${hostConfig.tokenPrefix}-${identity}.binding.json`,
);
try {
const bindingSnapshot = readRegularFileSecure(bindingPath, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8')));
const digest = createHash('sha256').update(snapshot.content).digest('hex');
if (
!binding.success ||
binding.data.tokenDigest !== digest ||
binding.data.identity !== identity ||
binding.data.estate !== estate ||
binding.data.host !== host ||
binding.data.providerLogin !== identity
) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'token and binding metadata are not one committed identity-bound generation',
);
}
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
// Legacy token files predate binding metadata and remain readable until rotated.
}
const permissions = snapshot.mode & 0o777;
if (snapshot.uid !== currentUid) {
throw new CredentialStoreError(
'insecure-token-owner',
'token file is not owned by the runtime uid',
);
}
if ((permissions & 0o077) !== 0) {
throw new CredentialStoreError(
'insecure-token-mode',
'token file grants group or other access',
);
}
return Object.freeze({
identity,
estate,
host,
resolutionId: randomUUID(),
secret: validateSecret(snapshot.content),
});
}
}
function assertPrivateTokenDirectory(path: string): {
readonly dev: number | bigint;
readonly ino: number | bigint;
} {
const stat = lstatSync(path);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== process.getuid?.() ||
(stat.mode & 0o022) !== 0
) {
throw new CredentialStoreError(
'insecure-token-owner',
'token directory ownership or write permissions are unsafe',
);
}
return { dev: stat.dev, ino: stat.ino };
}
async function syncDirectory(path: string): Promise<void> {
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
}
export class FileCredentialStore {
constructor(
private readonly tokenDirectory: string,
private readonly estateRegistry: ParsedCredentialEstateRegistry,
) {}
private paths(
identity: string,
estate: string,
host: string,
): {
readonly token: string;
readonly binding: string;
readonly envelope: string;
readonly prefix: string;
} {
if (!IDENTITY.test(identity)) {
throw new CredentialStoreError(
'invalid-identity',
'identity is outside the allowlist grammar',
);
}
const config = this.estateRegistry.resolve(estate, host);
if (config === undefined) {
throw new CredentialStoreError('estate-host-mismatch', 'estate and host do not match');
}
const prefix = `${config.tokenPrefix}-${identity}`;
return {
token: join(this.tokenDirectory, `${prefix}.token`),
binding: join(this.tokenDirectory, `${prefix}.binding.json`),
envelope: join(this.tokenDirectory, `${prefix}.credential.json`),
prefix,
};
}
async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise<void> {
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
const token = validateSecret(Buffer.from(secret));
const envelope = credentialEnvelopeSchema.parse({
...metadata,
schemaVersion: 1,
tokenDigest: createHash('sha256').update(token).digest('hex'),
token: Buffer.from(token).toString('utf8'),
});
const suffix = randomUUID();
const envelopeTemp = `${paths.envelope}.${suffix}.tmp`;
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
let lock;
try {
lock = await open(lockPath, 'wx', 0o600);
} catch {
throw new CredentialStoreError(
'conflicting-credential-mutation',
'another mutation owns the identity lock',
);
}
try {
const handle = await open(envelopeTemp, 'wx', 0o600);
try {
await handle.writeFile(`${JSON.stringify(envelope)}\n`, 'utf8');
await handle.sync();
} finally {
await handle.close();
}
const beforeCommit = assertPrivateTokenDirectory(this.tokenDirectory);
if (
beforeCommit.dev !== directoryIdentity.dev ||
beforeCommit.ino !== directoryIdentity.ino
) {
throw new CredentialStoreError(
'insecure-token-owner',
'token directory changed during credential commit',
);
}
await rename(envelopeTemp, paths.envelope);
await syncDirectory(this.tokenDirectory);
} finally {
await lock.close();
await unlink(envelopeTemp).catch((): void => undefined);
await unlink(lockPath).catch((): void => undefined);
await syncDirectory(this.tokenDirectory);
}
}
async snapshot(
identity: string,
estate: string,
host: string,
): Promise<
| {
readonly binding: CredentialBindingMetadataDto;
readonly secret: Uint8Array;
}
| undefined
> {
const binding = await this.readBinding(identity, estate, host);
if (binding === undefined) return undefined;
const resolved = await new FileCredentialResolver(
this.tokenDirectory,
this.estateRegistry,
).resolve(identity, estate, host);
if (resolved === undefined) {
throw new CredentialStoreError(
'invalid-binding',
'binding metadata exists without its token generation',
);
}
return { binding, secret: new Uint8Array(resolved.secret) };
}
async readBinding(
identity: string,
estate: string,
host: string,
): Promise<CredentialBindingMetadataDto | undefined> {
const paths = this.paths(identity, estate, host);
let snapshot: SecureFileSnapshot;
try {
const envelope = readRegularFileSecure(paths.envelope, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
const parsed = credentialEnvelopeSchema.safeParse(
JSON.parse(envelope.content.toString('utf8')),
);
if (!parsed.success) {
throw new CredentialStoreError('invalid-binding', 'credential envelope is malformed');
}
const verified = await new FileCredentialResolver(
this.tokenDirectory,
this.estateRegistry,
).resolve(identity, estate, host);
if (verified === undefined) {
throw new CredentialStoreError('invalid-binding', 'credential envelope was not resolvable');
}
verified.secret.fill(0);
return {
schemaVersion: 1,
identity: parsed.data.identity,
estate: parsed.data.estate,
host: parsed.data.host,
providerLogin: parsed.data.providerLogin,
tokenName: parsed.data.tokenName,
scopes: parsed.data.scopes,
createdAt: parsed.data.createdAt,
tokenDigest: parsed.data.tokenDigest,
};
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
}
try {
snapshot = readRegularFileSecure(paths.binding, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
} catch (error: unknown) {
if (isMissingFile(error)) return undefined;
throw error;
}
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new CredentialStoreError('insecure-token-owner', 'binding metadata is not private');
}
const parsed = bindingSchema.safeParse(JSON.parse(snapshot.content.toString('utf8')));
if (!parsed.success) {
throw new CredentialStoreError(
'invalid-binding',
'binding metadata failed schema validation',
);
}
return parsed.data;
}
async list(estate: string, host: string): Promise<readonly string[]> {
const config = this.estateRegistry.resolve(estate, host);
if (config === undefined) return [];
const names = await readdir(this.tokenDirectory);
const prefix = `${config.tokenPrefix}-`;
return [
...new Set(
names.flatMap((name): string[] => {
if (!name.startsWith(prefix)) return [];
if (name.endsWith('.token')) {
return [name.slice(prefix.length, -'.token'.length)];
}
if (name.endsWith('.credential.json')) {
return [name.slice(prefix.length, -'.credential.json'.length)];
}
return [];
}),
),
]
.filter((identity): boolean => IDENTITY.test(identity))
.sort();
}
async remove(
identity: string,
estate: string,
host: string,
expectedTokenDigest?: string,
): Promise<void> {
const paths = this.paths(identity, estate, host);
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
let lock;
try {
lock = await open(lockPath, 'wx', 0o600);
} catch {
throw new CredentialStoreError(
'conflicting-credential-mutation',
'another mutation owns the identity lock',
);
}
try {
if (expectedTokenDigest !== undefined) {
const current = await this.readBinding(identity, estate, host);
if (current === undefined || current.tokenDigest !== expectedTokenDigest) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'credential generation changed before removal',
);
}
}
const beforeRemoval = assertPrivateTokenDirectory(this.tokenDirectory);
if (
beforeRemoval.dev !== directoryIdentity.dev ||
beforeRemoval.ino !== directoryIdentity.ino
) {
throw new CredentialStoreError(
'insecure-token-owner',
'token directory changed during credential removal',
);
}
await unlink(paths.token).catch((error: unknown): void => {
if (!isMissingFile(error)) throw error;
});
await unlink(paths.binding).catch((error: unknown): void => {
if (!isMissingFile(error)) throw error;
});
await unlink(paths.envelope).catch((error: unknown): void => {
if (!isMissingFile(error)) throw error;
});
await syncDirectory(this.tokenDirectory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
await syncDirectory(this.tokenDirectory);
}
}
}
@@ -1,276 +0,0 @@
import { describe, expect, it } from 'vitest';
import { GiteaCredentialProviderAdapter, GiteaTeamGrantProviderAdapter } from './gitea-provider.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
const credential: ResolvedCredential = Object.freeze({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
resolutionId: 'resolution-1',
secret: new TextEncoder().encode('seeded-secret-canary'),
});
function jsonResponse(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json;charset=utf-8' },
});
}
describe('Gitea credential provider transport', (): void => {
it('reads the provider identity with the fixed transport and no secret in the URL', async (): Promise<void> => {
const calls: Array<{ readonly input: string; readonly init?: RequestInit }> = [];
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
calls.push({ input: String(input), ...(init === undefined ? {} : { init }) });
return jsonResponse({ id: 21, login: 'seat-name' });
},
);
const evidence = await adapter.readIdentity(credential);
expect(evidence).toEqual({
login: 'seat-name',
endpoint: 'GET /api/v1/user',
contentType: 'application/json;charset=utf-8',
});
expect(calls[0]?.input).toBe('https://git.example.invalid/api/v1/user');
expect(calls[0]?.input).not.toContain('seeded-secret-canary');
expect(new Headers(calls[0]?.init?.headers).get('user-agent')).toBe('mosaic-cred/1');
});
it('maps the authenticated provider repository object to effective permission', async (): Promise<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> =>
jsonResponse({
id: 99,
full_name: 'owner/repo',
permissions: { admin: false, push: true, pull: true },
}),
);
const evidence = await adapter.readRepositoryPermission(credential, 'owner/repo');
expect(evidence.effective).toBe('write');
expect(evidence.endpoint).toBe('GET /api/v1/repos/owner/repo');
});
it('binds an authenticated receive-pack advertisement to the supplied credential handle', async (): Promise<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> =>
new Response('001f# service=git-receive-pack\n0000', {
status: 200,
headers: {
'content-type': 'application/x-git-receive-pack-advertisement',
},
}),
);
const evidence = await adapter.probeReceivePack(credential, 'owner/repo');
expect(evidence).toEqual({
state: 'advertised',
principal: 'seat-name',
resolutionId: 'resolution-1',
contentType: 'application/x-git-receive-pack-advertisement',
});
});
it('reports authenticated and unauthenticated receive-pack refusals without inventing success', async (): Promise<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> =>
new Response('denied', { status: 403, headers: { 'content-type': 'text/plain' } }),
);
await expect(adapter.probeReceivePack(credential, 'owner/repo')).resolves.toMatchObject({
state: 'refused',
principal: 'seat-name',
resolutionId: 'resolution-1',
});
await expect(adapter.probeReceivePack(undefined, 'owner/repo')).resolves.toMatchObject({
state: 'refused',
principal: null,
resolutionId: null,
});
});
it('does not call a scope-forbidden identity read a dead credential', async (): Promise<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> => jsonResponse({ message: 'forbidden' }, 403),
);
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
code: 'identity-read-forbidden',
});
});
it('classifies only the supplied credential as rejected without inferring identity absence', async (): Promise<void> => {
let calls = 0;
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> => {
calls += 1;
return jsonResponse({ message: 'unauthorized' }, 401);
},
);
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
code: 'credential-rejected',
});
expect(calls).toBe(1);
});
it('classifies a rejected credential separately when the declared identity exists', async (): Promise<void> => {
let call = 0;
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> => {
call += 1;
if (call === 1) return jsonResponse({ message: 'unauthorized' }, 401);
return jsonResponse({ id: 21, login: 'seat-name' });
},
);
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
code: 'credential-rejected',
});
});
it('cancels an undeclared oversized streaming provider response before buffering it all', async (): Promise<void> => {
let pulls = 0;
let cancelled = false;
const stream = new ReadableStream<Uint8Array>({
pull(controller): void {
pulls += 1;
controller.enqueue(new Uint8Array(64 * 1024));
if (pulls === 100) controller.close();
},
cancel(): void {
cancelled = true;
},
});
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> =>
new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }),
);
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
code: 'unexpected-provider-shape',
});
expect(pulls).toBeLessThan(100);
expect(cancelled).toBe(true);
});
it('rejects a 200 HTML identity response as unexpected content type', async (): Promise<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> =>
new Response('<html>not an API object</html>', {
status: 200,
headers: { 'content-type': 'text/html' },
}),
);
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
code: 'unexpected-content-type',
});
});
it('reads team permission, member attachment, and repository attachment separately', async (): Promise<void> => {
let memberRemoved = false;
let repositoryDetached = false;
const adapter = new GiteaTeamGrantProviderAdapter(
'https://git.example.invalid',
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url = String(input);
if (url.endsWith('/api/v1/orgs/owner/teams')) {
return jsonResponse([{ id: 7, name: 'writers', permission: 'write' }]);
}
if (init?.method === 'PUT') return new Response(null, { status: 204 });
if (init?.method === 'DELETE') {
if (url.includes('/members/')) memberRemoved = true;
if (url.includes('/repos/')) repositoryDetached = true;
return new Response(null, { status: 204 });
}
if (url.includes('/members/seat-name')) {
return jsonResponse({ id: 21, login: 'seat-name' });
}
if (url.includes('/repos/owner/repo')) {
return jsonResponse({
id: 4,
full_name: 'owner/repo',
permissions: { admin: false, push: true, pull: true },
});
}
return jsonResponse({ message: 'unexpected' }, 500);
},
);
const team = await adapter.resolveTeam(credential, 'owner', 'writers');
await adapter.addTeamMember(credential, team.id, 'seat-name');
await adapter.attachTeamRepository(credential, team.id, 'owner/repo');
await expect(adapter.readTeamMember(credential, team.id, 'seat-name')).resolves.toMatchObject({
state: 'present',
});
await expect(
adapter.readTeamRepository(credential, team.id, 'owner/repo'),
).resolves.toMatchObject({ state: 'present' });
await adapter.removeTeamMember(credential, team.id, 'seat-name');
await adapter.detachTeamRepository(credential, team.id, 'owner/repo');
expect(memberRemoved).toBe(true);
expect(repositoryDetached).toBe(true);
expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' });
});
it('rejects a successful team read-back that names the wrong object', async (): Promise<void> => {
const adapter = new GiteaTeamGrantProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> => jsonResponse({ id: 99, login: 'other-seat' }),
);
await expect(adapter.readTeamMember(credential, 7, 'seat-name')).rejects.toMatchObject({
code: 'unexpected-provider-shape',
});
});
it('bounds a provider that never returns response headers', async (): Promise<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (_input: string | URL | Request, init?: RequestInit): Promise<Response> =>
new Promise<Response>((_resolve, reject): void => {
init?.signal?.addEventListener('abort', (): void => {
reject(new Error('aborted'));
});
}),
10,
);
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
code: 'provider-unavailable',
});
});
it('never includes seeded secret material in provider error messages', async (): Promise<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> => {
throw new Error('connection reset');
},
);
let message = '';
try {
await adapter.readIdentity(credential);
} catch (error: unknown) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).not.toContain('seeded-secret-canary');
expect(message).toContain('provider-unavailable');
});
});
@@ -1,943 +0,0 @@
import { z } from 'zod';
import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js';
import type { GiteaGrantProvider } from './grant.js';
import type { GiteaLifecycleProvider, MintedToken } from './lifecycle.js';
import type { TokenObjectEvidenceDto } from './lifecycle.dto.js';
import type {
GiteaTeamGrantProvider,
PresenceEvidence,
TeamRepositorySetEvidence,
TeamResolutionEvidence,
} from './team-grant.js';
import type {
CollaboratorPermissionEvidenceDto,
OrganizationMembershipEvidenceDto,
} from './grant.dto.js';
import type {
ProviderIdentityEvidenceDto,
ReceivePackEvidenceDto,
RepositoryPermission,
RepositoryPermissionEvidenceDto,
} from './credential-result.dto.js';
const MAX_PROVIDER_BYTES = 1024 * 1024;
const USER_AGENT = 'mosaic-cred/1';
const JSON_CONTENT_TYPE = 'application/json';
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
const REPO_COMPONENT = /^[A-Za-z0-9_.-]+$/;
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
const userSchema = z
.object({
id: z.number().int(),
login: z.string().min(1),
is_admin: z.boolean().optional(),
visibility: z.enum(['public', 'limited', 'private']).optional(),
})
.passthrough();
const collaboratorPermissionSchema = z
.object({
permission: z.enum(['read', 'write', 'admin']),
user: z.object({ login: z.string().min(1) }).passthrough(),
})
.passthrough();
const organizationSchema = z.object({ username: z.string().min(1) }).passthrough();
const tokenObjectSchema = z
.object({
name: z.string().min(1),
sha1: z.string().min(1).optional(),
token: z.string().min(1).optional(),
scopes: z.array(z.string()).default([]),
})
.passthrough();
const teamSchema = z
.object({
id: z.number().int().positive(),
name: z.string().min(1),
permission: z.enum(['read', 'write', 'admin']),
})
.passthrough();
const repoSchema = z
.object({
id: z.number().int(),
full_name: z.string().min(3),
permissions: z
.object({
admin: z.boolean(),
push: z.boolean(),
pull: z.boolean(),
})
.strict(),
})
.passthrough();
export class CredentialProviderEvidenceError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`Gitea credential evidence unavailable: code=${code} ${message}`);
this.name = 'CredentialProviderEvidenceError';
}
}
function contentType(response: Response): string {
return response.headers.get('content-type') ?? '';
}
function isJson(response: Response): boolean {
return contentType(response).toLowerCase().startsWith(JSON_CONTENT_TYPE);
}
async function boundedBody(response: Response): Promise<Uint8Array> {
const declared = response.headers.get('content-length');
if (declared !== null) {
if (!/^\d+$/.test(declared)) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'provider response declared an invalid content length',
);
}
const bytes = Number(declared);
if (!Number.isSafeInteger(bytes) || bytes > MAX_PROVIDER_BYTES) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'provider response exceeded the bounded size',
);
}
}
if (response.body === null) return new Uint8Array();
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const next = await reader.read();
if (next.done) break;
total += next.value.byteLength;
if (total > MAX_PROVIDER_BYTES) {
await reader.cancel();
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'provider response exceeded the bounded size',
);
}
chunks.push(next.value);
}
} finally {
reader.releaseLock();
}
if (declared !== null && total !== Number(declared)) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'provider response length contradicted its declaration',
);
}
const body = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return body;
}
async function jsonObject(response: Response): Promise<unknown> {
if (!isJson(response)) {
throw new CredentialProviderEvidenceError(
'unexpected-content-type',
'provider response was not JSON',
);
}
const bytes = await boundedBody(response);
try {
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
} catch {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'provider JSON could not be parsed',
);
}
}
function tokenText(resolved: ResolvedCredential): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(resolved.secret);
} catch {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'credential bytes were not valid text',
);
}
}
function apiAuthorization(resolved: ResolvedCredential): string {
return `token ${tokenText(resolved)}`;
}
function gitAuthorization(resolved: ResolvedCredential): string {
const basic = Buffer.from(`${resolved.identity}:${tokenText(resolved)}`, 'utf8').toString(
'base64',
);
return `Basic ${basic}`;
}
function repoPath(repo: string): { readonly owner: string; readonly name: string } {
const pieces = repo.split('/');
const owner = pieces[0];
const name = pieces[1];
if (
pieces.length !== 2 ||
owner === undefined ||
name === undefined ||
!REPO_COMPONENT.test(owner) ||
!REPO_COMPONENT.test(name)
) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'repository must be exactly owner/name in the allowlist grammar',
);
}
return { owner, name };
}
function effectivePermission(permissions: {
readonly admin: boolean;
readonly push: boolean;
readonly pull: boolean;
}): RepositoryPermission {
if (permissions.admin) return 'admin';
if (permissions.push) return 'write';
if (permissions.pull) return 'read';
return 'none';
}
export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider {
protected readonly origin: string;
constructor(
apiBaseUrl: string,
private readonly fetchImpl: FetchLike = fetch,
private readonly requestTimeoutMs = 10_000,
) {
const parsed = new URL(apiBaseUrl);
this.origin = parsed.origin;
if (
!Number.isSafeInteger(requestTimeoutMs) ||
requestTimeoutMs < 1 ||
requestTimeoutMs > 30_000
) {
throw new CredentialProviderEvidenceError(
'invalid-input',
'provider request timeout is outside the bounded range',
);
}
}
protected async request(url: string, init: RequestInit): Promise<Response> {
const deadline = AbortSignal.timeout(this.requestTimeoutMs);
const signal = init.signal == null ? deadline : AbortSignal.any([init.signal, deadline]);
try {
return await this.fetchImpl(url, { ...init, signal });
} catch {
throw new CredentialProviderEvidenceError(
'provider-unavailable',
'provider request failed before evidence was available',
);
}
}
private async classifyRejectedIdentity(rejected: Response): Promise<never> {
if (!isJson(rejected)) {
throw new CredentialProviderEvidenceError(
'unexpected-content-type',
'provider credential rejection was not JSON',
);
}
const status = rejected.status;
await boundedBody(rejected);
if (status === 403 || status === 404) {
throw new CredentialProviderEvidenceError(
'identity-read-forbidden',
'provider denied the identity endpoint; credential capability must be tested in scope',
);
}
throw new CredentialProviderEvidenceError(
'credential-rejected',
'provider rejected the supplied credential; account existence was not inferred',
);
}
async readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
const endpoint = 'GET /api/v1/user';
const response = await this.request(`${this.origin}/api/v1/user`, {
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(resolved),
'User-Agent': USER_AGENT,
},
});
if (response.status === 401 || response.status === 403 || response.status === 404) {
return this.classifyRejectedIdentity(response);
}
if (!response.ok) {
throw new CredentialProviderEvidenceError(
'provider-unavailable',
`provider identity request returned HTTP ${response.status.toString()}`,
);
}
const parsed = userSchema.safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'provider identity object lacked required fields',
);
}
return {
login: parsed.data.login,
endpoint,
contentType: contentType(response),
};
}
async readRepositoryPermission(
resolved: ResolvedCredential,
repo: string,
): Promise<RepositoryPermissionEvidenceDto> {
const { owner, name } = repoPath(repo);
const endpoint = `GET /api/v1/repos/${owner}/${name}`;
const response = await this.request(`${this.origin}/api/v1/repos/${owner}/${name}`, {
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(resolved),
'User-Agent': USER_AGENT,
},
});
if (!response.ok) {
throw new CredentialProviderEvidenceError(
'provider-unavailable',
`provider repository request returned HTTP ${response.status.toString()}`,
);
}
const parsed = repoSchema.safeParse(await jsonObject(response));
if (!parsed.success || parsed.data.full_name !== repo) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'provider repository object did not identify the requested repository',
);
}
return {
effective: effectivePermission(parsed.data.permissions),
endpoint,
contentType: contentType(response),
};
}
async probeReceivePack(
resolved: ResolvedCredential | undefined,
repo: string,
): Promise<ReceivePackEvidenceDto> {
const { owner, name } = repoPath(repo);
const headers = new Headers({
Accept: RECEIVE_PACK_CONTENT_TYPE,
'User-Agent': USER_AGENT,
});
if (resolved !== undefined) headers.set('Authorization', gitAuthorization(resolved));
const response = await this.request(
`${this.origin}/${owner}/${name}.git/info/refs?service=git-receive-pack`,
{ method: 'GET', headers },
);
const responseType = contentType(response);
if (response.status === 401 || response.status === 403) {
await boundedBody(response);
return {
state: 'refused',
principal: resolved?.identity ?? null,
resolutionId: resolved?.resolutionId ?? null,
contentType: responseType,
};
}
if (!response.ok || !responseType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)) {
throw new CredentialProviderEvidenceError(
response.ok ? 'unexpected-content-type' : 'provider-unavailable',
`receive-pack response was not an advertisement (HTTP ${response.status.toString()})`,
);
}
const body = new TextDecoder('utf-8', { fatal: true }).decode(await boundedBody(response));
if (!body.includes('# service=git-receive-pack')) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'receive-pack advertisement lacked the protocol service preamble',
);
}
return {
state: 'advertised',
principal: resolved?.identity ?? null,
resolutionId: resolved?.resolutionId ?? null,
contentType: responseType,
};
}
}
export class GiteaGrantProviderAdapter
extends GiteaCredentialProviderAdapter
implements GiteaGrantProvider
{
async readBasicIdentity(authority: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
const endpoint = 'GET /api/v1/user';
const response = await this.request(`${this.origin}/api/v1/user`, {
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
});
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
'credential-rejected',
'delegated Basic authority was rejected',
);
}
const parsed = userSchema.safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'delegated Basic identity response was malformed',
);
}
return { login: parsed.data.login, endpoint, contentType: contentType(response) };
}
async grantCollaborator(
authority: ResolvedCredential,
identity: string,
repo: string,
permission: RepositoryPermission,
): Promise<void> {
const { owner, name } = repoPath(repo);
const response = await this.request(
`${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}`,
{
method: 'PUT',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'Content-Type': JSON_CONTENT_TYPE,
'User-Agent': USER_AGENT,
},
body: JSON.stringify({ permission }),
},
);
await boundedBody(response);
if (!response.ok) {
throw new CredentialProviderEvidenceError(
response.status === 401 || response.status === 403
? 'credential-rejected'
: 'provider-unavailable',
`provider grant request returned HTTP ${response.status.toString()}`,
);
}
}
async readCollaboratorPermission(
authority: ResolvedCredential,
identity: string,
repo: string,
): Promise<CollaboratorPermissionEvidenceDto> {
const { owner, name } = repoPath(repo);
const endpoint = `GET /api/v1/repos/${owner}/${name}/collaborators/${identity}/permission`;
const response = await this.request(
`${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}/permission`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
'readback-missing',
`collaborator permission read-back returned HTTP ${response.status.toString()}`,
);
}
const parsed = collaboratorPermissionSchema.safeParse(await jsonObject(response));
if (!parsed.success || parsed.data.user.login !== identity) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'collaborator permission object did not identify the declared subject',
);
}
return {
identity: parsed.data.user.login,
permission: parsed.data.permission,
endpoint,
contentType: contentType(response),
};
}
async readOrganizationMembership(
subject: ResolvedCredential,
organization: string,
): Promise<OrganizationMembershipEvidenceDto> {
const endpoint = `GET /api/v1/users/${subject.identity}/orgs`;
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(subject.identity)}/orgs`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(subject),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
response.status === 401 || response.status === 403
? 'scope-not-evaluable'
: 'provider-unavailable',
`organization membership read-back returned HTTP ${response.status.toString()}`,
);
}
const parsed = z.array(organizationSchema).safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'organization membership response was not an organization array',
);
}
return {
state: parsed.data.some((entry): boolean => entry.username === organization)
? 'present'
: 'absent',
endpoint,
contentType: contentType(response),
};
}
}
export class GiteaTeamGrantProviderAdapter
extends GiteaGrantProviderAdapter
implements GiteaTeamGrantProvider
{
async resolveTeam(
authority: ResolvedCredential,
organization: string,
team: string,
): Promise<TeamResolutionEvidence> {
const endpoint = `GET /api/v1/orgs/${organization}/teams`;
const response = await this.request(
`${this.origin}/api/v1/orgs/${encodeURIComponent(organization)}/teams`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
'provider-unavailable',
'team list was unavailable',
);
}
const parsed = z.array(teamSchema).safeParse(await jsonObject(response));
const matches = parsed.success
? parsed.data.filter((entry): boolean => entry.name === team)
: [];
if (matches.length !== 1 || matches[0] === undefined) {
throw new CredentialProviderEvidenceError(
'readback-missing',
'team did not resolve uniquely',
);
}
return { ...matches[0], endpoint, contentType: contentType(response) };
}
async listTeamRepositories(
authority: ResolvedCredential,
teamId: number,
): Promise<TeamRepositorySetEvidence> {
const endpoint = `GET /api/v1/teams/${teamId.toString()}/repos`;
const repositories: string[] = [];
let observedType = '';
for (let page = 1; page <= 100; page += 1) {
const response = await this.request(
`${this.origin}/api/v1/teams/${teamId.toString()}/repos?limit=50&page=${page.toString()}`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
'readback-missing',
'team repository set was unavailable',
);
}
observedType = contentType(response);
const parsed = z.array(repoSchema).safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'team repository set was not a repository array',
);
}
repositories.push(...parsed.data.map((repo): string => repo.full_name));
if (parsed.data.length < 50) {
return { repositories, endpoint, contentType: observedType };
}
}
throw new CredentialProviderEvidenceError(
'readback-missing',
'team repository set exceeded the pagination bound',
);
}
async addTeamMember(
authority: ResolvedCredential,
teamId: number,
identity: string,
): Promise<void> {
await this.putTeamPath(
authority,
`/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
);
}
async removeTeamMember(
authority: ResolvedCredential,
teamId: number,
identity: string,
): Promise<void> {
const response = await this.request(
`${this.origin}/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
{
method: 'DELETE',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
await boundedBody(response);
if (!response.ok) {
throw new CredentialProviderEvidenceError(
'provider-unavailable',
'team member rollback failed',
);
}
}
async attachTeamRepository(
authority: ResolvedCredential,
teamId: number,
repo: string,
): Promise<void> {
const { owner, name } = repoPath(repo);
await this.putTeamPath(authority, `/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`);
}
async detachTeamRepository(
authority: ResolvedCredential,
teamId: number,
repo: string,
): Promise<void> {
const { owner, name } = repoPath(repo);
const response = await this.request(
`${this.origin}/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`,
{
method: 'DELETE',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
await boundedBody(response);
if (!response.ok) {
throw new CredentialProviderEvidenceError(
'provider-unavailable',
'team repository rollback failed',
);
}
}
private async putTeamPath(authority: ResolvedCredential, path: string): Promise<void> {
const response = await this.request(`${this.origin}${path}`, {
method: 'PUT',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
});
await boundedBody(response);
if (!response.ok) {
throw new CredentialProviderEvidenceError(
'provider-unavailable',
'team grant mutation failed',
);
}
}
async readTeamMember(
authority: ResolvedCredential,
teamId: number,
identity: string,
): Promise<PresenceEvidence> {
return this.readPresence(
authority,
`GET /api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
(value: unknown): boolean => {
const parsed = userSchema.safeParse(value);
return parsed.success && parsed.data.login === identity;
},
);
}
async readTeamRepository(
authority: ResolvedCredential,
teamId: number,
repo: string,
): Promise<PresenceEvidence> {
const { owner, name } = repoPath(repo);
return this.readPresence(
authority,
`GET /api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`,
(value: unknown): boolean => {
const parsed = repoSchema.safeParse(value);
return parsed.success && parsed.data.full_name === repo;
},
);
}
private async readPresence(
authority: ResolvedCredential,
endpoint: string,
matchesExpectedObject: (value: unknown) => boolean,
): Promise<PresenceEvidence> {
const response = await this.request(`${this.origin}${endpoint.slice(4)}`, {
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
});
if (response.status === 404) {
await boundedBody(response);
return { state: 'absent', endpoint, contentType: contentType(response) };
}
if (!response.ok || !isJson(response)) {
await boundedBody(response);
throw new CredentialProviderEvidenceError('readback-missing', 'team read-back failed');
}
if (!matchesExpectedObject(await jsonObject(response))) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'team read-back did not identify the requested object',
);
}
return { state: 'present', endpoint, contentType: contentType(response) };
}
}
function basicAuthorization(authority: ResolvedCredential): string {
const prefix = Buffer.from(`${authority.identity}:`, 'utf8');
const material = Buffer.concat([prefix, Buffer.from(authority.secret)]);
try {
return `Basic ${material.toString('base64')}`;
} finally {
prefix.fill(0);
material.fill(0);
}
}
export class GiteaLifecycleProviderAdapter
extends GiteaCredentialProviderAdapter
implements GiteaLifecycleProvider
{
async readBasicIdentity(authority: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
const endpoint = 'GET /api/v1/user';
const response = await this.request(`${this.origin}/api/v1/user`, {
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
});
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
'credential-rejected',
'delegated Basic authority was rejected',
);
}
const parsed = userSchema.safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'delegated Basic identity response was malformed',
);
}
return { login: parsed.data.login, endpoint, contentType: contentType(response) };
}
async mintToken(
authority: ResolvedCredential,
identity: string,
name: string,
scopes: readonly string[],
): Promise<MintedToken> {
const endpoint = `POST /api/v1/users/${identity}/tokens`;
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
{
method: 'POST',
headers: {
Accept: JSON_CONTENT_TYPE,
'Content-Type': JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
body: JSON.stringify({ name, scopes }),
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError('provider-unavailable', 'token mint failed');
}
const parsed = tokenObjectSchema.safeParse(await jsonObject(response));
const secret = parsed.success ? (parsed.data.sha1 ?? parsed.data.token) : undefined;
if (!parsed.success || secret === undefined || parsed.data.name !== name) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'minted token object lacked the requested name or secret',
);
}
return {
secret: new TextEncoder().encode(secret),
evidence: {
name: parsed.data.name,
scopes: parsed.data.scopes,
endpoint,
contentType: contentType(response),
},
};
}
async readToken(
authority: ResolvedCredential,
identity: string,
name: string,
): Promise<TokenObjectEvidenceDto> {
const endpoint = `GET /api/v1/users/${identity}/tokens`;
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError('readback-missing', 'token list read-back failed');
}
const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response));
const matches = parsed.success
? parsed.data.filter((token): boolean => token.name === name)
: [];
if (matches.length !== 1 || matches[0] === undefined) {
throw new CredentialProviderEvidenceError(
'readback-missing',
'minted token did not resolve uniquely by name',
);
}
return {
name: matches[0].name,
scopes: matches[0].scopes,
endpoint,
contentType: contentType(response),
};
}
async tokenExists(
authority: ResolvedCredential,
identity: string,
name: string,
): Promise<boolean> {
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
'readback-missing',
'token absence read-back failed',
);
}
const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'token absence read-back was malformed',
);
}
return parsed.data.some((token): boolean => token.name === name);
}
async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void> {
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`,
{
method: 'DELETE',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
await boundedBody(response);
if (!response.ok) {
throw new CredentialProviderEvidenceError('mutation-state-unknown', 'token revoke failed');
}
}
}
@@ -1,45 +0,0 @@
import type {
CredentialAuditResultDto,
CredentialMutationState,
CredentialOutcome,
CredentialReasonDto,
CredentialSubjectDto,
CredentialValidationEvidenceDto,
RepositoryPermission,
} from './credential-result.dto.js';
export interface CollaboratorPermissionEvidenceDto {
readonly identity: string;
readonly permission: RepositoryPermission;
readonly endpoint: string;
readonly contentType: string;
}
export interface OrganizationMembershipEvidenceDto {
readonly state: 'present' | 'absent';
readonly endpoint: string;
readonly contentType: string;
}
export interface CredentialGrantEvidenceDto extends CredentialValidationEvidenceDto {
readonly collaboratorPermission: CollaboratorPermissionEvidenceDto | null;
readonly organizationMembership: OrganizationMembershipEvidenceDto | null;
}
export interface CredentialGrantResultDto {
readonly schemaVersion: 1;
readonly operation: 'grant';
readonly outcome: CredentialOutcome;
readonly exitCode: 0 | 10 | 20 | 30;
readonly retryable: boolean;
readonly subject: CredentialSubjectDto;
readonly mutation: CredentialMutationState;
readonly reason: CredentialReasonDto;
readonly evidence: CredentialGrantEvidenceDto;
readonly audit: CredentialAuditResultDto;
}
export interface DirectGrantRequestDto extends CredentialSubjectDto {
readonly permission: RepositoryPermission;
readonly readOnlyControlIdentity: string;
}
@@ -1,230 +0,0 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { listCredentialJournals } from './audit-journal.js';
import { grantDirectRepositoryPermission } from './grant.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
import type { GiteaGrantProvider } from './grant.js';
import type { CredentialValidationDependencies } from './validate.js';
let cleanup: string | undefined;
const authority: ResolvedCredential = Object.freeze({
identity: 'provisioner',
estate: 'homelab',
host: 'git.example.invalid',
resolutionId: 'authority',
secret: new TextEncoder().encode('authority-canary'),
});
async function stateRoot(): Promise<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-grant-'));
return join(cleanup, 'state');
}
afterEach(async (): Promise<void> => {
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
function validationDependencies(permission: 'read' | 'write'): CredentialValidationDependencies {
const subject: ResolvedCredential = Object.freeze({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
resolutionId: 'subject',
secret: new TextEncoder().encode('subject-canary'),
});
const control: ResolvedCredential = Object.freeze({
identity: 'read-control',
estate: 'homelab',
host: 'git.example.invalid',
resolutionId: 'control',
secret: new TextEncoder().encode('control-canary'),
});
return {
estateRegistry: { matches: (): boolean => true },
resolver: {
async resolve(identity: string): Promise<ResolvedCredential | undefined> {
if (identity === 'seat-name') return subject;
if (identity === 'read-control') return control;
return undefined;
},
},
provider: {
async readIdentity(resolved: ResolvedCredential) {
return {
login: resolved.identity,
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async readRepositoryPermission(resolved: ResolvedCredential) {
return {
effective: resolved.identity === 'seat-name' ? permission : 'read',
endpoint: 'GET /api/v1/repos/owner/repo',
contentType: 'application/json',
};
},
async probeReceivePack(resolved: ResolvedCredential | undefined) {
const subjectWrite = resolved?.identity === 'seat-name' && permission === 'write';
return {
state: subjectWrite ? 'advertised' : 'refused',
principal: resolved?.identity ?? null,
resolutionId: resolved?.resolutionId ?? null,
contentType: subjectWrite ? 'application/x-git-receive-pack-advertisement' : 'text/plain',
};
},
},
};
}
describe('direct repository grant', (): void => {
it('opens the journal before mutation and accepts only matching provider read-back', async (): Promise<void> => {
const root = await stateRoot();
const provider: GiteaGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async grantCollaborator(): Promise<void> {
expect((await listCredentialJournals(root))[0]?.state).toBe('open');
},
async readCollaboratorPermission() {
return {
identity: 'seat-name',
permission: 'write',
endpoint: 'GET /api/v1/repos/owner/repo/collaborators/seat-name/permission',
contentType: 'application/json',
};
},
async readOrganizationMembership() {
return {
state: 'absent',
endpoint: 'GET /api/v1/users/seat-name/orgs',
contentType: 'application/json',
};
},
};
const result = await grantDirectRepositoryPermission(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write',
readOnlyControlIdentity: 'read-control',
},
authority,
provider,
validationDependencies('write'),
{ stateRoot: root, actor: 'provisioner' },
);
expect(result.outcome).toBe('ok');
expect(result.mutation).toBe('applied');
expect(result.evidence.repositoryPermission?.effective).toBe('write');
expect(result.evidence.organizationMembership?.state).toBe('absent');
expect(result.audit.state).toBe('sealed');
const [sealed] = await listCredentialJournals(root);
const source = await readFile(sealed?.path ?? '', 'utf8');
expect(source).toContain('"phase":"mutation"');
expect(source).toContain('"decision":"collaborator-grant-applied"');
expect(source).toContain('"decision":"identity-verified"');
expect(source).toContain('"decision":"organization-member-absent"');
expect(source).toContain('"decision":"transport-write-verified"');
});
it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise<void> => {
const root = await stateRoot();
const provider: GiteaGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async grantCollaborator(): Promise<void> {},
async readCollaboratorPermission() {
throw new Error('read-back unavailable');
},
async readOrganizationMembership() {
throw new Error('must not be reached');
},
};
const result = await grantDirectRepositoryPermission(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write',
readOnlyControlIdentity: 'read-control',
},
authority,
provider,
validationDependencies('write'),
{ stateRoot: root, actor: 'provisioner' },
);
expect(result.outcome).toBe('indeterminate');
expect(result.mutation).toBe('applied');
expect(result.reason.code).toBe('readback-missing');
expect(result.audit.journalId).not.toBeNull();
expect(result.audit.state).toBe('sealed');
});
it('is indeterminate when grant read-back disagrees with the requested permission', async (): Promise<void> => {
const root = await stateRoot();
const provider: GiteaGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async grantCollaborator(): Promise<void> {},
async readCollaboratorPermission() {
return {
identity: 'seat-name',
permission: 'read',
endpoint: 'GET /api/v1/repos/owner/repo/collaborators/seat-name/permission',
contentType: 'application/json',
};
},
async readOrganizationMembership() {
return {
state: 'absent',
endpoint: 'GET /api/v1/users/seat-name/orgs',
contentType: 'application/json',
};
},
};
const result = await grantDirectRepositoryPermission(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write',
readOnlyControlIdentity: 'read-control',
},
authority,
provider,
validationDependencies('read'),
{ stateRoot: root, actor: 'provisioner' },
);
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('permission-evidence-disagrees');
expect(result.mutation).toBe('applied');
});
});
-262
View File
@@ -1,262 +0,0 @@
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
import type {
CredentialValidationDependencies,
ResolvedCredential,
} from './credential-provider.dto.js';
import type { RepositoryPermission } from './credential-result.dto.js';
import type {
CollaboratorPermissionEvidenceDto,
CredentialGrantResultDto,
DirectGrantRequestDto,
OrganizationMembershipEvidenceDto,
} from './grant.dto.js';
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
export interface GiteaGrantProvider {
readBasicIdentity(authority: ResolvedCredential): Promise<{
readonly login: string;
readonly endpoint: string;
readonly contentType: string;
}>;
grantCollaborator(
authority: ResolvedCredential,
identity: string,
repo: string,
permission: RepositoryPermission,
): Promise<void>;
readCollaboratorPermission(
authority: ResolvedCredential,
identity: string,
repo: string,
): Promise<CollaboratorPermissionEvidenceDto>;
readOrganizationMembership(
subject: ResolvedCredential,
organization: string,
): Promise<OrganizationMembershipEvidenceDto>;
}
export class CredentialGrantExecutionError extends Error {
constructor(
public readonly code: string,
public readonly mutation: 'none' | 'unknown' | 'applied',
public readonly journalId: string,
) {
super(`Credential grant control failed: code=${code}`);
this.name = 'CredentialGrantExecutionError';
}
}
export interface CredentialGrantServiceOptions {
readonly stateRoot: string;
readonly actor: string;
}
function exitFor(outcome: CredentialGrantResultDto['outcome']): 0 | 10 | 20 | 30 {
if (outcome === 'ok') return 0;
if (outcome === 'refused') return 10;
if (outcome === 'error') return 20;
return 30;
}
export async function grantDirectRepositoryPermission(
request: DirectGrantRequestDto,
authority: ResolvedCredential,
grantProvider: GiteaGrantProvider,
validationDependencies: CredentialValidationDependencies,
options: CredentialGrantServiceOptions,
): Promise<CredentialGrantResultDto> {
const journal = await CredentialAuditJournal.open(options.stateRoot, {
operation: 'grant',
actor: options.actor,
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
});
await journal.recordIntent('provider-grant');
let mutation: 'none' | 'unknown' | 'applied' = 'none';
try {
const authorityIdentity = await grantProvider.readBasicIdentity(authority);
if (authorityIdentity.login !== options.actor) {
await journal.seal('refused', 'provider-identity-mismatch');
return {
schemaVersion: 1,
operation: 'grant',
outcome: 'refused',
exitCode: 10,
retryable: false,
subject: {
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
},
mutation: 'none',
reason: {
code: 'provider-identity-mismatch',
message: 'Delegated grant authority did not authenticate as the explicit audit actor.',
},
evidence: {
providerIdentity: authorityIdentity,
tokenCapabilities: {
state: 'not-measured',
scopes: [],
source: 'runtime-not-authorized',
},
repositoryPermission: null,
writeDifferential: null,
collaboratorPermission: null,
organizationMembership: null,
},
audit: { journalId: journal.journalId(), state: 'sealed' },
};
}
await journal.recordProviderEvidence({
endpoint: authorityIdentity.endpoint,
contentType: authorityIdentity.contentType,
decision: 'identity-verified',
});
mutation = 'unknown';
await grantProvider.grantCollaborator(
authority,
request.identity,
request.repo,
request.permission,
);
mutation = 'applied';
await journal.recordMutation('collaborator-grant-applied');
const collaborator = await grantProvider.readCollaboratorPermission(
authority,
request.identity,
request.repo,
);
const subject = await validationDependencies.resolver.resolve(
request.identity,
request.estate,
request.host,
);
const organization = request.repo.split('/')[0] ?? '';
const organizationMembership =
subject === undefined
? null
: await grantProvider.readOrganizationMembership(subject, organization);
const validation =
request.permission === 'read'
? await evaluateGiteaReadValidation(request, validationDependencies)
: await evaluateGiteaWriteValidation(request, validationDependencies);
if (organizationMembership !== null) {
await journal.recordProviderEvidence({
endpoint: organizationMembership.endpoint,
contentType: organizationMembership.contentType,
decision:
organizationMembership.state === 'present'
? 'organization-member-present'
: 'organization-member-absent',
});
}
if (validation.evidence.providerIdentity !== null) {
await journal.recordProviderEvidence({
endpoint: validation.evidence.providerIdentity.endpoint,
contentType: validation.evidence.providerIdentity.contentType,
decision: 'identity-verified',
});
}
if (validation.evidence.repositoryPermission !== null) {
await journal.recordProviderEvidence({
endpoint: validation.evidence.repositoryPermission.endpoint,
contentType: validation.evidence.repositoryPermission.contentType,
decision: `permission-${validation.evidence.repositoryPermission.effective}`,
});
}
if (validation.evidence.writeDifferential !== null) {
await journal.recordMutation('transport-write-verified');
}
const readBackMatches =
collaborator.identity === request.identity &&
collaborator.permission === request.permission &&
validation.outcome === 'ok' &&
validation.evidence.repositoryPermission?.effective === request.permission;
const outcome: CredentialGrantResultDto['outcome'] = readBackMatches ? 'ok' : 'indeterminate';
const reasonCode = readBackMatches ? 'grant-verified' : 'permission-evidence-disagrees';
await journal.recordProviderEvidence({
endpoint: collaborator.endpoint,
contentType: collaborator.contentType,
decision: `permission-${collaborator.permission}`,
});
await journal.seal(outcome, reasonCode);
return {
schemaVersion: 1,
operation: 'grant',
outcome,
exitCode: exitFor(outcome),
retryable: false,
subject: {
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
},
mutation: 'applied',
reason: {
code: reasonCode,
message: readBackMatches
? 'Grant matched every required provider read-back.'
: 'Grant mutation completed but provider permission evidence disagreed.',
},
evidence: {
...validation.evidence,
collaboratorPermission: collaborator,
organizationMembership,
},
audit: { journalId: journal.journalId(), state: 'sealed' },
};
} catch (error: unknown) {
if (error instanceof CredentialJournalError) {
throw new CredentialGrantExecutionError(error.code, mutation, journal.journalId());
}
const reasonCode = mutation === 'applied' ? 'readback-missing' : 'mutation-state-unknown';
try {
await journal.seal('indeterminate', reasonCode);
} catch (journalError: unknown) {
if (journalError instanceof CredentialJournalError) {
throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId());
}
throw journalError;
}
return {
schemaVersion: 1,
operation: 'grant',
outcome: 'indeterminate',
exitCode: 30,
retryable: false,
subject: {
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
},
mutation,
reason: {
code: reasonCode,
message: 'Grant mutation state was preserved after provider evidence failed.',
},
evidence: {
providerIdentity: null,
tokenCapabilities: {
state: 'not-measured',
scopes: [],
source: 'runtime-not-authorized',
},
repositoryPermission: null,
writeDifferential: null,
collaboratorPermission: null,
organizationMembership: null,
},
audit: { journalId: journal.journalId(), state: 'sealed' },
};
}
}
@@ -1,49 +0,0 @@
import type { CredentialOutcome } from './credential-result.dto.js';
export type CredentialLifecycleOperation =
| 'provision'
| 'wire'
| 'get'
| 'whoami'
| 'list'
| 'rotate'
| 'revoke'
| 'audit';
export interface TokenObjectEvidenceDto {
readonly name: string;
readonly scopes: readonly string[];
readonly endpoint: string;
readonly contentType: string;
}
export interface CredentialLifecycleResultDto {
readonly schemaVersion: 1;
readonly operation: CredentialLifecycleOperation;
readonly outcome: CredentialOutcome;
readonly exitCode: 0 | 10 | 20 | 30;
readonly retryable: boolean;
readonly subject: {
readonly identity: string;
readonly estate: string;
readonly host: string;
readonly repo: null;
};
readonly mutation: 'none' | 'unknown' | 'applied';
readonly reason: { readonly code: string; readonly message: string };
readonly evidence: {
readonly providerIdentity: string | null;
readonly token: TokenObjectEvidenceDto | null;
readonly teaLogin: {
readonly name: string;
readonly host: string;
readonly state: 'registered' | 'not-measured';
} | null;
readonly identities: readonly string[];
readonly journalIds: readonly string[];
};
readonly audit: {
readonly journalId: string | null;
readonly state: 'not-started' | 'open' | 'sealed';
};
}
@@ -1,214 +0,0 @@
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import type { ResolvedCredential } from './credential-provider.dto.js';
import { parseCredentialEstateRegistry } from './estate-registry.js';
import { FileCredentialStore } from './file-credential-store.js';
import { provisionCredential, revokeCredential, type GiteaLifecycleProvider } from './lifecycle.js';
import { TeaLoginStore } from './tea-login-store.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function fixture(): Promise<{
root: string;
store: FileCredentialStore;
teaStore: TeaLoginStore;
}> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-lifecycle-'));
const tokens = join(cleanup, 'tokens');
await mkdir(tokens, { mode: 0o700 });
const registry = parseCredentialEstateRegistry(
JSON.stringify({
version: 1,
estates: [
{
name: 'homelab',
readOnlyControlIdentity: 'control',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-example',
},
],
},
],
}),
);
return {
root: join(cleanup, 'state'),
store: new FileCredentialStore(tokens, registry),
teaStore: new TeaLoginStore(join(cleanup, 'tea', 'config.yml')),
};
}
const authority: ResolvedCredential = Object.freeze({
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
resolutionId: 'basic-authority',
secret: new TextEncoder().encode('password-canary'),
});
function provider(): GiteaLifecycleProvider {
return {
async readBasicIdentity() {
return { login: 'seat', endpoint: 'GET /api/v1/user', contentType: 'application/json' };
},
async mintToken(_authority, _identity, name, scopes) {
return {
secret: new TextEncoder().encode('minted-token-canary'),
evidence: {
name,
scopes,
endpoint: 'POST /api/v1/users/seat/tokens',
contentType: 'application/json',
},
};
},
async readToken(_authority, _identity, name) {
return {
name,
scopes: ['write:repository'],
endpoint: 'GET /api/v1/users/seat/tokens',
contentType: 'application/json',
};
},
async revokeToken(): Promise<void> {},
async tokenExists(): Promise<boolean> {
return false;
},
};
}
describe('credential lifecycle', (): void => {
it('accepts provision only after exact principal and scope read-back', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-1',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat', now: (): string => '2026-08-05T00:00:00.000Z' },
);
expect(result.outcome).toBe('ok');
await expect(
store.readBinding('seat', 'homelab', 'git.example.invalid'),
).resolves.toMatchObject({ providerLogin: 'seat', scopes: ['write:repository'] });
expect(JSON.stringify(result)).not.toContain('minted-token-canary');
});
it('rolls back a minted token when exact scope read-back disagrees', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
let revoked = false;
const lifecycleProvider = provider();
lifecycleProvider.readToken = async (_authority, _identity, name) => ({
name,
scopes: ['admin'],
endpoint: 'GET /api/v1/users/seat/tokens',
contentType: 'application/json',
});
lifecycleProvider.revokeToken = async (): Promise<void> => {
revoked = true;
};
lifecycleProvider.tokenExists = async (): Promise<boolean> => false;
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-bad',
scopes: ['write:repository'],
},
authority,
lifecycleProvider,
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result.outcome).toBe('error');
expect(result.mutation).toBe('none');
expect(revoked).toBe(true);
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('revokes at provider before removing the local binding', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-1',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
let revoked = false;
const lifecycleProvider = provider();
lifecycleProvider.revokeToken = async (): Promise<void> => {
revoked = true;
};
const result = await revokeCredential(
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
authority,
lifecycleProvider,
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result.outcome).toBe('ok');
expect(revoked).toBe(true);
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('preserves the local recovery binding when provider revocation read-back still finds the token', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-1',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
const lifecycleProvider = provider();
lifecycleProvider.tokenExists = async (): Promise<boolean> => true;
const result = await revokeCredential(
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
authority,
lifecycleProvider,
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result.outcome).toBe('indeterminate');
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']);
});
});
@@ -1,364 +0,0 @@
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
import type { FileCredentialStore } from './file-credential-store.js';
import type { TeaLoginStore } from './tea-login-store.js';
import type {
CredentialLifecycleOperation,
CredentialLifecycleResultDto,
TokenObjectEvidenceDto,
} from './lifecycle.dto.js';
export interface MintedToken {
readonly secret: Uint8Array;
readonly evidence: TokenObjectEvidenceDto;
}
export interface GiteaLifecycleProvider {
readBasicIdentity(authority: ResolvedCredential): Promise<{
readonly login: string;
readonly endpoint: string;
readonly contentType: string;
}>;
mintToken(
authority: ResolvedCredential,
identity: string,
name: string,
scopes: readonly string[],
): Promise<MintedToken>;
readToken(
authority: ResolvedCredential,
identity: string,
name: string,
): Promise<TokenObjectEvidenceDto>;
revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void>;
tokenExists(authority: ResolvedCredential, identity: string, name: string): Promise<boolean>;
}
export interface LifecycleRequest {
readonly identity: string;
readonly estate: string;
readonly host: string;
}
export interface ProvisionRequest extends LifecycleRequest {
readonly tokenName: string;
readonly scopes: readonly string[];
}
export interface LifecycleOptions {
readonly stateRoot: string;
readonly actor: string;
readonly now?: () => string;
readonly allowReplace?: boolean;
readonly journal?: CredentialAuditJournal;
readonly deferSuccessSeal?: boolean;
}
function lifecycleResult(
operation: CredentialLifecycleOperation,
request: LifecycleRequest,
options: {
readonly outcome: CredentialLifecycleResultDto['outcome'];
readonly mutation: CredentialLifecycleResultDto['mutation'];
readonly code: string;
readonly message: string;
readonly journalId: string | null;
readonly auditState: 'not-started' | 'open' | 'sealed';
readonly providerIdentity?: string | null;
readonly token?: TokenObjectEvidenceDto | null;
readonly teaLogin?: CredentialLifecycleResultDto['evidence']['teaLogin'];
},
): CredentialLifecycleResultDto {
const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const;
return {
schemaVersion: 1,
operation,
outcome: options.outcome,
exitCode: exits[options.outcome],
retryable: false,
subject: { ...request, repo: null },
mutation: options.mutation,
reason: { code: options.code, message: options.message },
evidence: {
providerIdentity: options.providerIdentity ?? null,
token: options.token ?? null,
teaLogin: options.teaLogin ?? null,
identities: [],
journalIds: [],
},
audit: { journalId: options.journalId, state: options.auditState },
};
}
async function openLifecycleJournal(
operation: 'provision' | 'rotate' | 'revoke',
request: LifecycleRequest,
options: LifecycleOptions,
): Promise<CredentialAuditJournal> {
const journal = await CredentialAuditJournal.open(options.stateRoot, {
operation,
actor: options.actor,
identity: request.identity,
estate: request.estate,
host: request.host,
repo: null,
});
await journal.recordIntent(`${operation}-requested`);
return journal;
}
export async function provisionCredential(
request: ProvisionRequest,
authority: ResolvedCredential,
provider: GiteaLifecycleProvider,
store: FileCredentialStore,
teaStore: TeaLoginStore,
options: LifecycleOptions,
): Promise<CredentialLifecycleResultDto> {
const journal = options.journal ?? (await openLifecycleJournal('provision', request, options));
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let minted: MintedToken | undefined;
let failureCode = 'mutation-state-unknown';
const prior = await store.snapshot(request.identity, request.estate, request.host);
if (prior !== undefined && options.allowReplace !== true) {
await journal.seal('refused', 'credential-already-exists');
return lifecycleResult('provision', request, {
outcome: 'refused',
mutation: 'none',
code: 'credential-already-exists',
message: 'A governed credential already exists; use rotate.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
try {
const identity = await provider.readBasicIdentity(authority);
if (identity.login !== request.identity || authority.identity !== request.identity) {
await journal.seal('refused', 'provider-identity-mismatch');
return lifecycleResult('provision', request, {
outcome: 'refused',
mutation: 'none',
code: 'provider-identity-mismatch',
message: 'Delegated Basic authority did not bind the requested principal.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
});
}
await journal.recordProviderEvidence({
endpoint: identity.endpoint,
contentType: identity.contentType,
decision: 'identity-verified',
});
mutation = 'unknown';
minted = await provider.mintToken(
authority,
request.identity,
request.tokenName,
request.scopes,
);
mutation = 'applied';
await journal.recordMutation('token-mint-applied');
const readBack = await provider.readToken(authority, request.identity, request.tokenName);
const expected = [...request.scopes].sort();
const actual = [...readBack.scopes].sort();
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
failureCode = 'scope-not-evaluable';
throw new Error('scope read-back disagreed');
}
await store.put(
{
identity: request.identity,
estate: request.estate,
host: request.host,
providerLogin: identity.login,
tokenName: request.tokenName,
scopes: readBack.scopes,
createdAt: options.now?.() ?? new Date().toISOString(),
},
minted.secret,
);
await journal.recordMutation('token-binding-stored');
await teaStore.put(request.identity, request.host, minted.secret);
const teaLogin = teaStore.readBack(request.identity, request.host);
if (
teaLogin === undefined ||
!teaStore.matchesSecret(request.identity, request.host, minted.secret)
) {
failureCode = 'tea-login-missing';
throw new Error('Tea login did not resolve exactly');
}
await journal.recordMutation('tea-login-stored');
await journal.recordProviderEvidence({
endpoint: readBack.endpoint,
contentType: readBack.contentType,
decision: 'scope-verified',
});
if (options.deferSuccessSeal !== true) {
await journal.seal('ok', 'provision-verified');
}
return lifecycleResult('provision', request, {
outcome: 'ok',
mutation: 'applied',
code: options.deferSuccessSeal === true ? 'replacement-staged' : 'provision-verified',
message:
options.deferSuccessSeal === true
? 'Replacement was read back and staged under the open rotation transaction.'
: 'Provider principal and exact token scopes were read back and stored.',
journalId: journal.journalId(),
auditState: options.deferSuccessSeal === true ? 'open' : 'sealed',
providerIdentity: identity.login,
token: readBack,
teaLogin: { ...teaLogin, state: 'registered' },
});
} catch (error: unknown) {
const journalFailure = error instanceof CredentialJournalError;
if (minted === undefined) {
if (journalFailure) throw error;
await journal.seal('indeterminate', 'provider-unavailable');
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation,
code: 'provider-unavailable',
message: 'Provider token mint did not complete.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
let rollbackComplete = false;
try {
await provider.revokeToken(authority, request.identity, request.tokenName);
if (await provider.tokenExists(authority, request.identity, request.tokenName)) {
throw new Error('minted token still exists after rollback');
}
if (prior === undefined) {
await store.remove(request.identity, request.estate, request.host);
await teaStore.remove(request.identity, request.host).catch((): void => undefined);
} else {
await store.put(prior.binding, prior.secret);
await teaStore.put(request.identity, request.host, prior.secret);
}
rollbackComplete = true;
} catch {
rollbackComplete = false;
} finally {
prior?.secret.fill(0);
}
if (journalFailure) {
if (rollbackComplete) {
await journal.recordMutation('provision-rollback-verified').catch((): void => undefined);
}
throw error;
}
const code = rollbackComplete ? failureCode : 'rollback-incomplete';
if (rollbackComplete) await journal.recordMutation('provision-rollback-verified');
await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code);
return lifecycleResult('provision', request, {
outcome: rollbackComplete ? 'error' : 'indeterminate',
mutation: rollbackComplete ? 'none' : mutation,
code,
message: rollbackComplete
? 'Provisioning failed and every completed mutation was rolled back.'
: 'Provisioning failed and rollback could not be proven complete.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} finally {
minted?.secret.fill(0);
prior?.secret.fill(0);
}
}
export async function revokeCredential(
request: LifecycleRequest,
authority: ResolvedCredential,
provider: GiteaLifecycleProvider,
store: FileCredentialStore,
teaStore: TeaLoginStore,
options: LifecycleOptions,
): Promise<CredentialLifecycleResultDto> {
const journal = await openLifecycleJournal('revoke', request, options);
let mutation: 'none' | 'unknown' | 'applied' = 'none';
try {
const binding = await store.readBinding(request.identity, request.estate, request.host);
if (binding === undefined) {
await journal.seal('refused', 'no-token-for-identity');
return lifecycleResult('revoke', request, {
outcome: 'refused',
mutation: 'none',
code: 'no-token-for-identity',
message: 'No governed token binding exists for the identity.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
const identity = await provider.readBasicIdentity(authority);
if (identity.login !== request.identity || authority.identity !== request.identity) {
await journal.seal('refused', 'provider-identity-mismatch');
return lifecycleResult('revoke', request, {
outcome: 'refused',
mutation: 'none',
code: 'provider-identity-mismatch',
message: 'Delegated Basic authority did not bind the requested principal.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
});
}
await journal.recordProviderEvidence({
endpoint: identity.endpoint,
contentType: identity.contentType,
decision: 'identity-verified',
});
mutation = 'unknown';
await provider.revokeToken(authority, request.identity, binding.tokenName);
mutation = 'applied';
await journal.recordMutation('token-revoke-applied');
if (await provider.tokenExists(authority, request.identity, binding.tokenName)) {
await journal.seal('indeterminate', 'revoke-readback-missing');
return lifecycleResult('revoke', request, {
outcome: 'indeterminate',
mutation,
code: 'revoke-readback-missing',
message: 'Provider still returned the token after revocation acknowledgement.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
await teaStore.remove(request.identity, request.host);
if (teaStore.readBack(request.identity, request.host) !== undefined) {
throw new Error('Tea login still exists after revocation');
}
await journal.recordMutation('tea-login-removed');
await store.remove(request.identity, request.estate, request.host, binding.tokenDigest);
await journal.seal('ok', 'revoke-verified');
return lifecycleResult('revoke', request, {
outcome: 'ok',
mutation,
code: 'revoke-verified',
message: 'Provider token revocation completed before local binding removal.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} catch (error: unknown) {
if (error instanceof CredentialJournalError) {
return lifecycleResult('revoke', request, {
outcome: 'indeterminate',
mutation,
code: error.code,
message: 'Audit persistence failed; inspect the durable open journal before recovery.',
journalId: journal.journalId(),
auditState: 'open',
});
}
await journal.seal('indeterminate', 'mutation-state-unknown');
return lifecycleResult('revoke', request, {
outcome: 'indeterminate',
mutation,
code: 'mutation-state-unknown',
message: 'Revocation mutation state could not be established completely.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
}
@@ -1,54 +0,0 @@
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { TeaLoginStore } from './tea-login-store.js';
let root: string | undefined;
afterEach(async (): Promise<void> => {
if (root !== undefined) await rm(root, { recursive: true, force: true });
root = undefined;
});
describe('host-bound Tea login store', (): void => {
it('serializes concurrent updates and preserves the same identity on two hosts', async (): Promise<void> => {
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
const store = new TeaLoginStore(join(root, 'tea', 'config.yml'));
await Promise.all([
store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
]);
expect(
store.matchesSecret('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
).toBe(true);
expect(
store.matchesSecret('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
).toBe(true);
await store.remove('seat', 'git.one.invalid');
expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined();
expect(store.readBack('seat', 'git.two.invalid')).toEqual({
name: 'seat--git.two.invalid',
host: 'git.two.invalid',
});
});
it('preserves unrelated Tea configuration and rejects permissive secret reads', async (): Promise<void> => {
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
const configPath = join(root, 'tea', 'config.yml');
const store = new TeaLoginStore(configPath);
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
const original = await readFile(configPath, 'utf8');
await writeFile(configPath, `preferences:\n color: true\n${original}`, { mode: 0o600 });
await store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two'));
await store.remove('seat', 'git.one.invalid');
expect(await readFile(configPath, 'utf8')).toContain('color: true');
await chmod(configPath, 0o644);
expect(() => store.resolve('seat', 'homelab', 'git.two.invalid')).toThrow(
/tea-config-insecure/,
);
expect(() => store.readBack('seat', 'git.two.invalid')).toThrow(/tea-config-insecure/);
});
});
@@ -1,246 +0,0 @@
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { open, rename, unlink } from 'node:fs/promises';
import { dirname } from 'node:path';
import { parse, stringify } from 'yaml';
import { z } from 'zod';
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
export class TeaLoginStoreError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`Tea login store rejected: code=${code} ${message}`);
this.name = 'TeaLoginStoreError';
}
}
interface TeaLoginRecord {
readonly name: string;
readonly url: string;
readonly token: string;
readonly user: string;
readonly default: boolean;
}
interface TeaConfig {
readonly logins: TeaLoginRecord[];
readonly [key: string]: unknown;
}
const loginSchema = z
.object({
name: z.string().regex(SAFE_NAME),
url: z.string().url(),
token: z.string().min(1),
user: z.string().regex(SAFE_NAME),
default: z.boolean().default(false),
})
.passthrough();
const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough();
async function syncDirectory(path: string): Promise<void> {
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
}
async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open>>> {
for (let attempt = 0; attempt < 500; attempt += 1) {
try {
return await open(path, 'wx', 0o600);
} catch (error: unknown) {
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error;
await new Promise<void>((resolve): void => {
setTimeout(resolve, 10);
});
}
}
throw new TeaLoginStoreError(
'conflicting-credential-mutation',
'Tea configuration lock did not become available',
);
}
function loginName(identity: string, host: string): string {
return `${identity}--${host}`;
}
function assertPrivate(snapshot: { readonly mode: number; readonly uid: number }): void {
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
}
}
function missing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
export class TeaLoginStore {
constructor(private readonly configPath: string) {}
async put(identity: string, host: string, secret: Uint8Array): Promise<void> {
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
}
const directory = dirname(this.configPath);
ensureManagedDirectory(directory, directory);
const lockPath = `${this.configPath}.lock`;
const lock = await acquireLock(lockPath);
try {
let current: TeaConfig = { logins: [] };
try {
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
assertPrivate(snapshot);
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
current = decoded.data;
} catch (error: unknown) {
if (!missing(error)) throw error;
}
const token = Buffer.from(secret).toString('utf8');
const record: TeaLoginRecord = {
name: loginName(identity, host),
url: `https://${host}`,
token,
user: identity,
default: false,
};
const logins = current.logins.filter(
(login): boolean =>
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
);
logins.push(record);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, this.configPath);
await syncDirectory(directory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
}
}
resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined {
const directory = dirname(this.configPath);
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
} catch (error: unknown) {
if (missing(error)) return undefined;
throw error;
}
assertPrivate(snapshot);
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) return undefined;
const matches = decoded.data.logins.filter(
(login): boolean =>
login.name === loginName(identity, host) &&
login.url === `https://${host}` &&
login.user === identity,
);
if (matches.length !== 1 || matches[0] === undefined) return undefined;
return Object.freeze({
identity,
estate,
host,
resolutionId: randomUUID(),
secret: new TextEncoder().encode(matches[0].token),
});
}
matchesSecret(identity: string, host: string, secret: Uint8Array): boolean {
const resolved = this.resolve(identity, 'binding-check', host);
if (resolved === undefined || resolved.secret.byteLength !== secret.byteLength) return false;
return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret));
}
async remove(identity: string, host: string): Promise<void> {
const directory = dirname(this.configPath);
const lockPath = `${this.configPath}.lock`;
const lock = await acquireLock(lockPath);
try {
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
assertPrivate(snapshot);
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
const logins = decoded.data.logins.filter(
(login): boolean =>
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ ...decoded.data, logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, this.configPath);
await syncDirectory(directory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
}
}
readBack(
identity: string,
host: string,
): { readonly name: string; readonly host: string } | undefined {
const directory = dirname(this.configPath);
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, { root: directory, maxBytes: 1024 * 1024 });
} catch (error: unknown) {
if (missing(error)) return undefined;
throw error;
}
assertPrivate(snapshot);
const decoded: unknown = parse(snapshot.content.toString('utf8'));
if (
typeof decoded !== 'object' ||
decoded === null ||
!('logins' in decoded) ||
!Array.isArray(decoded.logins)
)
return undefined;
const matches = decoded.logins.filter((value: unknown): value is TeaLoginRecord => {
if (typeof value !== 'object' || value === null) return false;
return (
'name' in value &&
value.name === loginName(identity, host) &&
'url' in value &&
value.url === `https://${host}` &&
'user' in value &&
value.user === identity
);
});
return matches.length === 1 ? { name: loginName(identity, host), host } : undefined;
}
}
@@ -1,613 +0,0 @@
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { grantTeamRepositoryPermission, type GiteaTeamGrantProvider } from './team-grant.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
import type { CredentialValidationDependencies } from './validate.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
const authority: ResolvedCredential = Object.freeze({
identity: 'provisioner',
estate: 'homelab',
host: 'git.example.invalid',
resolutionId: 'authority',
secret: new TextEncoder().encode('authority-canary'),
});
const subject: ResolvedCredential = Object.freeze({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
resolutionId: 'subject',
secret: new TextEncoder().encode('subject-canary'),
});
const control: ResolvedCredential = Object.freeze({
identity: 'read-control',
estate: 'homelab',
host: 'git.example.invalid',
resolutionId: 'control',
secret: new TextEncoder().encode('control-canary'),
});
function validation(): CredentialValidationDependencies {
return {
estateRegistry: { matches: (): boolean => true },
resolver: {
async resolve(identity: string) {
return identity === 'seat-name' ? subject : control;
},
},
provider: {
async readIdentity(resolved: ResolvedCredential) {
return {
login: resolved.identity,
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async readRepositoryPermission(resolved: ResolvedCredential) {
return {
effective: resolved.identity === 'seat-name' ? 'write' : 'read',
endpoint: 'GET /api/v1/repos/owner/repo',
contentType: 'application/json',
};
},
async probeReceivePack(resolved: ResolvedCredential | undefined) {
const write = resolved?.identity === 'seat-name';
return {
state: write ? 'advertised' : 'refused',
principal: resolved?.identity ?? null,
resolutionId: resolved?.resolutionId ?? null,
contentType: write ? 'application/x-git-receive-pack-advertisement' : 'text/plain',
};
},
},
};
}
describe('team repository grant', (): void => {
it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let repositoryReads = 0;
const provider: GiteaTeamGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async resolveTeam() {
return {
id: 7,
name: 'writers',
permission: 'write',
endpoint: 'GET /api/v1/orgs/owner/teams',
contentType: 'application/json',
};
},
async listTeamRepositories() {
repositoryReads += 1;
return {
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
endpoint: 'GET /api/v1/teams/7/repos',
contentType: 'application/json',
};
},
async addTeamMember(): Promise<void> {},
async removeTeamMember(): Promise<void> {},
async attachTeamRepository(): Promise<void> {},
async detachTeamRepository(): Promise<void> {},
async readTeamMember() {
return {
state: 'present',
endpoint: 'GET /api/v1/teams/7/members/seat-name',
contentType: 'application/json',
};
},
async readTeamRepository() {
return {
state: 'present',
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
contentType: 'application/json',
};
},
async readOrganizationMembership() {
return {
state: 'present',
endpoint: 'GET /api/v1/users/seat-name/orgs',
contentType: 'application/json',
};
},
};
const result = await grantTeamRepositoryPermission(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write',
team: 'writers',
readOnlyControlIdentity: 'read-control',
},
authority,
provider,
validation(),
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
);
expect(result.outcome).toBe('ok');
expect(result.evidence.organizationMembership?.state).toBe('present');
expect(result.evidence.teamMembership?.state).toBe('present');
expect(result.evidence.teamRepository?.state).toBe('present');
});
it('refuses a shared team already attached to any repository outside the request', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let mutated = false;
const provider: GiteaTeamGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async resolveTeam() {
return {
id: 7,
name: 'writers',
permission: 'write',
endpoint: 'GET /api/v1/orgs/owner/teams',
contentType: 'application/json',
};
},
async listTeamRepositories() {
return {
repositories: ['owner/unrelated'],
endpoint: 'GET /api/v1/teams/7/repos',
contentType: 'application/json',
};
},
async addTeamMember(): Promise<void> {
mutated = true;
},
async removeTeamMember(): Promise<void> {
mutated = true;
},
async attachTeamRepository(): Promise<void> {
mutated = true;
},
async detachTeamRepository(): Promise<void> {
mutated = true;
},
async readTeamMember() {
return {
state: 'absent',
endpoint: 'GET /api/v1/teams/7/members/seat-name',
contentType: 'application/json',
};
},
async readTeamRepository() {
return {
state: 'absent',
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
contentType: 'application/json',
};
},
async readOrganizationMembership() {
return {
state: 'absent',
endpoint: 'GET /api/v1/users/seat-name/orgs',
contentType: 'application/json',
};
},
};
const result = await grantTeamRepositoryPermission(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write',
team: 'writers',
readOnlyControlIdentity: 'read-control',
},
authority,
provider,
validation(),
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
);
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('team-scope-exceeds-request');
expect(mutated).toBe(false);
});
it('journals absent team objects as absent rather than present', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let repositoryReads = 0;
const provider: GiteaTeamGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async resolveTeam() {
return {
id: 7,
name: 'writers',
permission: 'write',
endpoint: 'GET /api/v1/orgs/owner/teams',
contentType: 'application/json',
};
},
async listTeamRepositories() {
repositoryReads += 1;
return {
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
endpoint: 'GET /api/v1/teams/7/repos',
contentType: 'application/json',
};
},
async addTeamMember(): Promise<void> {},
async removeTeamMember(): Promise<void> {},
async attachTeamRepository(): Promise<void> {},
async detachTeamRepository(): Promise<void> {},
async readTeamMember() {
return {
state: 'absent',
endpoint: 'GET /api/v1/teams/7/members/seat-name',
contentType: 'application/json',
};
},
async readTeamRepository() {
return {
state: 'absent',
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
contentType: 'application/json',
};
},
async readOrganizationMembership() {
return {
state: 'present',
endpoint: 'GET /api/v1/users/seat-name/orgs',
contentType: 'application/json',
};
},
};
const stateRoot = join(cleanup, 'state');
const result = await grantTeamRepositoryPermission(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write',
team: 'writers',
readOnlyControlIdentity: 'read-control',
},
authority,
provider,
validation(),
{ stateRoot, actor: 'provisioner' },
);
expect(result.outcome).toBe('indeterminate');
const [journalName] = await readdir(join(stateRoot, 'journals'));
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
expect(journal).toContain('team-member-absent');
expect(journal).toContain('team-repository-absent');
expect(journal).not.toContain('"decision":"team-member-present"');
expect(journal).not.toContain('"decision":"team-repository-present"');
});
it('fails closed and removes newly added membership when team scope changes during mutation', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let repositoryReads = 0;
let membershipReads = 0;
let removed = false;
let detached = false;
const provider: GiteaTeamGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async resolveTeam() {
return {
id: 7,
name: 'writers',
permission: 'write',
endpoint: 'GET /api/v1/orgs/owner/teams',
contentType: 'application/json',
};
},
async listTeamRepositories() {
repositoryReads += 1;
return {
repositories: repositoryReads === 1 ? [] : ['owner/repo', 'owner/concurrent-attachment'],
endpoint: 'GET /api/v1/teams/7/repos',
contentType: 'application/json',
};
},
async addTeamMember(): Promise<void> {},
async removeTeamMember(): Promise<void> {
removed = true;
},
async attachTeamRepository(): Promise<void> {},
async detachTeamRepository(): Promise<void> {
detached = true;
},
async readTeamMember() {
membershipReads += 1;
return {
state: membershipReads === 1 || removed ? 'absent' : 'present',
endpoint: 'GET /api/v1/teams/7/members/seat-name',
contentType: 'application/json',
};
},
async readTeamRepository() {
return {
state: detached ? 'absent' : 'present',
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
contentType: 'application/json',
};
},
async readOrganizationMembership() {
return {
state: 'present',
endpoint: 'GET /api/v1/users/seat-name/orgs',
contentType: 'application/json',
};
},
};
const stateRoot = join(cleanup, 'state');
const result = await grantTeamRepositoryPermission(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write',
team: 'writers',
readOnlyControlIdentity: 'read-control',
},
authority,
provider,
validation(),
{ stateRoot, actor: 'provisioner' },
);
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('team-scope-changed-during-grant');
expect(result.evidence.teamRepositorySet?.repositories).toEqual([
'owner/repo',
'owner/concurrent-attachment',
]);
expect(removed).toBe(true);
expect(detached).toBe(true);
const [journalName] = await readdir(join(stateRoot, 'journals'));
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
expect(journal.indexOf('team-member-absent')).toBeLessThan(
journal.indexOf('team-member-applied'),
);
expect(journal).toContain('team-repository-rollback-applied');
expect(journal).toContain('team-repository-absent');
});
it('compensates provider changes when repository attachment fails after applying', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let memberPresent = false;
let repositoryPresent = false;
let memberRemoved = false;
let repositoryDetached = false;
const provider: GiteaTeamGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async resolveTeam() {
return {
id: 7,
name: 'writers',
permission: 'write',
endpoint: 'GET /api/v1/orgs/owner/teams',
contentType: 'application/json',
};
},
async listTeamRepositories() {
return {
repositories: repositoryPresent ? ['owner/repo'] : [],
endpoint: 'GET /api/v1/teams/7/repos',
contentType: 'application/json',
};
},
async addTeamMember(): Promise<void> {
memberPresent = true;
},
async removeTeamMember(): Promise<void> {
memberPresent = false;
memberRemoved = true;
},
async attachTeamRepository(): Promise<void> {
repositoryPresent = true;
throw new Error('provider response lost after attachment');
},
async detachTeamRepository(): Promise<void> {
repositoryPresent = false;
repositoryDetached = true;
},
async readTeamMember() {
return {
state: memberPresent ? 'present' : 'absent',
endpoint: 'GET /api/v1/teams/7/members/seat-name',
contentType: 'application/json',
};
},
async readTeamRepository() {
return {
state: repositoryPresent ? 'present' : 'absent',
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
contentType: 'application/json',
};
},
async readOrganizationMembership() {
return {
state: 'present',
endpoint: 'GET /api/v1/users/seat-name/orgs',
contentType: 'application/json',
};
},
};
const result = await grantTeamRepositoryPermission(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write',
team: 'writers',
readOnlyControlIdentity: 'read-control',
},
authority,
provider,
validation(),
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
);
expect(result.outcome).toBe('indeterminate');
expect(memberPresent).toBe(false);
expect(repositoryPresent).toBe(false);
expect(memberRemoved).toBe(true);
expect(repositoryDetached).toBe(true);
});
it('refuses a second governed mutation while the same team lock is held', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let releaseFirst!: () => void;
const firstMayFinish = new Promise<void>((resolve): void => {
releaseFirst = resolve;
});
let markFirstEntered!: () => void;
const firstEntered = new Promise<void>((resolve): void => {
markFirstEntered = resolve;
});
let addCalls = 0;
let memberAdded = false;
let repositoryAttached = false;
const provider: GiteaTeamGrantProvider = {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async resolveTeam() {
return {
id: 7,
name: 'writers',
permission: 'write',
endpoint: 'GET /api/v1/orgs/owner/teams',
contentType: 'application/json',
};
},
async listTeamRepositories() {
return {
repositories: repositoryAttached ? ['owner/repo'] : [],
endpoint: 'GET /api/v1/teams/7/repos',
contentType: 'application/json',
};
},
async addTeamMember(): Promise<void> {
addCalls += 1;
markFirstEntered();
await firstMayFinish;
memberAdded = true;
},
async removeTeamMember(): Promise<void> {
memberAdded = false;
},
async attachTeamRepository(): Promise<void> {
repositoryAttached = true;
},
async detachTeamRepository(): Promise<void> {
repositoryAttached = false;
},
async readTeamMember() {
return {
state: memberAdded ? 'present' : 'absent',
endpoint: 'GET /api/v1/teams/7/members/seat-name',
contentType: 'application/json',
};
},
async readTeamRepository() {
return {
state: repositoryAttached ? 'present' : 'absent',
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
contentType: 'application/json',
};
},
async readOrganizationMembership() {
return {
state: 'present',
endpoint: 'GET /api/v1/users/seat-name/orgs',
contentType: 'application/json',
};
},
};
const request = {
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
permission: 'write' as const,
team: 'writers',
readOnlyControlIdentity: 'read-control',
};
const options = { stateRoot: join(cleanup, 'state-one'), actor: 'provisioner' };
const secondOptions = { stateRoot: join(cleanup, 'state-two'), actor: 'provisioner' };
const first = grantTeamRepositoryPermission(
request,
authority,
provider,
validation(),
options,
);
await firstEntered;
const second = await grantTeamRepositoryPermission(
request,
authority,
provider,
validation(),
secondOptions,
);
releaseFirst();
const completedFirst = await first;
expect(completedFirst.outcome).toBe('ok');
expect(second.outcome).toBe('indeterminate');
expect(second.reason.code).toBe('concurrent-mutation');
expect(second.mutation).toBe('none');
expect(addCalls).toBe(1);
});
});
@@ -1,524 +0,0 @@
import { spawnSync } from 'node:child_process';
import { constants, lstatSync } from 'node:fs';
import { open } from 'node:fs/promises';
import { join } from 'node:path';
import { ensureManagedDirectory } from '../fleet/secure-file.js';
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
import type {
CredentialValidationDependencies,
ResolvedCredential,
} from './credential-provider.dto.js';
import type {
CredentialGrantResultDto,
DirectGrantRequestDto,
OrganizationMembershipEvidenceDto,
} from './grant.dto.js';
import type { RepositoryPermission } from './credential-result.dto.js';
import { CredentialGrantExecutionError } from './grant.js';
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
export interface TeamResolutionEvidence {
readonly id: number;
readonly name: string;
readonly permission: RepositoryPermission;
readonly endpoint: string;
readonly contentType: string;
}
export interface PresenceEvidence {
readonly state: 'present' | 'absent';
readonly endpoint: string;
readonly contentType: string;
}
export interface TeamRepositorySetEvidence {
readonly repositories: readonly string[];
readonly endpoint: string;
readonly contentType: string;
}
export interface TeamGrantRequest extends DirectGrantRequestDto {
readonly team: string;
}
export interface TeamGrantResult extends CredentialGrantResultDto {
readonly evidence: CredentialGrantResultDto['evidence'] & {
readonly team: TeamResolutionEvidence | null;
readonly teamMembership: PresenceEvidence | null;
readonly teamRepository: PresenceEvidence | null;
readonly teamRepositorySet: TeamRepositorySetEvidence | null;
};
}
export interface GiteaTeamGrantProvider {
readBasicIdentity(
authority: ResolvedCredential,
): Promise<{ readonly login: string; readonly endpoint: string; readonly contentType: string }>;
resolveTeam(
authority: ResolvedCredential,
organization: string,
team: string,
): Promise<TeamResolutionEvidence>;
listTeamRepositories(
authority: ResolvedCredential,
teamId: number,
): Promise<TeamRepositorySetEvidence>;
addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
removeTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
detachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
readTeamMember(
authority: ResolvedCredential,
teamId: number,
identity: string,
): Promise<PresenceEvidence>;
readTeamRepository(
authority: ResolvedCredential,
teamId: number,
repo: string,
): Promise<PresenceEvidence>;
readOrganizationMembership(
subject: ResolvedCredential,
organization: string,
): Promise<OrganizationMembershipEvidenceDto>;
}
export interface TeamGrantOptions {
readonly stateRoot: string;
readonly actor: string;
}
class TeamGrantLockError extends Error {
constructor(public readonly code: 'concurrent-mutation' | 'mutation-lock-unavailable') {
super(code);
}
}
async function acquireTeamGrantLock(
estate: string,
host: string,
teamId: number,
): Promise<() => Promise<void>> {
const uid = process.getuid?.();
if (uid === undefined) throw new TeamGrantLockError('mutation-lock-unavailable');
const locksDirectory = `/tmp/mosaic-cred-team-locks-${uid.toString()}`;
ensureManagedDirectory(locksDirectory, locksDirectory);
const directory = lstatSync(locksDirectory);
if (
!directory.isDirectory() ||
directory.isSymbolicLink() ||
directory.uid !== uid ||
(directory.mode & 0o077) !== 0
) {
throw new TeamGrantLockError('mutation-lock-unavailable');
}
const lockPath = join(locksDirectory, `${estate}--${host}--team-${teamId.toString()}.lock`);
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(
lockPath,
constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,
0o600,
);
} catch {
throw new TeamGrantLockError('mutation-lock-unavailable');
}
try {
const file = await handle.stat();
if (!file.isFile() || file.uid !== uid || (file.mode & 0o077) !== 0) {
throw new Error('team mutation lock file is unsafe');
}
} catch {
await handle.close().catch((): void => undefined);
throw new TeamGrantLockError('mutation-lock-unavailable');
}
// The child's fd 3 is a dup of the parent's open file description. Linux
// flock(2) associates the lock with that description, so it remains held
// after the helper exits until this process closes `handle` below.
const acquired = spawnSync('/usr/bin/flock', ['-n', '3'], {
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
});
if (acquired.error !== undefined || acquired.status !== 0) {
await handle.close().catch((): void => undefined);
throw new TeamGrantLockError(
acquired.status === 1 ? 'concurrent-mutation' : 'mutation-lock-unavailable',
);
}
return async (): Promise<void> => {
await handle.close();
};
}
export async function grantTeamRepositoryPermission(
request: TeamGrantRequest,
authority: ResolvedCredential,
provider: GiteaTeamGrantProvider,
dependencies: CredentialValidationDependencies,
options: TeamGrantOptions,
): Promise<TeamGrantResult> {
const journal = await CredentialAuditJournal.open(options.stateRoot, {
operation: 'grant',
actor: options.actor,
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
});
await journal.recordIntent('provider-grant');
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let releaseTeamLock: (() => Promise<void>) | undefined;
let rollbackTeam: TeamResolutionEvidence | undefined;
let membershipBeforeMutation: PresenceEvidence | undefined;
let repositoryAttachedBeforeMutation = false;
let memberMutationAttempted = false;
let repositoryMutationAttempted = false;
try {
const authorityIdentity = await provider.readBasicIdentity(authority);
const organization = request.repo.split('/')[0] ?? '';
const team = await provider.resolveTeam(authority, organization, request.team);
rollbackTeam = team;
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {
await journal.seal('refused', 'provider-identity-mismatch');
return result(
request,
journal,
'refused',
'none',
'provider-identity-mismatch',
null,
null,
null,
null,
null,
null,
);
}
await journal.recordProviderEvidence({
endpoint: authorityIdentity.endpoint,
contentType: authorityIdentity.contentType,
decision: 'identity-verified',
});
await journal.recordProviderEvidence({
endpoint: team.endpoint,
contentType: team.contentType,
decision: `permission-${team.permission}`,
});
try {
releaseTeamLock = await acquireTeamGrantLock(request.estate, request.host, team.id);
} catch (error: unknown) {
if (!(error instanceof TeamGrantLockError)) throw error;
await journal.seal('indeterminate', error.code);
return result(
request,
journal,
'indeterminate',
'none',
error.code,
null,
team,
null,
null,
null,
null,
);
}
const teamRepositorySet = await provider.listTeamRepositories(authority, team.id);
await journal.recordProviderEvidence({
endpoint: teamRepositorySet.endpoint,
contentType: teamRepositorySet.contentType,
decision: 'team-repository-set-verified',
});
if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) {
await journal.seal('refused', 'team-scope-exceeds-request');
return result(
request,
journal,
'refused',
'none',
'team-scope-exceeds-request',
null,
team,
null,
null,
null,
teamRepositorySet,
);
}
const repositoryAttachedBefore = teamRepositorySet.repositories.includes(request.repo);
repositoryAttachedBeforeMutation = repositoryAttachedBefore;
const membershipBefore = await provider.readTeamMember(authority, team.id, request.identity);
membershipBeforeMutation = membershipBefore;
await journal.recordProviderEvidence({
endpoint: membershipBefore.endpoint,
contentType: membershipBefore.contentType,
decision: membershipBefore.state === 'present' ? 'team-member-present' : 'team-member-absent',
});
mutation = 'unknown';
memberMutationAttempted = true;
await provider.addTeamMember(authority, team.id, request.identity);
mutation = 'applied';
await journal.recordMutation('team-member-applied');
repositoryMutationAttempted = true;
await provider.attachTeamRepository(authority, team.id, request.repo);
await journal.recordMutation('team-repository-applied');
let teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
let teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
const finalTeamRepositorySet = await provider.listTeamRepositories(authority, team.id);
await journal.recordProviderEvidence({
endpoint: teamMembership.endpoint,
contentType: teamMembership.contentType,
decision: teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent',
});
await journal.recordProviderEvidence({
endpoint: teamRepository.endpoint,
contentType: teamRepository.contentType,
decision:
teamRepository.state === 'present' ? 'team-repository-present' : 'team-repository-absent',
});
await journal.recordProviderEvidence({
endpoint: finalTeamRepositorySet.endpoint,
contentType: finalTeamRepositorySet.contentType,
decision: 'team-repository-set-verified',
});
const scopeRemainedExact =
finalTeamRepositorySet.repositories.length === 1 &&
finalTeamRepositorySet.repositories[0] === request.repo;
if (!scopeRemainedExact) {
if (membershipBefore.state === 'absent') {
await provider.removeTeamMember(authority, team.id, request.identity);
await journal.recordMutation('team-member-rollback-applied');
teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
await journal.recordProviderEvidence({
endpoint: teamMembership.endpoint,
contentType: teamMembership.contentType,
decision:
teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent',
});
if (teamMembership.state !== 'absent') throw new Error('team member rollback disagreed');
}
if (!repositoryAttachedBefore) {
await provider.detachTeamRepository(authority, team.id, request.repo);
await journal.recordMutation('team-repository-rollback-applied');
teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
await journal.recordProviderEvidence({
endpoint: teamRepository.endpoint,
contentType: teamRepository.contentType,
decision:
teamRepository.state === 'present'
? 'team-repository-present'
: 'team-repository-absent',
});
if (teamRepository.state !== 'absent') {
throw new Error('team repository rollback disagreed');
}
}
await journal.seal('indeterminate', 'team-scope-changed-during-grant');
return result(
request,
journal,
'indeterminate',
'applied',
'team-scope-changed-during-grant',
null,
team,
teamMembership,
teamRepository,
null,
finalTeamRepositorySet,
);
}
const subject = await dependencies.resolver.resolve(
request.identity,
request.estate,
request.host,
);
const organizationMembership =
subject === undefined
? null
: await provider.readOrganizationMembership(subject, organization);
const validation =
request.permission === 'read'
? await evaluateGiteaReadValidation(request, dependencies)
: await evaluateGiteaWriteValidation(request, dependencies);
if (organizationMembership !== null) {
await journal.recordProviderEvidence({
endpoint: organizationMembership.endpoint,
contentType: organizationMembership.contentType,
decision:
organizationMembership.state === 'present'
? 'organization-member-present'
: 'organization-member-absent',
});
}
if (validation.evidence.providerIdentity !== null) {
await journal.recordProviderEvidence({
endpoint: validation.evidence.providerIdentity.endpoint,
contentType: validation.evidence.providerIdentity.contentType,
decision: 'identity-verified',
});
}
if (validation.evidence.repositoryPermission !== null) {
await journal.recordProviderEvidence({
endpoint: validation.evidence.repositoryPermission.endpoint,
contentType: validation.evidence.repositoryPermission.contentType,
decision: `permission-${validation.evidence.repositoryPermission.effective}`,
});
}
if (validation.evidence.writeDifferential !== null) {
await journal.recordMutation('transport-write-verified');
}
const ok =
teamMembership.state === 'present' &&
teamRepository.state === 'present' &&
organizationMembership?.state === 'present' &&
validation.outcome === 'ok' &&
validation.evidence.repositoryPermission?.effective === request.permission;
await journal.seal(
ok ? 'ok' : 'indeterminate',
ok ? 'grant-verified' : 'permission-evidence-disagrees',
);
return result(
request,
journal,
ok ? 'ok' : 'indeterminate',
'applied',
ok ? 'grant-verified' : 'permission-evidence-disagrees',
validation,
team,
teamMembership,
teamRepository,
organizationMembership,
finalTeamRepositorySet,
);
} catch (error: unknown) {
let compensationError: unknown;
try {
if (
rollbackTeam !== undefined &&
membershipBeforeMutation?.state === 'absent' &&
memberMutationAttempted
) {
let current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity);
if (current.state === 'present') {
await provider.removeTeamMember(authority, rollbackTeam.id, request.identity);
await journal.recordMutation('team-member-rollback-applied');
current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity);
await journal.recordProviderEvidence({
endpoint: current.endpoint,
contentType: current.contentType,
decision: current.state === 'present' ? 'team-member-present' : 'team-member-absent',
});
if (current.state !== 'absent') throw new Error('team member rollback disagreed');
}
}
if (
rollbackTeam !== undefined &&
!repositoryAttachedBeforeMutation &&
repositoryMutationAttempted
) {
let current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo);
if (current.state === 'present') {
await provider.detachTeamRepository(authority, rollbackTeam.id, request.repo);
await journal.recordMutation('team-repository-rollback-applied');
current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo);
await journal.recordProviderEvidence({
endpoint: current.endpoint,
contentType: current.contentType,
decision:
current.state === 'present' ? 'team-repository-present' : 'team-repository-absent',
});
if (current.state !== 'absent') throw new Error('team repository rollback disagreed');
}
}
} catch (rollbackError: unknown) {
compensationError = rollbackError;
}
const auditError =
compensationError instanceof CredentialJournalError
? compensationError
: error instanceof CredentialJournalError
? error
: undefined;
if (auditError !== undefined) {
throw new CredentialGrantExecutionError(auditError.code, mutation, journal.journalId());
}
const reasonCode =
compensationError !== undefined
? 'rollback-incomplete'
: mutation === 'applied'
? 'readback-missing'
: 'mutation-state-unknown';
try {
await journal.seal('indeterminate', reasonCode);
} catch (journalError: unknown) {
if (journalError instanceof CredentialJournalError) {
throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId());
}
throw journalError;
}
return result(
request,
journal,
'indeterminate',
mutation,
reasonCode,
null,
null,
null,
null,
null,
null,
);
} finally {
// The kernel also releases this advisory lock on process exit. A close
// cleanup fault must not contradict an already sealed provider verdict.
await releaseTeamLock?.().catch((): void => undefined);
}
}
function result(
request: TeamGrantRequest,
journal: CredentialAuditJournal,
outcome: 'ok' | 'refused' | 'indeterminate',
mutation: 'none' | 'unknown' | 'applied',
code: string,
validation: Awaited<ReturnType<typeof evaluateGiteaWriteValidation>> | null,
team: TeamResolutionEvidence | null,
teamMembership: PresenceEvidence | null,
teamRepository: PresenceEvidence | null,
organizationMembership: OrganizationMembershipEvidenceDto | null,
teamRepositorySet: TeamRepositorySetEvidence | null,
): TeamGrantResult {
return {
schemaVersion: 1,
operation: 'grant',
outcome,
exitCode: outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30,
retryable: false,
subject: {
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
},
mutation,
reason: {
code,
message:
outcome === 'ok'
? 'Team grant matched every provider read-back.'
: 'Team grant was refused or could not be established.',
},
evidence: {
providerIdentity: validation?.evidence.providerIdentity ?? null,
tokenCapabilities: validation?.evidence.tokenCapabilities ?? {
state: 'not-measured',
scopes: [],
source: 'runtime-not-authorized',
},
repositoryPermission: validation?.evidence.repositoryPermission ?? null,
writeDifferential: validation?.evidence.writeDifferential ?? null,
collaboratorPermission: null,
organizationMembership,
team,
teamMembership,
teamRepository,
teamRepositorySet,
},
audit: { journalId: journal.journalId(), state: 'sealed' },
};
}
@@ -1,413 +0,0 @@
import { describe, expect, it } from 'vitest';
import { CredentialProviderEvidenceError } from './gitea-provider.js';
import {
evaluateGiteaReadValidation,
evaluateGiteaWriteValidation,
type CredentialResolver,
type CredentialValidationDependencies,
type GiteaCredentialProvider,
type ProviderIdentityEvidence,
type ReceivePackEvidence,
type RepositoryPermissionEvidence,
type ResolvedCredential,
} from './validate.js';
interface FixtureOptions {
readonly subjectProviderIdentity?: string;
readonly subjectPermission?: 'none' | 'read' | 'write' | 'admin';
readonly subjectTransportState?: 'advertised' | 'refused';
readonly subjectTransportPrincipal?: string;
readonly subjectTransportResolutionId?: string;
readonly controlProviderIdentity?: string;
readonly controlPermission?: 'none' | 'read' | 'write' | 'admin';
readonly controlTransportState?: 'advertised' | 'refused';
readonly controlTransportPrincipal?: string;
readonly unauthenticatedTransportState?: 'advertised' | 'refused';
readonly omitControl?: boolean;
readonly requiredPermission?: 'read' | 'write' | 'admin';
}
interface Fixture {
readonly dependencies: CredentialValidationDependencies;
readonly resolverCalls: string[];
readonly identityHandles: ResolvedCredential[];
readonly permissionHandles: ResolvedCredential[];
readonly receivePackHandles: Array<ResolvedCredential | undefined>;
}
const SUBJECT = 'seat-name';
const CONTROL = 'read-only-control';
const ESTATE = 'homelab';
const HOST = 'git.example.invalid';
const REPO = 'owner/repo';
function credential(identity: string, resolutionId: string): ResolvedCredential {
return Object.freeze({
identity,
estate: ESTATE,
host: HOST,
resolutionId,
secret: new Uint8Array([99, 97, 110, 97, 114, 121]),
});
}
function fixture(options: FixtureOptions = {}): Fixture {
const subjectCredential = credential(SUBJECT, 'subject-resolution');
const controlCredential = credential(CONTROL, 'control-resolution');
const resolverCalls: string[] = [];
const identityHandles: ResolvedCredential[] = [];
const permissionHandles: ResolvedCredential[] = [];
const receivePackHandles: Array<ResolvedCredential | undefined> = [];
const resolver: CredentialResolver = {
async resolve(identity: string): Promise<ResolvedCredential | undefined> {
resolverCalls.push(identity);
if (identity === SUBJECT) return subjectCredential;
if (identity === CONTROL && options.omitControl !== true) return controlCredential;
return undefined;
},
};
const provider: GiteaCredentialProvider = {
async readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidence> {
identityHandles.push(resolved);
const login =
resolved.identity === SUBJECT
? (options.subjectProviderIdentity ?? SUBJECT)
: (options.controlProviderIdentity ?? CONTROL);
return {
login,
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async readRepositoryPermission(
resolved: ResolvedCredential,
): Promise<RepositoryPermissionEvidence> {
permissionHandles.push(resolved);
const effective =
resolved.identity === SUBJECT
? (options.subjectPermission ?? 'write')
: (options.controlPermission ?? 'read');
return {
effective,
endpoint: `GET /api/v1/repos/${REPO}`,
contentType: 'application/json',
};
},
async probeReceivePack(resolved: ResolvedCredential | undefined): Promise<ReceivePackEvidence> {
receivePackHandles.push(resolved);
if (resolved === undefined) {
return {
state: options.unauthenticatedTransportState ?? 'refused',
principal: null,
resolutionId: null,
contentType: 'text/plain',
};
}
if (resolved.identity === SUBJECT) {
return {
state: options.subjectTransportState ?? 'advertised',
principal: options.subjectTransportPrincipal ?? SUBJECT,
resolutionId: options.subjectTransportResolutionId ?? resolved.resolutionId,
contentType: 'application/x-git-receive-pack-advertisement',
};
}
return {
state: options.controlTransportState ?? 'refused',
principal: options.controlTransportPrincipal ?? CONTROL,
resolutionId: resolved.resolutionId,
contentType: 'text/plain',
};
},
};
return {
dependencies: {
resolver,
provider,
estateRegistry: {
matches(estate: string, host: string): boolean {
return estate === ESTATE && host === HOST;
},
},
},
resolverCalls,
identityHandles,
permissionHandles,
receivePackHandles,
};
}
async function validate(options: FixtureOptions = {}): Promise<{
readonly result: Awaited<ReturnType<typeof evaluateGiteaWriteValidation>>;
readonly observed: Fixture;
}> {
const observed = fixture(options);
const result = await evaluateGiteaWriteValidation(
{
identity: SUBJECT,
estate: ESTATE,
host: HOST,
repo: REPO,
readOnlyControlIdentity: CONTROL,
requiredPermission: options.requiredPermission,
},
observed.dependencies,
);
return { result, observed };
}
describe('Gitea read validation', (): void => {
it('reads the explicit provider identity and repository permission without a write control', async (): Promise<void> => {
const observed = fixture({ subjectPermission: 'read' });
const result = await evaluateGiteaReadValidation(
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
observed.dependencies,
);
expect(result.outcome).toBe('ok');
expect(result.evidence.providerIdentity?.login).toBe(SUBJECT);
expect(result.evidence.repositoryPermission?.effective).toBe('read');
expect(result.evidence.writeDifferential).toBeNull();
expect(observed.resolverCalls).toEqual([SUBJECT]);
});
it('refuses a repository object whose permission flags establish no read access', async (): Promise<void> => {
const observed = fixture({ subjectPermission: 'none' });
const result = await evaluateGiteaReadValidation(
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
observed.dependencies,
);
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('permission-denied');
});
it('classifies the provider rejecting the subject credential as an authoritative refusal', async (): Promise<void> => {
const observed = fixture({ subjectPermission: 'read' });
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
throw new CredentialProviderEvidenceError(
'credential-rejected',
'provider rejected the supplied credential',
);
};
const result = await evaluateGiteaReadValidation(
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
observed.dependencies,
);
expect(result.outcome).toBe('refused');
expect(result.exitCode).toBe(10);
expect(result.reason.code).toBe('credential-rejected');
});
it('confirms in-scope capability while reporting identity as not measured', async (): Promise<void> => {
const observed = fixture({ subjectPermission: 'write' });
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
throw new CredentialProviderEvidenceError(
'identity-read-forbidden',
'identity endpoint requires a scope this token does not hold',
);
};
const result = await evaluateGiteaReadValidation(
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
observed.dependencies,
);
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('identity-not-measured');
expect(result.evidence.providerIdentity).toBeNull();
expect(result.evidence.repositoryPermission?.effective).toBe('write');
});
it('refuses a shared fallback rather than reporting a different principal as the subject', async (): Promise<void> => {
const observed = fixture({
subjectProviderIdentity: 'shared-owner',
subjectPermission: 'read',
});
const result = await evaluateGiteaReadValidation(
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
observed.dependencies,
);
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('provider-identity-mismatch');
});
});
describe('principal-bound Gitea write validation contract v1.1', (): void => {
it('confirms write capability when identity is scope-forbidden without exposing the internal reason', async (): Promise<void> => {
const observed = fixture();
const readIdentity = observed.dependencies.provider.readIdentity.bind(
observed.dependencies.provider,
);
observed.dependencies.provider.readIdentity = async (
resolved,
): Promise<ProviderIdentityEvidence> => {
if (resolved.identity === SUBJECT) {
throw new CredentialProviderEvidenceError(
'identity-read-forbidden',
'identity endpoint scope forbidden',
);
}
return readIdentity(resolved);
};
const result = await evaluateGiteaWriteValidation(
{
identity: SUBJECT,
estate: ESTATE,
host: HOST,
repo: REPO,
readOnlyControlIdentity: CONTROL,
},
observed.dependencies,
);
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('identity-not-measured');
expect(result.evidence.repositoryPermission?.effective).toBe('write');
expect(observed.receivePackHandles).toEqual([
expect.objectContaining({ identity: SUBJECT }),
expect.objectContaining({ identity: CONTROL }),
undefined,
]);
});
it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise<void> => {
const { result, observed } = await validate();
expect(result.outcome).toBe('ok');
expect(observed.resolverCalls).toEqual([SUBJECT, CONTROL]);
expect(observed.identityHandles[0]).toBe(observed.permissionHandles[0]);
expect(observed.identityHandles[0]).toBe(observed.receivePackHandles[0]);
});
it('refuses a subject credential whose provider identity is a shared fallback', async (): Promise<void> => {
const { result } = await validate({ subjectProviderIdentity: 'shared-owner' });
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('provider-identity-mismatch');
expect(result.mutation).toBe('none');
});
it('routes a transport principal mismatch to indeterminate, not refused', async (): Promise<void> => {
const { result } = await validate({ subjectTransportPrincipal: 'shared-owner' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('transport-principal-mismatch');
});
it('routes a transport credential-handle mismatch to indeterminate', async (): Promise<void> => {
const { result } = await validate({ subjectTransportResolutionId: 'fallback-resolution' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('transport-principal-mismatch');
});
it('refuses when the provider repository object authoritatively denies write', async (): Promise<void> => {
const { result } = await validate({ subjectPermission: 'read' });
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('permission-denied');
});
it('is indeterminate when repo permission says write but receive-pack refuses', async (): Promise<void> => {
const { result } = await validate({ subjectTransportState: 'refused' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('permission-evidence-disagrees');
});
it('refuses write permission when admin permission is explicitly required', async (): Promise<void> => {
const { result } = await validate({
requiredPermission: 'admin',
subjectPermission: 'write',
});
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('permission-denied');
});
it('accepts admin permission when admin is explicitly required', async (): Promise<void> => {
const { result } = await validate({
requiredPermission: 'admin',
subjectPermission: 'admin',
});
expect(result.outcome).toBe('ok');
});
it('makes a write-capable read-only control invalidate the entire result', async (): Promise<void> => {
const { result } = await validate({ controlPermission: 'write' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('read-only-control-invalid');
});
it('makes an identity-mismatched read-only control invalidate the entire result', async (): Promise<void> => {
const { result } = await validate({ controlProviderIdentity: 'other-control' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('read-only-control-invalid');
});
it('makes a read-only control that receives write transport invalidate the result', async (): Promise<void> => {
const { result } = await validate({ controlTransportState: 'advertised' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('read-only-control-invalid');
});
it('is indeterminate when the configured read-only control credential is absent', async (): Promise<void> => {
const { result } = await validate({ omitControl: true });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('read-only-control-invalid');
});
it('keeps the unauthenticated arm and rejects an advertisement there', async (): Promise<void> => {
const { result } = await validate({ unauthenticatedTransportState: 'advertised' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('permission-evidence-disagrees');
});
it('refuses an estate-host mismatch before resolving any credential', async (): Promise<void> => {
const observed = fixture();
const result = await evaluateGiteaWriteValidation(
{
identity: SUBJECT,
estate: 'usc',
host: HOST,
repo: REPO,
readOnlyControlIdentity: CONTROL,
},
observed.dependencies,
);
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('estate-host-mismatch');
expect(observed.resolverCalls).toEqual([]);
});
it('returns structured proof bounds only after every principal-bound arm passes', async (): Promise<void> => {
const { result } = await validate();
expect(result.outcome).toBe('ok');
expect(result.evidence.writeDifferential).toMatchObject({
state: 'can-write',
credentialBinding: 'same-resolution',
transportPrincipal: SUBJECT,
authenticatedReceivePack: 'advertised',
readOnlyControl: {
identity: CONTROL,
providerPermission: 'read',
receivePack: 'refused',
},
unauthenticatedReceivePack: 'refused',
artifactCreated: false,
});
expect(result.evidence.writeDifferential?.proves).toContain('declared subject credential');
expect(result.evidence.writeDifferential?.doesNotProve).toContain('branch protection');
});
});
-521
View File
@@ -1,521 +0,0 @@
import { CredentialProviderEvidenceError } from './gitea-provider.js';
import type {
CredentialValidationDependencies,
GiteaReadValidationRequestDto,
GiteaWriteValidationRequestDto,
ResolvedCredential,
} from './credential-provider.dto.js';
import type {
CredentialOutcome,
CredentialReasonDto,
CredentialValidationEvidenceDto,
CredentialValidationResultDto,
ProviderIdentityEvidenceDto,
ReceivePackEvidenceDto,
RepositoryPermissionEvidenceDto,
WriteDifferentialEvidenceDto,
} from './credential-result.dto.js';
export type {
CredentialResolver,
CredentialValidationDependencies,
GiteaCredentialProvider,
GiteaReadValidationRequestDto,
GiteaWriteValidationRequestDto,
ResolvedCredential,
} from './credential-provider.dto.js';
export type {
ProviderIdentityEvidenceDto as ProviderIdentityEvidence,
ReceivePackEvidenceDto as ReceivePackEvidence,
RepositoryPermissionEvidenceDto as RepositoryPermissionEvidence,
} from './credential-result.dto.js';
const JSON_CONTENT_TYPE = 'application/json';
const RUNTIME_SCOPE_NOT_MEASURED = {
state: 'not-measured' as const,
scopes: [] as readonly string[],
source: 'runtime-not-authorized' as const,
};
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
interface ResultOptions {
readonly outcome: CredentialOutcome;
readonly code: string;
readonly message: string;
readonly retryable?: boolean;
readonly evidence?: CredentialValidationEvidenceDto;
}
function subject(request: GiteaReadValidationRequestDto): CredentialValidationResultDto['subject'] {
return {
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
};
}
function result(
request: GiteaReadValidationRequestDto,
options: ResultOptions,
): CredentialValidationResultDto {
const exits: Readonly<Record<CredentialOutcome, 0 | 10 | 20 | 30>> = {
ok: 0,
refused: 10,
error: 20,
indeterminate: 30,
};
return {
schemaVersion: 1,
operation: 'validate',
outcome: options.outcome,
exitCode: exits[options.outcome],
retryable: options.retryable ?? false,
subject: subject(request),
mutation: 'none',
reason: { code: options.code, message: options.message },
evidence: options.evidence ?? {
providerIdentity: null,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission: null,
writeDifferential: null,
},
audit: { journalId: null, state: 'not-started' },
};
}
function refused(
request: GiteaReadValidationRequestDto,
reason: CredentialReasonDto,
evidence?: CredentialValidationEvidenceDto,
): CredentialValidationResultDto {
return result(request, {
outcome: 'refused',
code: reason.code,
message: reason.message,
...(evidence === undefined ? {} : { evidence }),
});
}
function indeterminate(
request: GiteaReadValidationRequestDto,
reason: CredentialReasonDto,
evidence?: CredentialValidationEvidenceDto,
): CredentialValidationResultDto {
return result(request, {
outcome: 'indeterminate',
code: reason.code,
message: reason.message,
...(evidence === undefined ? {} : { evidence }),
});
}
function providerEvidenceFailure(
request: GiteaReadValidationRequestDto,
error: CredentialProviderEvidenceError,
): CredentialValidationResultDto {
if (error.code === 'credential-rejected') {
return refused(request, {
code: error.code,
message: 'The provider authoritatively rejected the supplied subject credential.',
});
}
return indeterminate(request, {
code: error.code,
message: 'Provider evidence could not be evaluated completely.',
});
}
function identityContentTypeValid(evidence: ProviderIdentityEvidenceDto): boolean {
return evidence.contentType.toLowerCase().startsWith(JSON_CONTENT_TYPE);
}
function permissionContentTypeValid(evidence: RepositoryPermissionEvidenceDto): boolean {
return evidence.contentType.toLowerCase().startsWith(JSON_CONTENT_TYPE);
}
function advertised(evidence: ReceivePackEvidenceDto): boolean {
return (
evidence.state === 'advertised' &&
evidence.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)
);
}
async function resolveCredential(
request: GiteaWriteValidationRequestDto,
identity: string,
dependencies: CredentialValidationDependencies,
): Promise<ResolvedCredential | undefined> {
return dependencies.resolver.resolve(identity, request.estate, request.host);
}
function successfulEvidence(
subjectLogin: string,
subjectIdentity: ProviderIdentityEvidenceDto | null,
subjectPermission: RepositoryPermissionEvidenceDto,
subjectReceivePack: ReceivePackEvidenceDto,
controlIdentity: ProviderIdentityEvidenceDto,
controlPermission: RepositoryPermissionEvidenceDto,
controlReceivePack: ReceivePackEvidenceDto,
): CredentialValidationEvidenceDto {
const writeDifferential: WriteDifferentialEvidenceDto = {
state: 'can-write',
credentialBinding: 'same-resolution',
transportPrincipal: subjectLogin,
authenticatedReceivePack: 'advertised',
readOnlyControl: {
identity: controlIdentity.login,
providerPermission: controlPermission.effective,
receivePack: controlReceivePack.state,
},
unauthenticatedReceivePack: 'refused',
artifactCreated: false,
proves:
'The declared subject credential authenticated provider identity, repository permission, and write transport while a distinct provider-confirmed read-only principal and an unauthenticated caller were refused.',
doesNotProve:
'A particular ref update will pass branch protection, hooks, races, or content policy.',
};
return {
providerIdentity: subjectIdentity,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission: subjectPermission,
writeDifferential,
};
}
async function evaluateGiteaReadValidationUnsafe(
request: GiteaReadValidationRequestDto,
dependencies: CredentialValidationDependencies,
): Promise<CredentialValidationResultDto> {
if (!dependencies.estateRegistry.matches(request.estate, request.host)) {
return refused(request, {
code: 'estate-host-mismatch',
message: 'The declared estate does not contain the declared host.',
});
}
const resolved = await dependencies.resolver.resolve(
request.identity,
request.estate,
request.host,
);
if (resolved === undefined) {
return refused(request, {
code: 'no-token-for-identity',
message: 'The explicit identity has no credential in the declared estate.',
});
}
let providerIdentity: ProviderIdentityEvidenceDto | null;
try {
providerIdentity = await dependencies.provider.readIdentity(resolved);
} catch (error: unknown) {
if (
error instanceof CredentialProviderEvidenceError &&
error.code === 'identity-read-forbidden'
) {
const repositoryPermission = await dependencies.provider.readRepositoryPermission(
resolved,
request.repo,
);
const evidence: CredentialValidationEvidenceDto = {
providerIdentity: null,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission,
writeDifferential: null,
};
if (!permissionContentTypeValid(repositoryPermission)) {
return indeterminate(
request,
{
code: 'unexpected-content-type',
message: 'In-scope capability evidence was not JSON.',
},
evidence,
);
}
if (repositoryPermission.effective === 'none') {
return refused(
request,
{
code: 'permission-denied',
message: 'The in-scope provider object denies repository access.',
},
evidence,
);
}
return indeterminate(
request,
{
code: 'identity-not-measured',
message:
'Repository capability was confirmed, but identity was not measured because this least-privilege token cannot read /user.',
},
evidence,
);
}
throw error;
}
const repositoryPermission = await dependencies.provider.readRepositoryPermission(
resolved,
request.repo,
);
const evidence: CredentialValidationEvidenceDto = {
providerIdentity,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission,
writeDifferential: null,
};
if (
!identityContentTypeValid(providerIdentity) ||
!permissionContentTypeValid(repositoryPermission)
) {
return indeterminate(
request,
{
code: 'unexpected-content-type',
message: 'Provider read evidence was not JSON.',
},
evidence,
);
}
if (repositoryPermission.effective === 'none') {
return refused(
request,
{
code: 'permission-denied',
message: 'The provider repository object denies read permission.',
},
evidence,
);
}
if (providerIdentity.login !== request.identity) {
return refused(
request,
{
code: 'provider-identity-mismatch',
message: 'The provider credential identity does not equal the declared subject.',
},
evidence,
);
}
return result(request, {
outcome: 'ok',
code: 'validation-verified',
message: 'Provider identity and repository permission were read back.',
evidence,
});
}
export async function evaluateGiteaReadValidation(
request: GiteaReadValidationRequestDto,
dependencies: CredentialValidationDependencies,
): Promise<CredentialValidationResultDto> {
try {
return await evaluateGiteaReadValidationUnsafe(request, dependencies);
} catch (error: unknown) {
if (error instanceof CredentialProviderEvidenceError) {
return providerEvidenceFailure(request, error);
}
throw error;
}
}
async function evaluateGiteaWriteValidationUnsafe(
request: GiteaWriteValidationRequestDto,
dependencies: CredentialValidationDependencies,
): Promise<CredentialValidationResultDto> {
if (!dependencies.estateRegistry.matches(request.estate, request.host)) {
return refused(request, {
code: 'estate-host-mismatch',
message: 'The declared estate does not contain the declared host.',
});
}
const resolved = await resolveCredential(request, request.identity, dependencies);
if (resolved === undefined) {
return refused(request, {
code: 'no-token-for-identity',
message: 'The explicit identity has no credential in the declared estate.',
});
}
let subjectIdentity: ProviderIdentityEvidenceDto | null = null;
try {
subjectIdentity = await dependencies.provider.readIdentity(resolved);
} catch (error: unknown) {
if (
!(error instanceof CredentialProviderEvidenceError) ||
error.code !== 'identity-read-forbidden'
) {
throw error;
}
}
const subjectPermission = await dependencies.provider.readRepositoryPermission(
resolved,
request.repo,
);
const subjectReceivePack = await dependencies.provider.probeReceivePack(resolved, request.repo);
const baseEvidence: CredentialValidationEvidenceDto = {
providerIdentity: subjectIdentity,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission: subjectPermission,
writeDifferential: null,
};
if (subjectIdentity !== null && !identityContentTypeValid(subjectIdentity)) {
return indeterminate(
request,
{
code: 'unexpected-content-type',
message: 'The provider identity response was not JSON.',
},
baseEvidence,
);
}
if (subjectIdentity !== null && subjectIdentity.login !== request.identity) {
return refused(
request,
{
code: 'provider-identity-mismatch',
message: 'The provider credential identity does not equal the declared subject.',
},
baseEvidence,
);
}
if (!permissionContentTypeValid(subjectPermission)) {
return indeterminate(
request,
{
code: 'unexpected-content-type',
message: 'The provider repository response was not JSON.',
},
baseEvidence,
);
}
if (request.requiredPermission === 'admin' && subjectPermission.effective !== 'admin') {
return refused(
request,
{
code: 'permission-denied',
message: 'The provider repository object denies required admin permission.',
},
baseEvidence,
);
}
if (subjectPermission.effective === 'read' || subjectPermission.effective === 'none') {
return refused(
request,
{
code: 'permission-denied',
message: 'The provider repository object denies write permission.',
},
baseEvidence,
);
}
if (
subjectReceivePack.principal !== request.identity ||
subjectReceivePack.resolutionId !== resolved.resolutionId
) {
return indeterminate(
request,
{
code: 'transport-principal-mismatch',
message: 'The write transport evidence is not bound to the declared subject credential.',
},
baseEvidence,
);
}
if (!advertised(subjectReceivePack)) {
return indeterminate(
request,
{
code: 'permission-evidence-disagrees',
message: 'Repository permission and write transport evidence disagree.',
},
baseEvidence,
);
}
const control = await resolveCredential(request, request.readOnlyControlIdentity, dependencies);
if (control === undefined) {
return indeterminate(request, {
code: 'read-only-control-invalid',
message: 'The configured read-only control credential could not be resolved.',
});
}
const controlIdentity = await dependencies.provider.readIdentity(control);
const controlPermission = await dependencies.provider.readRepositoryPermission(
control,
request.repo,
);
const controlReceivePack = await dependencies.provider.probeReceivePack(control, request.repo);
const controlIsDistinct =
request.readOnlyControlIdentity !== request.identity &&
control.resolutionId !== resolved.resolutionId;
const controlIdentityMatches =
identityContentTypeValid(controlIdentity) &&
controlIdentity.login === request.readOnlyControlIdentity;
const controlPermissionIsReadOnly =
permissionContentTypeValid(controlPermission) && controlPermission.effective === 'read';
const controlTransportIsBoundAndRefused =
controlReceivePack.state === 'refused' &&
controlReceivePack.principal === request.readOnlyControlIdentity &&
controlReceivePack.resolutionId === control.resolutionId &&
!controlReceivePack.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE);
if (
!controlIsDistinct ||
!controlIdentityMatches ||
!controlPermissionIsReadOnly ||
!controlTransportIsBoundAndRefused
) {
return indeterminate(request, {
code: 'read-only-control-invalid',
message:
'The read-only control was absent, identity-mismatched, write-capable, unbound, or admitted to write transport.',
});
}
const unauthenticated = await dependencies.provider.probeReceivePack(undefined, request.repo);
if (
unauthenticated.state !== 'refused' ||
unauthenticated.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)
) {
return indeterminate(request, {
code: 'permission-evidence-disagrees',
message: 'The unauthenticated write-transport control was not refused.',
});
}
const evidence = successfulEvidence(
request.identity,
subjectIdentity,
subjectPermission,
subjectReceivePack,
controlIdentity,
controlPermission,
controlReceivePack,
);
return result(request, {
outcome: subjectIdentity === null ? 'indeterminate' : 'ok',
code: subjectIdentity === null ? 'identity-not-measured' : 'validation-verified',
message:
subjectIdentity === null
? 'Write capability and both controls were confirmed, but identity was not measured because this least-privilege token cannot read /user.'
: 'Every required provider evidence layer agreed.',
evidence,
});
}
export async function evaluateGiteaWriteValidation(
request: GiteaWriteValidationRequestDto,
dependencies: CredentialValidationDependencies,
): Promise<CredentialValidationResultDto> {
try {
return await evaluateGiteaWriteValidationUnsafe(request, dependencies);
} catch (error: unknown) {
if (error instanceof CredentialProviderEvidenceError) {
return providerEvidenceFailure(request, error);
}
throw error;
}
}
@@ -0,0 +1,104 @@
import { describe, expect, it, vi } from 'vitest';
import type { CommandResult, CommandRunner, FleetRoster } from '../commands/fleet.js';
import { TmuxPromotionTransport } from './promotion-transport.js';
const sessionId = 'a'.repeat(64);
const roster: FleetRoster = {
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
defaults: { workingDirectory: '~/src' },
runtimes: {},
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
transport: 'tmux',
version: 1,
};
function result(stdout = '', exitCode = 0, stderr = ''): CommandResult {
return { exitCode, stderr, stdout };
}
describe('TmuxPromotionTransport', () => {
it('resolves the exact roster seat and sends the registered command literally', async () => {
const runner = vi
.fn<CommandRunner>()
.mockResolvedValueOnce(result('1234 claude 0 0 0 0\n'))
.mockResolvedValueOnce(result())
.mockResolvedValueOnce(result());
const environmentReader = vi.fn(async () => `MOSAIC_LEASE_SESSION_ID=${sessionId}\0`);
const transport = new TmuxPromotionTransport({
environmentReader,
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner,
});
const target = await transport.resolve('claude-seat');
await transport.sendPromotion(target);
expect(target).toEqual({
bundle: 'mosaic-fleet',
seat: 'claude-seat',
sessionId,
});
expect(environmentReader).toHaveBeenCalledWith(1234);
expect(runner).toHaveBeenNthCalledWith(2, 'tmux', [
'-L',
'mosaic-fleet',
'send-keys',
'-t',
'=claude-seat:0.0',
'-l',
'/mosaic-promote',
]);
expect(runner).toHaveBeenNthCalledWith(3, 'tmux', [
'-L',
'mosaic-fleet',
'send-keys',
'-t',
'=claude-seat:0.0',
'Enter',
]);
});
// Regression for #1124: the launcher runs claude as a spawnSync CHILD of
// node(mosaic), so the lease env is on the child, not the tmux pane pid. The
// transport must WALK the subtree. This test exercises the real walk (no
// full mock of the resolution) — the seam the original unit test hid.
it('walks the pane subtree to the claude child that carries the lease id', async () => {
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
// pane pid 1234 = node(mosaic): NO lease env. child 5678 = claude: carries it.
const environmentReader = vi.fn(async (pid: number) =>
pid === 5678 ? `FOO=bar\0MOSAIC_LEASE_SESSION_ID=${sessionId}\0` : `FOO=bar\0`,
);
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
const transport = new TmuxPromotionTransport({
environmentReader,
childrenReader,
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner,
});
const target = await transport.resolve('claude-seat');
expect(target.sessionId).toBe(sessionId);
expect(environmentReader).toHaveBeenCalledWith(1234); // pane pid: no lease
expect(environmentReader).toHaveBeenCalledWith(5678); // walked to the child
expect(childrenReader).toHaveBeenCalledWith(1234); // walk actually ran
});
it('fails closed when no process in the pane subtree carries a lease id', async () => {
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
const environmentReader = vi.fn(async () => `FOO=bar\0`);
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
const transport = new TmuxPromotionTransport({
environmentReader,
childrenReader,
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner,
});
await expect(transport.resolve('claude-seat')).rejects.toThrow('no readable lease session');
expect(childrenReader).toHaveBeenCalledWith(1234);
});
});
@@ -0,0 +1,208 @@
import { readFile } from 'node:fs/promises';
import {
buildTmuxListPanesCommand,
getRosterAgent,
parseTmuxListPanes,
resolveFleetPaths,
type CommandResult,
type CommandRunner,
type FleetRoster,
RUNTIME_ACCEPTABLE_COMMANDS,
socketArgs,
} from '../commands/fleet.js';
import { loadFleetRoster } from './fleet-roster-v1.js';
const PROMOTION_COMMAND = '/mosaic-promote';
const SESSION_ID_PATTERN = /^[a-f0-9]{64}$/;
const TRANSPORT_COMMAND_TIMEOUT_MS = 5_000;
export interface PromotionTarget {
bundle: string;
seat: string;
sessionId: string;
}
export interface PromotionTransport {
resolve(seat: string): Promise<PromotionTarget>;
sendPromotion(target: PromotionTarget): Promise<void>;
}
export interface TmuxPromotionTransportOptions {
environmentReader?: (pid: number) => Promise<string>;
childrenReader?: (pid: number) => Promise<number[]>;
mosaicHome: string;
rosterLoader?: () => Promise<FleetRoster>;
runner: CommandRunner;
}
// The launcher runs the runtime as a spawnSync CHILD of node(mosaic) (see
// launch.ts:99 — deliberate, so the parent survives to propagate signals), so
// MOSAIC_LEASE_SESSION_ID lives on the claude child, NOT on the tmux pane's root
// pid. Bound the descendant search so a hung/large process tree can't stall it.
const MAX_SUBTREE_PIDS = 128;
/** Local, roster-bound transport for the in-seat promotion command. */
export class TmuxPromotionTransport implements PromotionTransport {
private readonly environmentReader: (pid: number) => Promise<string>;
private readonly childrenReader: (pid: number) => Promise<number[]>;
private readonly rosterLoader: () => Promise<FleetRoster>;
constructor(private readonly options: TmuxPromotionTransportOptions) {
this.environmentReader = options.environmentReader ?? readPaneEnvironment;
this.childrenReader = options.childrenReader ?? readChildPids;
this.rosterLoader =
options.rosterLoader ??
(() => loadFleetRoster(resolveFleetPaths(options.mosaicHome).rosterPath));
}
async resolve(seat: string): Promise<PromotionTarget> {
const roster = await this.rosterLoader();
const agent = getRosterAgent(roster, seat);
if (agent.runtime !== 'claude') {
throw new Error(`Lease promotion is currently available only for Claude seats: ${seat}.`);
}
const paneResult = await this.run(
buildTmuxListPanesCommand(agent.name, roster.tmux.socketName),
);
if (paneResult.exitCode !== 0) {
throw new Error(`Promotion seat is unavailable: ${seat}.`);
}
const pane = parseTmuxListPanes(paneResult.stdout);
const allowedCommands = RUNTIME_ACCEPTABLE_COMMANDS.claude;
if (
pane.dead ||
pane.pid === null ||
pane.command === null ||
allowedCommands === undefined ||
!allowedCommands.includes(pane.command)
) {
throw new Error(`Promotion seat runtime identity mismatch: ${seat}.`);
}
const sessionId = await this.resolveLeaseSessionId(pane.pid);
if (sessionId === null) {
throw new Error(`Promotion seat has no readable lease session: ${seat}.`);
}
return {
bundle: roster.tmux.socketName || 'default',
seat: agent.name,
sessionId,
};
}
/**
* Find the lease session id in the pane's process subtree. The pane's root pid
* is node(mosaic), which has no lease env; the id lives on the claude child.
* BFS from the root, bounded, returning the first descendant that carries a
* valid MOSAIC_LEASE_SESSION_ID. Fail-closed (null) if none is found.
*/
private async resolveLeaseSessionId(rootPid: number): Promise<string | null> {
const queue: number[] = [rootPid];
const seen = new Set<number>();
while (queue.length > 0 && seen.size < MAX_SUBTREE_PIDS) {
const pid = queue.shift()!;
if (seen.has(pid)) continue;
seen.add(pid);
let sessionId: string | null = null;
try {
sessionId = parseLeaseSessionId(await this.environmentReader(pid));
} catch {
sessionId = null;
}
if (sessionId !== null) return sessionId;
let children: number[] = [];
try {
children = await this.childrenReader(pid);
} catch {
children = [];
}
for (const child of children) {
if (!seen.has(child)) queue.push(child);
}
}
return null;
}
async sendPromotion(target: PromotionTarget): Promise<void> {
const targetPane = `=${target.seat}:0.0`;
const socketName = target.bundle === 'default' ? '' : target.bundle;
// Registered Claude commands must arrive as their exact literal text; the
// fleet agent sender prepends an identity envelope, so it cannot carry this
// command without preventing the UserPromptSubmit matcher from recognizing it.
await this.runPromotionCommand([
'tmux',
...socketArgs(socketName),
'send-keys',
'-t',
targetPane,
'-l',
PROMOTION_COMMAND,
]);
await this.runPromotionCommand([
'tmux',
...socketArgs(socketName),
'send-keys',
'-t',
targetPane,
'Enter',
]);
}
private async runPromotionCommand(command: string[]): Promise<void> {
const result = await this.run(command);
if (result.exitCode !== 0) {
throw new Error('Promotion command delivery failed.');
}
}
private async run(command: string[]): Promise<CommandResult> {
const [executable, ...args] = command;
if (executable === undefined) {
throw new Error('Promotion transport command is empty.');
}
return await withTimeout(this.options.runner(executable, args), TRANSPORT_COMMAND_TIMEOUT_MS);
}
}
function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Promotion transport command timed out after ${timeoutMs}ms.`));
}, timeoutMs);
void operation.then(
(value) => {
clearTimeout(timeout);
resolve(value);
},
(error: unknown) => {
clearTimeout(timeout);
reject(error);
},
);
});
}
async function readPaneEnvironment(pid: number): Promise<string> {
return readFile(`/proc/${pid}/environ`, 'utf8');
}
async function readChildPids(pid: number): Promise<number[]> {
// Linux exposes direct children of the main thread here (CONFIG_PROC_CHILDREN).
try {
const raw = await readFile(`/proc/${pid}/task/${pid}/children`, 'utf8');
return raw
.split(/\s+/)
.filter(Boolean)
.map((value) => Number.parseInt(value, 10))
.filter((value) => Number.isInteger(value) && value > 0);
} catch {
return [];
}
}
function parseLeaseSessionId(environment: string): string | null {
const value = environment
.split('\0')
.find((entry) => entry.startsWith('MOSAIC_LEASE_SESSION_ID='))
?.slice('MOSAIC_LEASE_SESSION_ID='.length);
return value !== undefined && SESSION_ID_PATTERN.test(value) ? value : null;
}

Some files were not shown because too many files have changed in this diff Show More