Compare commits

..
Author SHA1 Message Date
veronica 98f3384554 docs(W4): mark the Applied table as first-pass and point it forward (fred's F1)
ci/woodpecker/pr/ci Pipeline was successful
The Applied table carried 107 stamped and 17 held, the counts from the first
pass, with nothing telling a reader they had moved. The re-closed arithmetic at
the end of the document was already correct, so the document was not wrong
overall, but a reader hitting the table got a stale count with no forward
pointer. In this PR specifically that is the defect the PR exists to remove,
appearing in the PR's own worklist.

The first-pass numbers are left standing and labelled rather than overwritten,
matching every other correction in this document: a record of what a pass
produced is worth more than a number silently updated to still look right. The
current census follows it, re-measured: 108 stamped + 16 + 3 + 1 = 128, kinds 54
guide / 34 record / 13 spec / 7 tracking, status 106 active / 2 completed, and
the two files carrying source_of_truth: true named.
2026-08-20 20:46:16 -05:00
veronica 4211fb87f8 docs(W4): withdraw the test count from the method paragraph, do not replace it
ci/woodpecker/pr/ci Pipeline was canceled
The paragraph arguing 'run the suite before concluding a docs change has no code
consumers' carried a number: 'CI 2592 ran 1617 tests, 1 failed, 1616 passed'.
That number is wrong and is withdrawn in place rather than deleted.

Extracting per-test totals from these pipeline logs is not a reliable method:
the same regex over the same log format returns 1003 for 2592, 1022 for 2593
and 3471 for 2594, which are runs of the same suite. Three irreconcilable
answers from one method is proof the method does not measure what it claims,
so no corrected count is substituted.

The claim is restated in what the log carries reliably, the named FAIL list and
the turbo task line. The count was decoration on a claim already carried by a
specific name: one named failing spec is what refuted three hand-searches.
2026-08-20 20:41:52 -05:00
veronica ff40794bfe docs(W4): revert channel-protocol to guide (F withdrawn), stamp docs/README.md as the contract's source of truth (Q1)
ci/woodpecker/pr/ci Pipeline was successful
fred withdrew decision F after re-measuring channel-protocol.md with a control:
zero uppercase RFC2119 terms, seven lowercase 'must' all disclaiming authority,
under a banner refusing requirements status. F rested on 'the doc graph outranks
the page's own banner', which contradicts decision D, 'kind follows content'.
D is the rule; F was its counterexample. Neither rescue (vision's adjective edit
on the citing docs, or a banner edit) was taken: a kind that survives only by
editing the evidence around it is not a classification.

Q1 answered: docs/README.md prescribes the contract, so by D its content is a
spec, and by B it is the source of truth for that contract. A plan never
outranks the artifact it planned, so the flatten plan's stale 7-value enum is
corrected there rather than competing here.

128 live docs = 108 stamped + 16 operator-held + 3 supersede deferrals + 1 generated.
2026-08-20 20:21:06 -05:00
veronica 94a7d5b692 test(mosaic): pin quickstart.md to status: active, the contract's value for in-force
ci/woodpecker/pr/ci Pipeline failed
CI 2592 proved the old front-matter schema HAD a consumer. vision's C2 concluded "no consumer
found", fred's decision A rested on "no installed base to protect", and I accepted both. All three
of us were wrong.

installation-documentation.spec.ts:39 asserted a raw regex over the markdown text:

    expect(markdown).toMatch(/^---\n[\s\S]*?\nstatus: current\n[\s\S]*?\n---\n/);

Not a front-matter parse and not a key lookup, which is why a search organised around parsing
primitives could not see it. Stamping docs/USER-GUIDE/getting-started/quickstart.md replaced
`status: current` with `status: active` and turned it red.

Updated to the contract's vocabulary, intent unchanged: the page must still declare itself in
force, and must still teach no curl-pipe-to-shell install. Verified by evaluating both regexes
against the real file: old false, new true. Control: a page carrying `superseded-by` still fails
the new regex, so the assertion still asserts something.

2592 also confirms the NORTH_STAR revert took: the north-star drift test is green, `format` and
`lint` are green, and this was the only failure in 1617 tests.

Method point worth keeping: the full suite is a stronger consumer search than any grep, because it
does not depend on guessing how a consumer reads the file.
2026-08-20 20:04:35 -05:00
veronica 12d5258e20 docs(W4): apply fred's six contract decisions from PR #1350 comment 23693
A - docs/README.md:149-190 rewritten. It prescribed a competing front-matter schema
    (title/type/audience/status/source_of_truth) adopted by 4 of 128 live documents. Two
    documented conventions in one repo is the defect this pass removes, so the README now
    documents the contract and the 4 files convert in the same commit: `type` dropped
    (kind replaces it), `title`/`audience`/`source_of_truth` kept.
B - source-of-truth leaves the kind enum, which is now 6 values, and returns as an orthogonal
    boolean. kind was carrying two independent facts. docs/requirements/native-kanban-sot.md
    is stamped `kind: spec` + `source_of_truth: true`, which is what it always was.
C - status gains `completed`. Applied to the two executed plans, on artifact evidence rather
    than on their own say-so: --purpose push|merge ships in ci-queue-wait.sh, and every section
    the README plan specifies exists in docs/README.md today.
D - kind follows content, never filename. docs/native-kanban-sot/TASKS.md is `kind: spec`
    because its body says "a build plan, not a task tracker". The name stays wrong; that is a
    rename and it is out of scope here.
E - the contract covers .md only, written into the README as a decision with vision's
    YAML.parse measurement as the reason, so the omission does not read as an oversight.
F - channel-protocol.md guide -> spec. Applied, with a correction the reviewer should see: the
    ruling cites "7 normative MUSTs" and there are ZERO uppercase RFC2119 terms in that file.
    Control: the identical grep returns 25 lines in docs/requirements/native-kanban-sot.md. The
    citation half of the finding does hold and is larger than stated. Consequence recorded in
    the worklist: the file's own banner now contradicts its header.

Verified: 128 live .md under docs/ (127 baseline + this PR's worklist), 107 stamped, 0 invalid
kinds, 17 operator-held + 3 supersede-stamp deferrals + 1 generated = 21 unstamped. 107+21=128.
Control: the verifier reports valid=False when a kind is corrupted to `nonsense`, so the
0-invalid result is a real result. prettier --check clean across docs/.
2026-08-20 19:58:17 -05:00
veronica f6fbeaf57a docs(W4): response to the W5 adversarial pass, in the diff rather than a PR comment
pr-review.sh -n 1350 -a comment fails with HTTP 401 'invalid username, password or token'.
Measured cause with a control: veronica's seat token returns 200 on /api/v1/user and the shared
broker credential gitea-mosaicstack returns 401 on the same endpoint. The wrapper logic is fine,
the credential it is handed is dead, so the PR wrappers are down for every seat on this host.
MOSAIC_GIT_IDENTITY appears nowhere in credentials.sh, which is FD-9.

Constitution gate 8 makes that operation blocked, so the corrections land in the branch instead,
where they survive the PR anyway.
2026-08-20 19:55:23 -05:00
veronica 8a55c04108 docs(W4): prettier the worklist table padding
ci/woodpecker/pr/ci Pipeline failed
CI 2589 step `format` failed on exactly one file: this one, the plan document I hand-wrote.
prettier --check reproduces locally, rc=1, same single file.

That is worth stating for the review: the other 103 stamped documents pass
`prettier --check` unchanged, so the `---\nkind:\nstatus:\n---` front matter block is
already prettier-clean. The formatting failure was in my prose, not in the contract header.

Diff is 31 lines in, 31 out, all table column padding. No content change.
2026-08-20 19:48:07 -05:00
veronica bea47543f3 docs(W4): revert the NORTH_STAR.md stamp; a projection cannot carry a hand-added header
ci/woodpecker/pr/ci Pipeline was canceled
Found by vision's adversarial pass (W5) and re-measured here. The finding was raised as
latent; it is live in this PR.

docs/fleet/NORTH_STAR.md is generated by renderNorthStarMarkdown()
(packages/mosaic/src/commands/fleet.ts:373), whose first emitted line is the H1 and which
emits no front matter at all. fleet-north-star.spec.ts:110-114 is a drift test that reads
the committed file from disk and asserts full-string equality against the renderer output:

    expect(rendered).toBe(committed);

Stamping the file changed line 1 from '# Mosaic Fleet - NORTH STAR' to '---', so the
assertion fails. vitest could not be run in this worktree (node_modules empty, 'Cannot find
package yaml'), which is a harness gap and not a test result, so this is shown by
construction: the assertion above, the renderer's first lines.push, and line 1 before
versus after.

Reverted to origin/next verbatim: git diff origin/next -- docs/fleet/NORTH_STAR.md is 0
lines. Control on docs/fleet/reference/cli.md returns 13 lines, so the diff command does
report differences when they exist.

The header for a projection has to come from the renderer, not the file. That is a code
change and belongs in the flatten PR beside the resolveNorthStarPaths() fix.

Stamped count 104 -> 103. Untouched 23 -> 24. Worklist records it as a fourth held row.
2026-08-20 19:46:24 -05:00
veronica 37cd00e60d docs(W4): operator worklist for the 19 held rows, plus two contract conflicts
ci/woodpecker/pr/ci Pipeline failed
Records what the stamping pass applied, what it held and why, and the two
places where the contract contradicts itself or the repository:

- docs/native-kanban-sot/TASKS.md declares itself a build plan; the contract
  says TASKS.md is tracking and never a spec.
- docs/requirements/native-kanban-sot.md is hand-authored prose canon; plan
  section 5.2 calls that source-of-truth, the contract table requires
  source-of-truth to be machine-readable.
- docs/README.md already documents a competing front-matter convention on a
  colliding status key, applied to 4 files, with no code consumer.
- The contract has no defined form for a .yaml document, including
  docs/fleet/NORTH_STAR.yaml itself.
2026-08-20 19:31:25 -05:00
veronica f0d2dd9920 docs(W4): stamp kind and status front matter on 104 live documents
Applies the document contract from
docs/plans/2026-08-20_stack-docs-flatten-and-alignment.md section 3, partially:
`kind` and `status` only. `parent` is deliberately held until the flatten in
section 4 lands, so that 127 documents do not have to be re-pointed by hand
when docs/fleet/NORTH_STAR.yaml moves to docs/NORTH_STAR.yaml.

Scope, measured on origin/next at 63069149:

  127 live docs   = all *.md under docs/ minus docs/archive/ minus docs/_old_structure/
  104 stamped     here
   19 held        operator judgement (plan section 9), worklist in the same PR
    3 held        the SUPERSEDED TASKS.md stamps, which cite the moving path
    1 untouched   docs/fleet/FLEET-DOCTRINE.md, already stamped in W1

Kinds applied: 54 guide, 34 record, 9 spec, 6 tracking, 1 projection.
Every row carries a confidence and a one-line rationale in the worklist.

Two collisions with the existing state, both flagged rather than resolved:

1. docs/README.md:150-160 already documents a front-matter convention
   (title/type/audience/status/source_of_truth) with its own allowed values.
   It is applied to 4 of 127 files. Its `status` vocabulary is
   current|draft|deprecated|historical; the new contract's is active|superseded-by.
   The key collides. This commit lets the new contract win and rewrites
   `status: current` to `status: active` on those 4 files, keeping their other
   legacy keys untouched. No code reads any of them: `git grep source_of_truth`
   outside docs/ returns nothing. docs/README.md still prescribes the old
   convention and is an operator row, so it is not edited here.

2. Two of the plan's 20 operator rows are YAML files, not markdown
   (docs/fleet/examples/roster-v2.yaml, docs/openapi-tess.yaml), and the
   contract's front-matter form has no defined meaning for a .yaml document.
   That gap also applies to docs/fleet/NORTH_STAR.yaml, the source of truth
   itself. Raised in the worklist.

A third row from the plan, docs/fleet/north-star.md, no longer exists: W1
renamed it to docs/fleet/FLEET-DOCTRINE.md.

Verification: 104/104 parse with the expected kind and status in front matter;
the check was shown to reject a wrong kind before it was trusted. The diff
removes 4 lines total, all of them `status: current`.
2026-08-20 19:30:25 -05:00
33 changed files with 115 additions and 2568 deletions
-14
View File
@@ -91,20 +91,6 @@ steps:
# and sandboxes a throwaway git repo, so it resolves no real credentials and
# joins CI directly rather than the exclusions file.
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
# Hermetic regression for the git identity ladder (#1356): mock tea on PATH,
# sandboxed repo, no real credentials (3/3 green under an empty HOME). Pins
# fail-closed: a seat whose login is missing gets a named error, never a
# borrowed identity. Joins CI directly; its #1007 exclusion is burned down.
- bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
# Hermetic regression for issue-view.sh (#1357): mock tea/curl, sandboxed
# repo. Pins that comment BODIES render on both paths and that a tea
# failure is named as what it was (git-config vs credential).
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh
# Hermetic regression for mint-seat-credential.sh (fleet onboarding moved into
# the framework): mock curl, sandboxed brain home, no tea, no network. Pins
# that the admin seat is configured rather than hardcoded and that the seat
# slot is written from the mint response at mode 600.
- bash packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
# it still blocks the three mistakes AND still lets reads, unwrapped
# endpoints and ordinary commands through. Both directions are asserted —
@@ -1,24 +0,0 @@
# Fleet tools
Seat lifecycle tools for a Mosaic fleet. Paths are relative to
`packages/mosaic/framework/tools/fleet/` (deployed to `~/.config/mosaic/tools/fleet/`).
| Script | Purpose |
| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `start-agent-session.sh` | launch, stop, or attach a roster-driven agent session (reads `<seat>.env.generated`, honours `MOSAIC_TMUX_SOCKET`) |
| `seat-logins.sh` | project seat tokens into `tea` logins named `<instance>-<seat>` (dry-run by default, `--apply`, `--adopt`) |
| `mint-seat-credential.sh` | create the Gitea account for a seat on every configured instance, mint a token, write the seat's credential slot, then project it into `tea` |
| `start-interaction-service.sh`, `print-interaction-effective-policy.sh`, `start-tmux-holder.sh` | operator interaction service and tmux holder |
## Onboarding a seat's credential
```
MOSAIC_ADMIN_SEAT=<admin-seat> MOSAIC_SEAT_EMAIL_DOMAIN=<domain> mint-seat-credential.sh <seat>
```
- The admin token is read from `$MOSAIC_BRAIN_HOME/fleet/agents/<admin-seat>/secrets/gitea-<instance>-<admin-seat>.token`. It is never printed.
- `MOSAIC_SEAT_EMAIL_DOMAIN` is required (no default): the framework ships no estate-specific domain.
- Instances default to the map shared with `seat-logins.sh`; `MOSAIC_GITEA_INSTANCES="a b"` limits the set and `MOSAIC_GITEA_URL_<INSTANCE>` overrides a server URL (hyphens in the instance name become underscores in the variable, as in `seat-logins.sh`).
- The seat slot is written from the mint response: `.token`, `.scopes` (what was granted), `.principal`, each mode 600.
- `tea` absent is a warning, not a failure: REST-path wrappers work with the token alone.
- Regression suite: `test-mint-seat-credential.sh` (hermetic, mock curl, no network).
@@ -1,198 +0,0 @@
#!/usr/bin/env bash
# mint-seat-credential.sh — create the Gitea account and mint a token for one seat,
# on every configured instance, writing the result into that seat's credential slot.
#
# mint-seat-credential.sh [--admin-seat <seat>] [--instances "<a> <b>"] <seat>
#
# Configuration (environment; flags win over environment):
# MOSAIC_ADMIN_SEAT seat whose admin token is used to call the Gitea
# admin API. Required. Its token is read from
# $MOSAIC_BRAIN_HOME/fleet/agents/<admin>/secrets/
# gitea-<instance>-<admin>.token. Never printed.
# MOSAIC_GITEA_INSTANCES space-separated instance names to mint on.
# Default: every instance in the map below.
# MOSAIC_GITEA_URL_<INSTANCE> server URL override per instance (same
# convention as seat-logins.sh).
# MOSAIC_SEAT_EMAIL_DOMAIN domain for the account email (<seat>@<domain>).
# Required, no default: the framework tree
# carries no estate-specific domain
# (framework-PR firewall; the instance host
# map stays per seat-logins.sh precedent).
# MOSAIC_BRAIN_HOME brain checkout; default ~/.mosaic.
#
# Exit codes: 0 minted and projected on every instance; 1 at least one instance
# failed (the others are untouched or complete); 3 usage error.
#
# WHY BASIC AUTH, WHICH LOOKS WRONG AT FIRST
# Gitea refuses token auth on POST /users/{user}/tokens by design, and the Sudo
# header and sudo query parameter are both rejected there (probed 2026-08-19, probe
# token deleted). So minting for another account needs a password: this script
# generates a random one, uses it once, and never stores or prints it. Agents
# authenticate by token; the password is not a credential anyone keeps.
#
# The .scopes file is written from the mint RESPONSE rather than from what was
# requested, so the record is what was granted rather than what was asked for.
#
# SECRETS NEVER TOUCH ARGV (#1343 class, rev-security-01 review 259): the admin
# token, the generated password, and the minted seat token all pass through
# 0600 curl --config / --data files — the landed in-tree standard
# (gitea_write_auth_config in detect-platform.sh). argv is world-readable via
# /proc/<pid>/cmdline for the life of each request, and a bash -x trace would
# print every secret otherwise. The staging files are unlinked after each use.
set -Eeuo pipefail
# Stage secrets into 0600 files; nothing secret reaches argv or a trace.
# write_auth_config <token> -> curl --config carrying the Authorization header
# (same shape as gitea_write_auth_config in
# detect-platform.sh, local so this script stays
# standalone under tools/fleet).
# write_user_config <u> <pw> -> curl --config with `user =` (covers -u).
# write_body <json> -> 0600 file for --data @file.
write_auth_config() {
local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-auth.XXXXXX") || return 1
printf 'header = "Authorization: token %s"\n' "$1" >"$f" || { rm -f "$f"; return 1; }
chmod 600 "$f"; printf '%s' "$f"
}
write_user_config() {
local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-user.XXXXXX") || return 1
printf 'user = "%s:%s"\n' "$1" "$2" >"$f" || { rm -f "$f"; return 1; }
chmod 600 "$f"; printf '%s' "$f"
}
write_body() {
local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-body.XXXXXX") || return 1
printf '%s' "$1" >"$f" || { rm -f "$f"; return 1; }
chmod 600 "$f"; printf '%s' "$f"
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BRAIN="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
ADMIN="${MOSAIC_ADMIN_SEAT:-}"
INSTANCES="${MOSAIC_GITEA_INSTANCES:-}"
EMAIL_DOMAIN="${MOSAIC_SEAT_EMAIL_DOMAIN:-}"
SEAT=""
usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" >&2; exit 3; }
while [[ $# -gt 0 ]]; do
case "$1" in
--admin-seat) ADMIN="${2:-}"; shift 2 ;;
--instances) INSTANCES="${2:-}"; shift 2 ;;
-h|--help) usage ;;
-*) echo "mint: unknown flag: $1" >&2; exit 3 ;;
*) [[ -z "$SEAT" ]] || { echo "mint: one seat only" >&2; exit 3; }; SEAT="$1"; shift ;;
esac
done
[[ -n "$SEAT" ]] || usage
[[ "$SEAT" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad seat name: $SEAT" >&2; exit 3; }
[[ -n "$ADMIN" ]] || { echo "mint: no admin seat. Set MOSAIC_ADMIN_SEAT or pass --admin-seat." >&2; exit 3; }
[[ "$ADMIN" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad admin seat name: $ADMIN" >&2; exit 3; }
[[ -n "$EMAIL_DOMAIN" ]] || { echo "mint: no email domain. Set MOSAIC_SEAT_EMAIL_DOMAIN (the framework ships no estate default)." >&2; exit 3; }
# Instance -> server URL. Same map and override convention as seat-logins.sh:
# hyphens in instance names map to underscores in the override variable
# (MOSAIC_GITEA_URL_MY-INST is not a valid shell name; MY_INST is).
url_override_var() { printf 'MOSAIC_GITEA_URL_%s' "$(printf '%s' "$1" | tr '[:lower:]-' '[:upper:]_')"; }
declare -A INSTANCE_URL=(
[mosaicstack]="https://git.mosaicstack.dev"
[usc]="https://git.uscllc.com"
)
for inst in "${!INSTANCE_URL[@]}"; do
ov="$(url_override_var "$inst")"
[[ -n "${!ov:-}" ]] && INSTANCE_URL[$inst]="${!ov}"
done
[[ -n "$INSTANCES" ]] || INSTANCES="$(printf '%s\n' "${!INSTANCE_URL[@]}" | sort | tr '\n' ' ')"
SCOPES='["read:user","write:repository","write:issue","read:organization"]'
D="$BRAIN/fleet/agents/$SEAT/secrets"
mkdir -p "$D"; chmod 700 "$D"
rc=0
for KEY in $INSTANCES; do
ov="$(url_override_var "$KEY")"
BASE="${INSTANCE_URL[$KEY]:-${!ov:-}}"
[[ -n "$BASE" ]] || { echo " $KEY: no URL known for this instance (set $ov), skipped" >&2; rc=1; continue; }
ADMIN_TOKEN_FILE="$BRAIN/fleet/agents/$ADMIN/secrets/gitea-$KEY-$ADMIN.token"
[[ -r "$ADMIN_TOKEN_FILE" ]] || { echo " $KEY: no admin token for seat '$ADMIN' ($ADMIN_TOKEN_FILE), skipped" >&2; rc=1; continue; }
T="$(cat "$ADMIN_TOKEN_FILE")"
AUTH_CFG="$(write_auth_config "$T")"
PW="$(openssl rand -base64 33 | tr -d '\n/+=' | head -c 32)"
USER_CFG="$(write_user_config "$SEAT" "$PW")"
if curl -sf -o /dev/null --config "$AUTH_CFG" "$BASE/api/v1/users/$SEAT"; then
BODY="$(write_body "{\"login_name\":\"$SEAT\",\"source_id\":0,\"password\":\"$PW\",\"must_change_password\":false}")"
curl -s -o /dev/null -X PATCH -H "Content-Type: application/json" \
--config "$AUTH_CFG" --data "@$BODY" \
"$BASE/api/v1/admin/users/$SEAT"
rm -f "$BODY"; BODY=""
act="reset-pw"
else
BODY="$(write_body "{\"username\":\"$SEAT\",\"email\":\"$SEAT@$EMAIL_DOMAIN\",\"password\":\"$PW\",\"must_change_password\":false,\"full_name\":\"Mosaic fleet seat $SEAT\"}")"
curl -s -o /dev/null -X POST -H "Content-Type: application/json" \
--config "$AUTH_CFG" --data "@$BODY" \
"$BASE/api/v1/admin/users"
rm -f "$BODY"; BODY=""
act="create"
fi
tmp="$(mktemp)"; chmod 600 "$tmp"
MINT_BODY="$(write_body "{\"name\":\"mosaic-seat\",\"scopes\":$SCOPES}")"
code="$(curl -s -o "$tmp" -w '%{http_code}' -X POST -H "Content-Type: application/json" \
--config "$USER_CFG" --data "@$MINT_BODY" "$BASE/api/v1/users/$SEAT/tokens")"
rm -f "$MINT_BODY"; MINT_BODY=""
if [[ "$code" != "201" ]]; then
echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; PW=""; rm -f "$AUTH_CFG" "$USER_CFG"; continue
fi
python3 - "$tmp" "$D" "$KEY" "$SEAT" <<'PY'
import json,sys,pathlib
tmp,d,key,seat=sys.argv[1:5]
t=json.load(open(tmp))
p=pathlib.Path(d)
(p/f"gitea-{key}-{seat}.token").write_text(t["sha1"]+"\n")
(p/f"gitea-{key}-{seat}.scopes").write_text(json.dumps(t.get("scopes",[]))+"\n")
(p/f"gitea-{key}-{seat}.principal").write_text(seat+"\n")
for suf in ("token","scopes","principal"):
(p/f"gitea-{key}-{seat}.{suf}").chmod(0o600)
PY
rm -f "$tmp"; PW=""; rm -f "$AUTH_CFG" "$USER_CFG"
VERIFY_CFG="$(write_auth_config "$(cat "$D/gitea-$KEY-$SEAT.token")")"
login="$(curl -s --config "$VERIFY_CFG" "$BASE/api/v1/user" \
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("login","ERR"))' 2>/dev/null || echo ERR)"
rm -f "$VERIFY_CFG"
if [[ "$login" == "$SEAT" ]]; then
echo " $KEY: $act, minted, GET /user -> $login"
else
echo " $KEY: minted but identity check returned '$login', expected '$SEAT'" >&2; rc=1
fi
done
# ── Project into tea ─────────────────────────────────────────────────────────
# A token in the secrets dir is only half a credential. tea 0.14.0 cannot read
# that store, it only uses logins already in its own config, so a seat minted
# but not projected holds a working token and no login. Minting and projecting
# are therefore ONE operation.
#
# --adopt is deliberately NOT passed. Adopting deletes an operator-made login,
# which is a human decision. A collision reports BLOCK and a nonzero rc instead.
#
# tea absent is not a minting failure. The REST-path wrappers still work with
# the token that was just written, so warn and carry on.
SEAT_LOGINS="$SCRIPT_DIR/seat-logins.sh"
if [[ "$rc" -eq 0 ]]; then
if command -v tea >/dev/null 2>&1; then
if "$SEAT_LOGINS" --apply --seat "$SEAT"; then
:
else
echo " projection FAILED: token is minted and valid, but no tea login exists for $SEAT." >&2
echo " tea-path wrappers will not act as this seat. Re-run:" >&2
echo " $SEAT_LOGINS --apply --seat $SEAT" >&2
rc=1
fi
else
echo " tea not on PATH: token minted, no login projected (REST-path wrappers still work)." >&2
fi
fi
exit $rc
@@ -1,181 +0,0 @@
#!/usr/bin/env bash
# seat-logins.sh — project seat credentials into tea's login config.
#
# Issue: mosaicstack/stack#1356 (tea login resolution fails open).
#
# WHY THIS EXISTS. tea 0.14.0 has no --token on its operations; it can only use a
# login already stored in ~/.config/tea/config.yml. So the wrappers cannot read the
# seat secrets dir on the tea path. The secrets dir stays authoritative and this
# script projects it into tea's config, which is a DERIVED CACHE: regenerate it,
# never hand-edit it. Same shape as the config-registry projector, same reason —
# a third-party tool that cannot read our store has to be fed.
#
# Canonical login name is "<instance>-<seat>", which is what the identity ladder in
# detect-platform.sh computes from the seat name. A login the ladder cannot compute
# is a fail-open surface, so an ad-hoc name is a defect, not a style.
#
# COLLISIONS. tea refuses to store one token under two names ("token already been
# used, delete login 'X' first"). A hand-made alias holding a seat's token there-
# fore BLOCKS its canonical name. Detected up front by hashing, so a dry run shows
# it; --adopt resolves it by deleting the alias and re-minting canonically. Same
# token, same access, only the label changes.
#
# Tokens are never printed, never logged, and never passed on a visible command
# line beyond tea's own --token, which is unavoidable with this client. tea's
# stderr is echoed on failure with any token-shaped string redacted.
#
# Usage:
# seat-logins.sh # dry run, all seats (default: changes nothing)
# seat-logins.sh --apply # mint/refresh all seats
# seat-logins.sh --seat <seat> # limit to one seat
# seat-logins.sh --apply --adopt # also rename ad-hoc aliases to canonical names
set -euo pipefail
BRAIN_HOME="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
TEA_CONFIG="${TEA_CONFIG:-$HOME/.config/tea/config.yml}"
APPLY=0
ADOPT=0
ONLY_SEAT=""
# Instance -> server URL.
#
# Instances are named here because there is no registry to read them from yet.
# Override per-instance without editing this file, which is how a deployment adds
# its own hosts: MOSAIC_GITEA_URL_<INSTANCE>=https://...
declare -A INSTANCE_URL=(
[mosaicstack]="https://git.mosaicstack.dev"
[usc]="https://git.uscllc.com"
)
while [ $# -gt 0 ]; do
case "$1" in
--apply) APPLY=1; shift ;;
--adopt) ADOPT=1; shift ;;
--seat) ONLY_SEAT="${2:?--seat needs a name}"; shift 2 ;;
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
*) echo "seat-logins.sh: unknown argument '$1'" >&2; exit 2 ;;
esac
done
command -v tea >/dev/null || { echo "seat-logins.sh: tea not on PATH" >&2; exit 1; }
url_for() {
local inst="$1" ovr
ovr="MOSAIC_GITEA_URL_$(printf '%s' "$inst" | tr '[:lower:]-' '[:upper:]_')"
if [ -n "${!ovr:-}" ]; then printf '%s' "${!ovr}"; return 0; fi
printf '%s' "${INSTANCE_URL[$inst]:-}"
}
# Redact anything token-shaped before any tea output reaches a log.
redact() { sed -E 's/[A-Za-z0-9]{30,}/<REDACTED>/g'; }
# token sha256 -> login name, for every login tea already holds. This is what
# makes collisions visible in a DRY RUN instead of only as an apply-time error.
declare -A TOKEN_OWNER=()
if [ -r "$TEA_CONFIG" ]; then
while read -r sha lname; do
[ -n "${sha:-}" ] && TOKEN_OWNER["$sha"]="$lname"
done < <(python3 - "$TEA_CONFIG" <<'PY'
import sys, yaml, hashlib
try:
cfg = yaml.safe_load(open(sys.argv[1])) or {}
except Exception:
sys.exit(0)
for l in (cfg.get('logins') or []):
t = l.get('token')
if t:
print(hashlib.sha256(t.encode()).hexdigest(), l.get('name'))
PY
)
fi
minted=0; refreshed=0; skipped=0; failed=0; planned=0; adopted=0; blocked=0
existing="$(tea login list --output simple 2>/dev/null | awk '{print $1}' || true)"
shopt -s nullglob
for tokfile in "$BRAIN_HOME"/fleet/agents/*/secrets/gitea-*.token; do
seat="${tokfile#"$BRAIN_HOME"/fleet/agents/}"; seat="${seat%%/*}"
[ -n "$ONLY_SEAT" ] && [ "$seat" != "$ONLY_SEAT" ] && continue
base="$(basename "$tokfile" .token)" # gitea-<instance>-<seat>
inst="${base#gitea-}"; inst="${inst%-"$seat"}"
name="${inst}-${seat}"
url="$(url_for "$inst")"
if [ -z "$url" ]; then
echo " SKIP $name — no URL known for instance '$inst' (set MOSAIC_GITEA_URL_${inst^^})"
skipped=$((skipped+1)); continue
fi
if [ ! -r "$tokfile" ]; then
echo " SKIP $name — token not readable"
skipped=$((skipped+1)); continue
fi
action="mint"
grep -qx "$name" <<<"$existing" && action="refresh"
# Is this exact token already stored under some OTHER name?
tsha="$(sha256sum < "$tokfile" | awk '{print $1}')"
owner="${TOKEN_OWNER[$tsha]:-}"
collision=""
[ -n "$owner" ] && [ "$owner" != "$name" ] && collision="$owner"
if [ "$APPLY" -eq 0 ]; then
if [ -n "$collision" ]; then
if [ "$ADOPT" -eq 1 ]; then
echo " PLAN adopt $collision -> $name ($url)"
else
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
blocked=$((blocked+1)); continue
fi
else
echo " PLAN $action $name -> $url"
fi
planned=$((planned+1)); continue
fi
if [ -n "$collision" ]; then
if [ "$ADOPT" -eq 0 ]; then
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
blocked=$((blocked+1)); continue
fi
tea login delete "$collision" >/dev/null 2>&1 || true
action="adopt"
fi
# tea has no idempotent add; refresh is delete-then-add so a rotated token lands.
[ "$action" = refresh ] && tea login delete "$name" >/dev/null 2>&1 || true
if err="$(tea login add --name "$name" --url "$url" \
--token "$(cat "$tokfile")" --no-version-check 2>&1 >/dev/null)"; then
case "$action" in
mint) minted=$((minted+1)) ;;
refresh) refreshed=$((refreshed+1)) ;;
adopt) adopted=$((adopted+1)) ;;
esac
if [ "$action" = adopt ]; then
echo " OK adopt $collision -> $name ($url)"
else
echo " OK $action $name -> $url"
fi
else
# A failure here is real information: the seat's token is dead, or the server
# refused it. Do not paper over it; the seat cannot act until it is reminted.
# tea's own words, redacted — a summarised FAIL hides whether the cause is the
# credential or the client, which cost a diagnosis on 2026-08-21.
echo " FAIL $action $name -> $url"
echo " tea: $(printf '%s' "$err" | redact | head -1)"
failed=$((failed+1))
fi
done
echo
if [ "$APPLY" -eq 0 ]; then
echo "dry run: $planned login(s) would be written, $skipped skipped, $blocked blocked."
[ "$blocked" -gt 0 ] && echo "re-run with --adopt to rename ad-hoc aliases to canonical names."
echo "no changes made. re-run with --apply."
else
echo "minted=$minted adopted=$adopted refreshed=$refreshed skipped=$skipped blocked=$blocked failed=$failed"
fi
[ "$failed" -eq 0 ] && [ "$blocked" -eq 0 ]
@@ -257,36 +257,8 @@ assert_owned_tmux_server() {
fail "tmux server ownership or environment validation failed"
}
# Lease-broker socket preflight (#1292). The gated runtime (`mosaic yolo …` →
# launch-runtime.py) registers with the broker or dies ~4 seconds in, with the
# diagnostic invisible because tmux destroys the dead pane. This check runs
# BEFORE any tmux effect — including the ownership probe below — so a host
# without a broker produces a named, surviving refusal instead of a doomed
# pane. Exit 75 (EX_TEMPFAIL), distinct from 64 (bad projection) and 69 (host
# not ready for other reasons); the agent@ unit is Type=oneshot with no
# Restart=, so the failed unit keeps its message instead of looping. Socket
# resolution matches launch.ts's defaultLeaseBrokerSocket precedence exactly.
# This preflight DETECTS and REFUSES — it never starts the broker (activation
# belongs to the fleet control plane; a component that both detects and fixes
# cannot be used to measure whether the fix worked).
broker_socket_path() {
if [ -n "${MOSAIC_LEASE_BROKER_SOCKET:-}" ]; then
printf '%s\n' "$MOSAIC_LEASE_BROKER_SOCKET"
return 0
fi
local runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
printf '%s\n' "${runtime_dir}/mosaic-lease/broker.sock"
}
if [ "$MODE" = "launch" ]; then
_broker_socket=$(broker_socket_path)
if [ ! -S "$_broker_socket" ]; then
echo "[fleet] FAIL_LAUNCH broker-absent: lease broker socket ${_broker_socket} missing; runtime launch denied (#1292)." >&2
echo "[fleet] remedy: systemctl --user enable --now mosaic-lease-broker.service (or reinstall via: mosaic fleet install)" >&2
exit 75
fi
fi
# Validate exact server ownership before querying, cleaning, or creating any
# managed session. An unmanaged or contaminated named socket is never repaired.
assert_owned_tmux_server
if [ "$MODE" = interaction ]; then
@@ -1,222 +0,0 @@
#!/usr/bin/env bash
# CI-fit regression suite for the #1292 lease-broker socket preflight in
# start-agent-session.sh.
#
# WHY THIS SUITE IS CI-FIT WHERE test-start-agent-session.sh IS NOT (#1017/#1270
# context): that older suite's precondition is "the host does not have the pi
# binary", which a CI image that ships pi violates — its guard correctly
# refuses to report a pass there, so it is excluded from the chain. THIS suite
# controls its own preconditions instead of inheriting them from the host: a
# fake tmux on PATH, a fake mosaic on PATH, a real unix socket created in a
# tmpdir, a hermetic env (env -i, fake HOME, GIT_CONFIG_GLOBAL severed). It
# never depends on what the host has installed, so a green here means the same
# thing on every host. Anyone adding cases: keep that property — no case may
# depend on host state.
#
# The failure this suite is written down to catch (#1292): a seat launched on a
# host with no lease broker dies ~4 seconds in at registration, with the
# diagnostic invisible because tmux destroys the dead pane. The preflight runs
# BEFORE any tmux effect and refuses with a NAMED code (exit 75, EX_TEMPFAIL)
# so the message survives. The agent@ unit is Type=oneshot with no Restart=,
# so a failed unit keeps its output instead of looping.
#
# Cases:
# 1. absent socket -> exit 75, message names broker-absent + socket path +
# remedy, and NO tmux session was ever created (the doomed-pane half).
# 2. present socket (real unix socket in tmpdir) -> proceeds PAST the
# preflight (the suite then stops at the next precondition, proving the
# preflight was not the refusal).
# 3. explicit MOSAIC_LEASE_BROKER_SOCKET wins over XDG_RUNTIME_DIR default.
# 4. --stop mode does NOT require the broker (teardown must not be fenced on
# a component whose absence is exactly what teardown may follow).
#
# Sabotage control, run by the developer (not in-suite): remove the preflight
# block from start-agent-session.sh, re-run — case 1 fails (a tmux session is
# created / exit is not 75), cases 2-4 still pass; restore byte-identically.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/agent-session-broker-preflight}"
FAKE_HOME="$WORK_DIR/home"
BIN_DIR="$WORK_DIR/bin"
ENV_DIR="$WORK_DIR/env"
SOCK_DIR="$WORK_DIR/sockets"
LOG_FILE="$WORK_DIR/tmux-calls.log"
rm -rf "$WORK_DIR"
# The script asserts a managed directory tree under MOSAIC_HOME: mosaic/,
# mosaic/fleet/, mosaic/fleet/agents/ — private (0700/0750-style) modes, no
# symlinks — plus a per-agent env projection. Build the full tree the launcher
# expects so the suite reaches the BROKER preflight rather than dying at
# environment validation.
mkdir -p "$FAKE_HOME/.config/mosaic/fleet/agents" "$BIN_DIR" "$SOCK_DIR"
chmod 700 "$FAKE_HOME/.config/mosaic" "$FAKE_HOME/.config/mosaic/fleet/agents"
chmod 750 "$FAKE_HOME/.config/mosaic/fleet"
cat > "$FAKE_HOME/.config/mosaic/fleet/agents/preflight-test.env.generated" <<'ENVEOF'
MOSAIC_AGENT_NAME=preflight-test
MOSAIC_GIT_IDENTITY=preflight-test
MOSAIC_AGENT_CLASS=worker
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=
MOSAIC_AGENT_REASONING=
MOSAIC_AGENT_TOOL_POLICY=code
MOSAIC_AGENT_WORKDIR=/tmp
MOSAIC_TMUX_SOCKET=mosaic-fleet
ENVEOF
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/preflight-test.env.generated"
# ─── Fake tmux: records every invocation; new-session marks the marker. ────
: > "$LOG_FILE"
cat > "$BIN_DIR/tmux" <<SH
#!/usr/bin/env bash
printf 'tmux %s\n' "\$*" >> "$LOG_FILE"
if [[ "\$*" == *new-session* ]]; then
echo "TMUX-NEW-SESSION-INVOKED" >> "$LOG_FILE"
fi
exit 0
SH
chmod +x "$BIN_DIR/tmux"
# ─── Fake mosaic/pi binaries so the script proceeds past its own lookups. ───
for bin in mosaic pi claude; do
printf '#!/usr/bin/env bash\nexit 0\n' > "$BIN_DIR/$bin"
chmod +x "$BIN_DIR/$bin"
done
# ─── Minimal launch environment the script expects. ────────────────────────
# (Enough for the preflight to be reached; later stages will still fail in
# case 2 — that is expected and asserted.)
run_session_script() {
local mode="$1"; shift
(
cd "$WORK_DIR"
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:/usr/bin:/bin" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_HOME="$FAKE_HOME/.config/mosaic" \
AGENT_NAME=preflight-test \
"$@" \
bash "$SCRIPT_DIR/start-agent-session.sh" $mode preflight-test
)
}
fail=0
assert() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" != "$actual" ]]; then
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
fail=1
fi
}
assert_contains() {
local desc="$1" haystack="$2" needle="$3"
[[ "$haystack" == *"$needle"* ]] || { echo "FAIL: $desc — missing '$needle' in: $haystack" >&2; fail=1; }
}
assert_not_contains() {
local desc="$1" haystack="$2" needle="$3"
if [[ "$haystack" == *"$needle"* ]]; then
echo "FAIL: $desc — must not contain '$needle'" >&2
fail=1
fi
return 0
}
# ─── 1. Absent socket → named refusal, NO tmux session. ────────────────────
: > "$LOG_FILE"
stderr_file="$WORK_DIR/stderr-1.tmp"
set +e
out=$(run_session_script "" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent.sock" 2>"$stderr_file")
rc=$?
set -e
assert "absent socket exit code" "75" "$rc"
err=$(cat "$stderr_file")
assert_contains "absent socket names the failure" "$err" "FAIL_LAUNCH broker-absent"
assert_contains "absent socket names the socket path" "$err" "$SOCK_DIR/absent.sock"
assert_contains "absent socket names a remedy" "$err" "mosaic fleet install"
log1=$(cat "$LOG_FILE")
assert_not_contains "absent socket must not create a tmux session" "$log1" "TMUX-NEW-SESSION-INVOKED"
# ─── 2. Present socket → passes the preflight. ─────────────────────────────
# Expected: ownership/env checks AFTER the preflight may refuse (fixture is
# minimal by design); the assertion is only that the refusal is NOT
# broker-absent and the exit is NOT 75.
# Create a REAL unix socket: a detached python holder binds it and stays alive
# for the duration (bash cannot create sockets; a foreground python would
# close the socket on exit and -S on a closed-but-unlinked path fails). Written
# as a script file + setsid nohup so no job-control/heredoc interaction with
# set -e can silently kill the suite.
# AF_UNIX binds cap at 108 path bytes; the suite's workdir exceeds that, so
# the live socket lives at a SHORT path under /tmp (unique per run, cleaned
# with the suite). The preflight takes its socket path explicitly, so this
# stays fully controlled.
# A real unix socket at a SHORT absolute path (AF_UNIX limit is 108 bytes,
# so the repo-deep SOCK_DIR cannot host it). The name is composed, not
# `mktemp -u`: the CI image's mktemp dialect rejects that invocation
# (pipeline 2562: "mktemp: : Invalid argument"), and no pre-existing file is
# wanted anyway — the holder binds it fresh.
LIVE_SOCK="/tmp/mosaic-preflight-$RANDOM-$$.sock"
trap 'rm -f "$LIVE_SOCK"' EXIT
rm -f "$SOCK_DIR/live.sock" "$LIVE_SOCK"
cat > "$SOCK_DIR/holder.py" <<'PY'
import socket, sys, time
path = sys.argv[1]
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(path)
s.listen(1)
time.sleep(120)
PY
python3 "$SOCK_DIR/holder.py" "$LIVE_SOCK" >/dev/null 2>"$SOCK_DIR/holder.err" &
HOLDER_PID=$!
# Wait for the socket object to exist (bind is near-instant, but do not race it).
for _ in $(seq 1 50); do
[ -S "$LIVE_SOCK" ] && break
sleep 0.1
done
if [ ! -S "$LIVE_SOCK" ]; then
echo "FAIL: could not create live socket fixture (holder pid $HOLDER_PID)" >&2
ps -p "$HOLDER_PID" -o pid,stat,cmd --no-headers >&2 || echo "(holder exited)" >&2
cat "$SOCK_DIR/holder.err" >&2 || true
exit 1
fi
: > "$LOG_FILE"
set +e
out=$(run_session_script "" MOSAIC_LEASE_BROKER_SOCKET="$LIVE_SOCK" 2>"$WORK_DIR/stderr-2.tmp")
rc=$?
set -e
# The preflight PASSED if the failure (whatever later stage refused) is NOT
# the broker refusal, and tmux was reached or a later precondition named
# something else.
err2=$(cat "$WORK_DIR/stderr-2.tmp")
assert_not_contains "live socket must not refuse broker-absent" "$err2" "broker-absent"
if [[ "$rc" == "75" ]]; then
echo "FAIL: live socket — preflight still refused (exit 75) with a live socket" >&2
fail=1
fi
# ─── 3. Explicit socket env wins over XDG default. ─────────────────────────
set +e
out=$(run_session_script "" XDG_RUNTIME_DIR="$SOCK_DIR/no-runtime-here" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent2.sock" 2>"$WORK_DIR/stderr-3.tmp")
rc=$?
set -e
assert "explicit env wins (exit 75)" "75" "$rc"
assert_contains "explicit env path named" "$(cat "$WORK_DIR/stderr-3.tmp")" "$SOCK_DIR/absent2.sock"
# ─── 4. --stop is not fenced on the broker. ────────────────────────────────
: > "$LOG_FILE"
set +e
out=$(run_session_script "--stop" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent3.sock" 2>"$WORK_DIR/stderr-4.tmp")
rc=$?
set -e
err4=$(cat "$WORK_DIR/stderr-4.tmp")
assert_not_contains "--stop must not refuse broker-absent" "$err4" "broker-absent"
if [[ "$rc" == "75" ]]; then
echo "FAIL: --stop — exit 75 means teardown was fenced on the broker" >&2
fail=1
fi
kill "$HOLDER_PID" 2>/dev/null || true
if [[ "$fail" -eq 0 ]]; then
echo "start-agent-session lease-broker preflight regression passed"
fi
exit "$fail"
@@ -1,161 +0,0 @@
#!/usr/bin/env bash
# Hermetic regression for mint-seat-credential.sh: mock curl on PATH, sandboxed
# brain home, no tea, no network, no real credentials.
#
# Pins:
# M1 the seat slot is written from the mint RESPONSE (token, granted scopes,
# principal), each file mode 600, and the identity check passes.
# M2 the admin token is read from MOSAIC_ADMIN_SEAT's slot, never hardcoded;
# a missing admin token is reported per instance and exits nonzero.
# M3 MOSAIC_GITEA_INSTANCES limits which instances are touched, and the URL
# override MOSAIC_GITEA_URL_<INSTANCE> is honoured.
# M4 no admin seat configured is a usage error (rc=3), nothing written.
# M5 the admin token value never appears on stdout or stderr.
# M6 secrets never touch argv: no Authorization header, no -u user:pass, no
# inline --data JSON carrying the password, on any curl invocation; auth
# travels in --config files and bodies in --data @files (#1343 class,
# rev-security-01 review 259 blocker).
# M7 the scopes record discriminates: a requested-but-not-granted scope
# (write:issue) must be ABSENT from .scopes — the pin is on the RESPONSE,
# and a mutant writing the requested set fails here (both reviewers).
# M8 hyphenated instance names resolve their override through the underscore
# variable, matching seat-logins.sh (SF3).
# M9 MOSAIC_SEAT_EMAIL_DOMAIN is required: unset is a usage error (rc=3),
# nothing written (framework-PR firewall answer).
set -euo pipefail
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
SANDBOX="$WORK_ROOT/mint-seat-credential-test-$$"
MOCK_BIN="$SANDBOX/bin"; BRAIN="$SANDBOX/brain"; CALLS="$SANDBOX/calls.log"
cleanup() { rm -rf "$SANDBOX"; }
trap cleanup EXIT
fail() { echo "FAIL: $*"; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="$SCRIPT_DIR/mint-seat-credential.sh"
[ -f "$TARGET" ] || fail "mint-seat-credential.sh not found beside this test"
mkdir -p "$MOCK_BIN" "$BRAIN/fleet/agents/admin-seat/secrets" || fail "setup: sandbox"
: > "$CALLS"
ADMIN_TOKEN_VALUE="admin-token-value-sentinel-4491"
printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token"
chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token"
# A PATH with only the mock bin plus the system tools the script needs, and no tea.
SYS_BIN="$SANDBOX/sys"; mkdir -p "$SYS_BIN"
for t in bash sed cat mktemp openssl tr head python3 sort printf chmod mkdir rm dirname grep stat; do
p="$(command -v "$t" 2>/dev/null || true)"; [ -n "$p" ] && ln -s "$p" "$SYS_BIN/$t"
done
export PATH="$MOCK_BIN:$SYS_BIN" CALLS
export MOSAIC_BRAIN_HOME="$BRAIN"
export MOSAIC_GITEA_URL_ALPHA="https://alpha.example.test"
export MOSAIC_SEAT_EMAIL_DOMAIN="seats.example.test"
unset MOSAIC_ADMIN_SEAT MOSAIC_GITEA_INSTANCES
# --- mock curl: records method + URL + a REDACTED auth marker, answers minting --
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
method=GET; url=""; out=""; wcode=0; auth=""; body=""
while [ $# -gt 0 ]; do
case "$1" in
-X) method="$2"; shift 2 ;;
-o) out="$2"; shift 2 ;;
-w) wcode=1; shift 2 ;;
--config)
if grep -q 'Authorization: token' "$2" 2>/dev/null; then auth="${auth}token,"; fi
if grep -q '^user = ' "$2" 2>/dev/null; then auth="${auth}user,"; fi
shift 2 ;;
--data)
case "$2" in
@*) body="@file" ;;
*) body="inline" ;;
esac
shift 2 ;;
-H|-u) shift 2 ;;
http*) url="$1"; shift ;;
*) shift ;;
esac
done
printf '%s %s auth=%s body=%s\n' "$method" "$url" "${auth:-NONE}" "$body" >> "$CALLS"
emit() { if [ -n "$out" ]; then printf '%s' "$1" > "$out"; else printf '%s' "$1"; fi; }
case "$method $url" in
"GET "*/api/v1/users/newseat) exit 22 ;; # 404 under -f: account does not exist yet
"POST "*/api/v1/admin/users) emit '{}'; exit 0 ;;
"POST "*/api/v1/users/newseat/tokens) emit '{"id":9,"name":"mosaic-seat","sha1":"minted-token-7f3a","scopes":["read:user","write:repository"]}'
[ "$wcode" = 1 ] && printf '201'; exit 0 ;;
"GET "*/api/v1/user) emit '{"login":"newseat"}'; exit 0 ;;
*) emit '{}'; exit 0 ;;
esac
EOF
chmod +x "$MOCK_BIN/curl"
[ "$(command -v curl)" = "$MOCK_BIN/curl" ] || fail "setup: curl does not resolve to the mock"
command -v tea >/dev/null 2>&1 && fail "setup: tea must be absent from the sandbox PATH"
run() { bash "$TARGET" "$@" >"$SANDBOX/out" 2>"$SANDBOX/err"; echo $?; }
# M4: no admin seat configured.
rc=$(run newseat)
[ "$rc" = 3 ] || fail "M4: expected rc=3 without an admin seat, got $rc: $(cat "$SANDBOX/err")"
grep -q 'MOSAIC_ADMIN_SEAT' "$SANDBOX/err" || fail "M4: error does not name MOSAIC_ADMIN_SEAT"
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M4: a token was written without an admin seat"
# M9: email domain is required, unset is a usage error, nothing written.
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha MOSAIC_SEAT_EMAIL_DOMAIN= run newseat)
[ "$rc" = 3 ] || fail "M9: expected rc=3 with no email domain, got $rc: $(cat "$SANDBOX/err")"
grep -q 'MOSAIC_SEAT_EMAIL_DOMAIN' "$SANDBOX/err" || fail "M9: error does not name MOSAIC_SEAT_EMAIL_DOMAIN"
[ ! -s "$CALLS" ] || fail "M9: API called without an email domain"
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M9: token written without an email domain"
# M1 + M3 + M5: mint on the single configured instance.
: > "$CALLS"
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat)
[ "$rc" = 0 ] || fail "M1: expected rc=0, got $rc: $(cat "$SANDBOX/err")"
SLOT="$BRAIN/fleet/agents/newseat/secrets"
[ "$(cat "$SLOT/gitea-alpha-newseat.token")" = "minted-token-7f3a" ] || fail "M1: token file not written from the mint response"
grep -q 'write:repository' "$SLOT/gitea-alpha-newseat.scopes" || fail "M1: scopes file not written from the response"
[ "$(cat "$SLOT/gitea-alpha-newseat.principal")" = "newseat" ] || fail "M1: principal file wrong"
for suf in token scopes principal; do
m=$(stat -c '%a' "$SLOT/gitea-alpha-newseat.$suf"); [ "$m" = 600 ] || fail "M1: $suf is mode $m, expected 600"
done
grep -q 'alpha: create, minted, GET /user -> newseat' "$SANDBOX/out" || fail "M1: success line missing: $(cat "$SANDBOX/out")"
grep -q 'https://alpha.example.test/api/v1/admin/users' "$CALLS" || fail "M3: URL override not honoured: $(cat "$CALLS")"
if grep -q 'usc\|mosaicstack' "$CALLS"; then fail "M3: an instance outside MOSAIC_GITEA_INSTANCES was touched: $(cat "$CALLS")"; fi
grep -q 'tea not on PATH' "$SANDBOX/err" || fail "tea-absent path should warn, not fail: $(cat "$SANDBOX/err")"
if grep -q "$ADMIN_TOKEN_VALUE" "$SANDBOX/out" "$SANDBOX/err" "$CALLS"; then fail "M5: admin token value leaked to output or call log"; fi
# M7: scopes pin discriminates — requested-but-not-granted scope is ABSENT.
if grep -q 'write:issue' "$SLOT/gitea-alpha-newseat.scopes"; then
fail "M7: write:issue appears in .scopes — the record is the REQUESTED set, not the response"
fi
# M6: no secret ever travels argv — every call authenticates via --config
# (token header or user= basic-auth directive) and bodies go as --data @file.
while IFS= read -r line; do
case "$line" in
*auth=NONE*) fail "M6: unauthenticated call: $line" ;;
*body=inline*) fail "M6: inline body (secret in argv risk): $line" ;;
esac
done < "$CALLS"
[ "$(grep -c 'auth=token' "$CALLS")" -eq 3 ] || fail "M6: expected exactly 3 token-auth calls (exists-check, admin write, verify), got: $(cat "$CALLS")"
grep -q 'auth=user' "$CALLS" || fail "M6: mint call did not use the user= directive: $(cat "$CALLS")"
# M8: hyphenated instance name resolves its override via the underscore variable.
printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token"
chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token"
export MOSAIC_GITEA_URL_MY_INST="https://myinst.example.test"
: > "$CALLS"; rm -rf "$BRAIN/fleet/agents/newseat"
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=my-inst run newseat)
[ "$rc" = 0 ] || fail "M8: hyphenated instance mint failed rc=$rc: $(cat "$SANDBOX/err")"
grep -q 'https://myinst.example.test/api/v1/admin/users' "$CALLS" || fail "M8: hyphen override (MY_INST) not honoured: $(cat "$CALLS")"
unset MOSAIC_GITEA_URL_MY_INST
# M2: admin token missing for the instance is reported, rc=1, nothing written.
rm -rf "$BRAIN/fleet/agents/newseat"
: > "$CALLS"
rc=$(MOSAIC_ADMIN_SEAT=other-admin MOSAIC_GITEA_INSTANCES=alpha run newseat)
[ "$rc" = 1 ] || fail "M2: expected rc=1 with no admin token, got $rc"
grep -q "no admin token for seat 'other-admin'" "$SANDBOX/err" || fail "M2: missing-admin-token not reported: $(cat "$SANDBOX/err")"
[ ! -s "$CALLS" ] || fail "M2: API was called without an admin token: $(cat "$CALLS")"
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M2: token written without an admin token"
echo "mint-seat-credential regression harness passed"
+1 -59
View File
@@ -30,9 +30,7 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio
### `--login` override
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`).
**With no `--login`, there is no tea lookup at all.** The acting credential is the calling identity's own, resolved by `get_gitea_token` (see "Per-agent Gitea identity" below), and there is deliberately no fallback from it. These wrappers previously _guessed_ a login from the repo host and looked that guess up in the tea config; on a shared-account host the guess resolved to the shared login, so an unqualified call authored its write as that account rather than as the caller. Since `get_gitea_token_for_login` matches by login **name** and performs no authentication check, a dead shared credential still resolved at rc=0 and the identity-aware resolver was never reached. A caller passing no `--login` is asking to act as itself, so `--login` is now the only route to the tea store (#1351). The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
@@ -102,36 +100,6 @@ of their own — `MOSAIC_GIT_IDENTITY=<id>` with a provisioned slot. There is de
environment variable that restores the fallback; one would reintroduce exactly the
substitution this removes.
### The tea path: login resolution (#1356)
The wrappers that go through `tea` (`issue-list.sh`, `pr-list.sh`, `pr-view.sh`,
`lane-brief.sh`, and the tea half of `issue-close.sh`) cannot use a token directly: tea
0.14 only acts as a **login** already stored in `~/.config/tea/config.yml`. Those wrappers
therefore resolve a login name, not a token, and the resolution follows the same identity
as above:
1. Resolve the identity (`MOSAIC_GIT_IDENTITY`, then `git config mosaic.gitIdentity`).
2. Derive the Gitea instance from the repo host (`git.mosaicstack.dev``mosaicstack`,
`git.uscllc.com``usc`), or from the owner when `--repo owner/name` is given.
3. The canonical login is `<instance>-<identity>`. If tea has it, that login acts.
4. If the identity is set but that login is missing, the wrapper **fails closed**: nonzero
exit, empty stdout, and a stderr line naming the login it wanted and the source of the
identity. When `tea` itself is not installed the message says so instead, since "no such
login" would send the reader to create a login they cannot create.
5. With **no identity set**, the old host-default behaviour is unchanged (first login
configured for that host, else the API fallback).
Step 4 replaced a fallback that picked any login configured for the host, which meant a
seat with no login of its own silently acted as whichever seat had configured one. That
satisfied the author≠reviewer gate on paper while one actor held both names.
**Provisioning the logins.** `tools/fleet/seat-logins.sh` projects each seat's token from
its secrets store into tea's config under the canonical name. tea's config is a derived
cache of the secrets store: regenerate it with the script, never hand-edit it. Run it with
`--seat <seat>` for one seat (all seats when omitted), dry-run by default, `--apply` to write. A hand-made
alias holding a seat's token blocks its canonical name (tea refuses one token under two
names); `--adopt` renames it.
### Enabling it for a clone
The framework installer syncs `git-credential-mosaic` to
@@ -157,32 +125,6 @@ otherwise careful never to touch. Because identity is already resolved per-workt
(`mosaic.gitIdentity`), the correct granularity for registering the helper is per-clone
too, so a documented manual step is the right shape here, not a global auto-write.
### Running these tests
`MOSAIC_GIT_IDENTITY` is inherited into each test's sandbox `HOME`, and **the tests disagree
about which value they need**, so no single ambient value passes all 29. Measured on `next` at
`a480ee83`, two full passes differing only in that variable:
| tests | identity exported | identity unset |
| ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -------------- |
| `gitea-login-resolution`, `issue-comment-readback`, `issue-create-interactive-auth`, `pr-edit`, `pr-merge-gitea-empty-uid`, `pr-metadata-gitea` | **fail** | pass |
| `issue-close-fail-closed` | pass | **fail** |
| remaining 22 | pass | pass |
The six fail because inside a sandbox `HOME` the identity has no `fleet/agents/<id>/`
directory, so it is classified as a **service identity**, its store is unpopulated, and the
resolver correctly refuses with `Refusing to borrow another slot's token`. That is the
documented fail-closed behaviour above, reached from a state the test never intended.
`issue-close-fail-closed` is the mirror image: it asserts that no comment POST is attempted, so
it needs an identity resolving to an empty slot, and with the variable unset the shared account
answers and the POST goes through.
These read as wrapper regressions rather than as environment. Two seats independently
misdiagnosed them as a patch defect while reviewing #1352. Until each test controls its own
value (#1353), `env -u MOSAIC_GIT_IDENTITY` is the closest thing to a clean run at 28/29, with
`issue-close-fail-closed` the expected failure — and **"the suite passes" is not a statement
anyone can make here without naming the ambient value that produced it.**
### PowerShell parity
`detect-platform.ps1`'s Gitea wrappers authenticate through `tea` CLI logins
@@ -180,66 +180,6 @@ raise SystemExit(1)
PY
}
# Map a host to the instance prefix used in canonical tea login names
# ("<instance>-<identity>"). This deliberately mirrors the _idpfx case in
# get_gitea_token(): the two credential paths must agree on what a host is called,
# or an agent authenticates as itself on one path and as somebody else on the other.
gitea_instance_for_host() {
case "${1:-}" in
git.uscllc.com) echo usc ;;
git.mosaicstack.dev) echo mosaicstack ;;
*) return 1 ;;
esac
}
# Resolve the acting git identity, same precedence as get_gitea_token() step 0.
# Prints "<identity>\t<source>" so the caller can name the source in an error.
resolve_git_identity() {
local ident src
ident="${MOSAIC_GIT_IDENTITY:-}"
src="MOSAIC_GIT_IDENTITY"
if [[ -z "$ident" ]]; then
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
src="git config mosaic.gitIdentity"
fi
[[ -n "$ident" ]] || return 1
printf '%s\t%s\n' "$ident" "$src"
}
# Map a repo owner to an instance. Used only by the --repo override path, which
# has an owner and no host. Previously lived inline in lane-brief.sh; one copy so
# the two override callers cannot drift apart.
gitea_instance_for_owner() {
local owner="${1:-}"
owner="${owner%%/*}"
case "$owner" in
usc|USC) echo usc ;;
mosaicstack|mosaic) echo mosaicstack ;;
*) return 1 ;;
esac
}
# Does a login of this name exist at all? The --repo override path cannot check
# host agreement, because it has no host.
tea_login_exists() {
local login_name="$1"
local logins_json
command -v tea >/dev/null 2>&1 || return 1
logins_json=$(tea login list --output json 2>/dev/null) || return 1
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" <<'PY_INNER'
import json, os, sys
want = sys.argv[1]
try:
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
except Exception:
raise SystemExit(1)
for login in logins if isinstance(logins, list) else []:
if str(login.get("name") or login.get("Name") or "") == want:
raise SystemExit(0)
raise SystemExit(1)
PY_INNER
}
tea_login_matches_host() {
local login_name="$1" host="$2"
local logins_json
@@ -336,40 +276,6 @@ get_gitea_login_for_host() {
fi
fi
# IDENTITY LADDER (#1356). Below this point the old code took the FIRST login
# matching the host, which is not an identity — with 43 logins on a fleet host,
# ~22 match one server, so a seat with no login of its own silently acted as
# whichever happened to be first in ~/.config/tea/config.yml. Gate 16 depends on
# author != reviewer, and borrowing satisfies it mechanically while violating it
# in fact. The token path already refuses to borrow; this is the same refusal.
#
# Enforced ONLY when an identity is resolvable, exactly like get_gitea_token():
# no identity means a human at a terminal, and neither path enforces there.
local ident ident_src inst canon
if IFS=$'\t' read -r ident ident_src < <(resolve_git_identity); then
if inst=$(gitea_instance_for_host "$host"); then
canon="${inst}-${ident}"
if tea_login_matches_host "$canon" "$host"; then
echo "$canon"
return 0
fi
# Say which of the two it is. "No such login" when tea is simply not
# installed is a diagnosis of a cause that was never checked, and it
# sends the reader off to create a login they cannot create.
if ! command -v tea >/dev/null 2>&1; then
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but tea is not installed," >&2
echo " so no login can be resolved. Refusing to guess an identity." >&2
return 1
fi
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but no tea login named '$canon' exists." >&2
echo " Refusing to borrow another login. Acting as a different identity would satisfy gate 16 mechanically while violating it." >&2
echo " Create it with: ~/.config/mosaic/tools/fleet/seat-logins.sh --apply --seat $ident" >&2
return 1
fi
# Identity known but the host is not a Mosaic instance. Fall through: the
# canonical name is undefined for it, so there is nothing to enforce.
fi
login=$(find_tea_login_for_host "$host" || true)
if [[ -n "$login" ]]; then
echo "$login"
@@ -445,49 +351,14 @@ raise SystemExit(1)
PY
}
# Resolve a login for an explicit --repo override, which supplies an owner and no
# host. Takes "owner" or "owner/repo".
#
# The old body fell through to get_default_tea_login(), which returns the
# default-marked login or, failing that, the first login of ANY host — arbitrary
# identity, chosen by config file order. That is the #1356 fail-open in its worst
# form, because unlike the host path it does not even constrain the server.
get_gitea_login_for_repo_override() {
local owner="${1:-}"
local login ident ident_src inst canon
local login
if [[ -n "${GITEA_LOGIN:-}" ]]; then
echo "$GITEA_LOGIN"
return 0
fi
if IFS=$'\t' read -r ident ident_src < <(resolve_git_identity); then
if inst=$(gitea_instance_for_owner "$owner"); then
canon="${inst}-${ident}"
if tea_login_exists "$canon"; then
echo "$canon"
return 0
fi
# Same split as the host path above (#1357 S1): a missing tea binary
# is not a missing login, and the "create it with" advice cannot be
# followed without tea.
if ! command -v tea >/dev/null 2>&1; then
echo "Error: git identity '$ident' (via $ident_src) requested for owner '${owner%%/*}', but tea is not installed," >&2
echo " so no login can be resolved. Refusing to guess an identity." >&2
return 1
fi
echo "Error: git identity '$ident' (via $ident_src) has no tea login '$canon' for owner '${owner%%/*}'." >&2
echo " Create it with: ~/.config/mosaic/tools/fleet/seat-logins.sh --apply --seat $ident" >&2
return 1
fi
echo "Error: git identity '$ident' (via $ident_src) is set, but owner '${owner%%/*}' maps to no known instance," >&2
echo " so the login name cannot be derived. Refusing to fall back to an arbitrary login." >&2
echo " Set GITEA_LOGIN to name the login explicitly." >&2
return 1
fi
# No identity: a human at a terminal. Unchanged, and the same place the token
# path stops enforcing.
login=$(get_default_tea_login || true)
if [[ -n "$login" ]]; then
echo "$login"
@@ -102,17 +102,9 @@ gitea_resolve_api_for_login() {
return 1
}
else
# NO --login: the acting credential is this identity's own token and there
# is deliberately no tea-config fallback. get_gitea_token_for_login matches
# by login NAME and performs no authentication check, and with no --login
# that name was a HOST GUESS resolving to a shared account. A live shared
# token would therefore have authored every seat's comment as that
# account, making Gate-16 author-is-not-reviewer unenforceable fleet-wide;
# a dead one is only what made the defect visible. get_gitea_token fails
# loud on a fleet host when no identity resolves, and that refusal is the
# correct outcome, not a case to fall back from.
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: no Gitea credential resolved for the acting identity on host '$host' (comment write/read-back). Set MOSAIC_GIT_IDENTITY=<agent-id>, or pass --login <name> to use a named tea credential." >&2
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&2
return 1
}
fi
@@ -343,12 +335,15 @@ if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
# A --login override selects a NAMED tea credential and is the only way to
# reach the tea store. With no --login there is deliberately no guess: the
# comment is attributed to this identity's own credential, resolved by
# gitea_resolve_api_for_login. The guess this replaced named a SHARED
# account, selecting an identity the caller never asked to act as.
# Resolve the login this comment should be attributed to: the --login
# override when given, otherwise the detected default for this repo's host.
# A --login override always wins. Otherwise name this repo host's login only
# as a best effort: the login name merely selects a per-login token, and
# gitea_resolve_api_for_login falls back to the host credential
# (get_gitea_token) when no tea login is named, so the default credential
# still resolves even when the host tea has no matching login entry.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive the
# acting identity from that SAME credential (GET /user). The write below and
@@ -99,8 +99,8 @@ case "$PLATFORM" in
;;
gitea)
if [[ -n "$REPO_OVERRIDE" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
exit 1
}
else
@@ -1,5 +1,5 @@
#!/bin/bash
# issue-view.sh - View issue details, including comments, on GitHub or Gitea
# issue-view.sh - View issue details on GitHub or Gitea
# Usage: issue-view.sh -i <issue_number>
set -e
@@ -28,47 +28,11 @@ gitea_issue_view_api() {
}
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
local -a curl_args=(-fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}")
if ! command -v python3 >/dev/null 2>&1; then
# No renderer: raw JSON is all this path can give. Comments are a
# second resource, so fetch them too rather than only the count.
curl "${curl_args[@]}" "$url"
curl "${curl_args[@]}" "${url}/comments"
return
if command -v python3 >/dev/null 2>&1; then
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -m json.tool
else
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
fi
# Render issue + comments as text (#1357 F2). The old fallback dumped the
# issue JSON, which carries only a comment COUNT, so every comment body was
# invisible on this path and the wrapper could never show what
# `tea issues --comments` shows.
{
curl "${curl_args[@]}" "$url"
echo
echo "__MOSAIC_COMMENTS__"
curl "${curl_args[@]}" "${url}/comments"
} | python3 -c '
import json, sys
raw = sys.stdin.read()
issue_raw, _, comments_raw = raw.partition("__MOSAIC_COMMENTS__")
issue = json.loads(issue_raw)
comments = json.loads(comments_raw) if comments_raw.strip() else []
print("#%s %s" % (issue["number"], issue["title"]))
print("State: %s Author: %s Created: %s" % (issue["state"], issue["user"]["login"], issue["created_at"]))
labels = ", ".join(l["name"] for l in issue.get("labels") or [])
if labels:
print("Labels: " + labels)
if issue.get("milestone"):
print("Milestone: " + issue["milestone"]["title"])
print("URL: " + issue["html_url"])
print()
print(issue.get("body") or "(no body)")
if comments:
print()
print("--- Comments (%d) ---" % len(comments))
for c in comments:
print()
print("[%s at %s]" % (c["user"]["login"], c["created_at"]))
print(c.get("body") or "")
'
}
while [[ $# -gt 0 ]]; do
@@ -82,8 +46,6 @@ while [[ $# -gt 0 ]]; do
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo ""
echo "Comments are always included (tea --comments / Gitea API /comments)."
echo " -h, --help Show this help"
exit 0
;;
@@ -105,30 +67,11 @@ if [[ "$PLATFORM" == "github" ]]; then
gh issue view "$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
if command -v tea >/dev/null 2>&1; then
# --comments is what makes tea print the comment bodies (#1357 F3).
# Without it tea prompts for them interactively, which in a
# non-interactive wrapper means they are silently never shown.
tea_err=$(mktemp)
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args) --comments 2>"$tea_err"; then
rm -f "$tea_err"
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args); then
exit 0
fi
# Name the cause tea actually reported, not a guessed one (#1357 F1/F4).
# tea reads the cwd's git config before honouring --repo; a repo with
# extensions.worktreeconfig=true makes it exit 1 with a
# repositoryformatversion error. That is a git-config condition, not a
# credential one. The old path printed the REVOKED OR STALE TOKEN note
# here unconditionally, which sent readers to rotate a token that was fine.
if grep -q 'repositoryformatversion' "$tea_err"; then
echo "Warning: tea cannot read this repo's git config (extensions.worktreeconfig); not a credential problem. Using Gitea API fallback." >&2
elif grep -q 'user does not exist' "$tea_err"; then
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
else
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
fi
sed 's/^/ tea: /' "$tea_err" >&2
rm -f "$tea_err"
fi
gitea_issue_view_api
else
@@ -49,28 +49,12 @@ if [[ -z "$LOGIN" ]]; then
if [[ -n "${GITEA_LOGIN:-}" ]]; then
LOGIN="$GITEA_LOGIN"
else
# #1356: the owner-derived map below picks a SHARED login (bare `usc` /
# `mosaicstack`). On a seat that is borrowing another identity, which is
# exactly what gate 16 forbids. So the identity ladder goes first and the
# map is only the no-identity fallback (a human at a terminal), which is
# where the token path stops enforcing too.
if LOGIN="$(get_gitea_login_for_repo_override "$REPO")"; then
:
elif resolve_git_identity >/dev/null 2>&1; then
# A git identity IS set and the ladder still could not resolve a login.
# The named reason is already on stderr. Falling through to the map here
# would hand this seat a SHARED login (bare `usc` / `mosaicstack`) — the
# identity-borrowing #1356 exists to stop. Fail closed instead.
exit 2
else
# No identity: a human at a terminal. Owner-derived map, unchanged. This
# is the same point at which the token path stops enforcing.
case "${REPO%%/*}" in
usc|USC) LOGIN=usc ;;
mosaicstack|mosaic) LOGIN=mosaicstack ;;
*) LOGIN="$(get_gitea_login_for_repo_override 2>/dev/null || true)" ;;
esac
fi
fi
fi
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
@@ -94,8 +94,8 @@ case "$PLATFORM" in
;;
gitea)
if [[ -n "$REPO_OVERRIDE" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
exit 1
}
else
@@ -12,7 +12,7 @@
# concurrent record cannot masquerade as this write and a no-op fails closed.
#
# --login override: the default login is resolved from the local tea login list
# for this repo's host from the acting identity's own credential. Pass --login <name> to
# for this repo's host (get_gitea_login_for_host). Pass --login <name> to
# override it for this invocation only. The REST write, the /user identity read,
# and every read-back are ALL performed with the token of the EFFECTIVE login,
# so the write and its verification bind to the same identity.
@@ -372,17 +372,9 @@ gitea_resolve_api_for_login() {
return 1
}
else
# NO --login: the acting credential is this identity's own token and there
# is deliberately no tea-config fallback. get_gitea_token_for_login matches
# by login NAME and performs no authentication check, and with no --login
# that name was a HOST GUESS resolving to a shared account. A live shared
# token would therefore have authored every seat's review as that
# account, making Gate-16 author-is-not-reviewer unenforceable fleet-wide;
# a dead one is only what made the defect visible. get_gitea_token fails
# loud on a fleet host when no identity resolves, and that refusal is the
# correct outcome, not a case to fall back from.
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: no Gitea credential resolved for the acting identity on host '$host' (review write/read-back). Set MOSAIC_GIT_IDENTITY=<agent-id>, or pass --login <name> to use a named tea credential." >&2
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
return 1
}
fi
@@ -706,7 +698,7 @@ if [[ "$PLATFORM" == "github" ]]; then
elif [[ "$PLATFORM" == "gitea" ]]; then
case $ACTION in
approve)
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
@@ -714,13 +706,15 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override selects a NAMED tea credential and is the only
# way to reach the tea store. With no --login there is deliberately no
# guess: gitea_resolve_api_for_login resolves this identity's own token.
# The guess this replaced named a SHARED account, selecting an identity
# the caller never asked to act as. The single resolved token is then
# used for the write, the /user identity, and the read-back.
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive
# the acting identity from that SAME credential so the review submit
# and its read-back verify against the identity that performed them.
@@ -741,7 +735,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: Comment required for request-changes"
exit 1
fi
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
@@ -749,13 +743,15 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override selects a NAMED tea credential and is the only
# way to reach the tea store. With no --login there is deliberately no
# guess: gitea_resolve_api_for_login resolves this identity's own token.
# The guess this replaced named a SHARED account, selecting an identity
# the caller never asked to act as. The single resolved token is then
# used for the write, the /user identity, and the read-back.
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
@@ -770,7 +766,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: Comment required"
exit 1
fi
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
@@ -778,13 +774,15 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override selects a NAMED tea credential and is the only
# way to reach the tea store. With no --login there is deliberately no
# guess: gitea_resolve_api_for_login resolves this identity's own token.
# The guess this replaced named a SHARED account, selecting an identity
# the caller never asked to act as. The single resolved token is then
# used for the write, the /user identity, and the read-back.
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
@@ -59,8 +59,8 @@ if [[ "$PLATFORM" == "github" ]]; then
gh pr view "$PR_NUMBER" --repo "$REPO_INFO"
elif [[ "$PLATFORM" == "gitea" ]]; then
if [[ -n "$REPO_OVERRIDE" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
exit 1
}
else
@@ -110,20 +110,8 @@ chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
run_in_repo() {
(
# HERMETICITY, second half (#1356). The empty repo-local `mosaic.gitIdentity`
# above pins the git-config route into identity resolution. It does NOT pin
# the environment route, and MOSAIC_GIT_IDENTITY is checked FIRST — so on any
# provisioned seat, where the launcher exports it, this suite failed before
# any change: rc=1 as-is, rc=0 under `env -u MOSAIC_GIT_IDENTITY`, one
# variable. A suite that cannot run on a seat cannot guard this code for the
# agents that actually run it.
#
# Unset rather than set empty: an empty MOSAIC_GIT_IDENTITY and an absent one
# take different branches in resolve_git_identity(), and the case under test
# is "no identity at all".
cd "$REPO_DIR"
env -u MOSAIC_GIT_IDENTITY \
PATH="${_SANDBOX_BIN:-$BIN_DIR}:$PATH" \
PATH="$BIN_DIR:$PATH" \
HOME="$HOME_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
@@ -319,11 +307,14 @@ SH
chmod +x "$BIN_DIR2/tea"
run_in_repo2() {
# Same sandbox as run_in_repo, different mock tea (BIN_DIR2 defines a
# mosaicstack login). This MUST delegate rather than re-implement: it was a
# copy once, and the copy silently missed the MOSAIC_GIT_IDENTITY unset, so
# the suite kept failing on a seat after run_in_repo was already fixed.
_SANDBOX_BIN="$BIN_DIR2" run_in_repo "$@"
(
cd "$REPO_DIR"
PATH="$BIN_DIR2:$PATH" \
HOME="$HOME_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
)
}
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
@@ -349,151 +340,6 @@ if [[ "$override_wins" != "mosaicstack" ]]; then
fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
# ---------------------------------------------------------------------------
# #1356: the git-identity ladder. A seat declares who it is (MOSAIC_GIT_IDENTITY
# or `git config mosaic.gitIdentity`); resolution must use THAT seat's login and
# must REFUSE to borrow another one when it is absent. Silently borrowing
# satisfies gate 16 mechanically (a review exists) while violating it (the
# reviewer and the author are the same actor under two names).
#
# BIN_DIR3 mocks a tea that holds a canonical per-seat login, which is what a
# projected seat looks like. BIN_DIR2 (mosaicstack only) is reused as the
# "seat has no login" case — no third mock needed for the negative branch.
# ---------------------------------------------------------------------------
BIN_DIR3="$WORK_DIR/bin3"
mkdir -p "$BIN_DIR3"
cp "$BIN_DIR/curl" "$BIN_DIR3/curl"
cat > "$BIN_DIR3/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"ci-bot"},
{"name":"mosaicstack-testseat","url":"https://git.mosaicstack.dev","user":"testseat"},
{"name":"usc","url":"https://git.uscllc.com","user":"ci-bot"}
]
JSON
exit 0
fi
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
exit 0
SH
chmod +x "$BIN_DIR3/tea"
run_in_repo3() { _SANDBOX_BIN="$BIN_DIR3" run_in_repo "$@"; }
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
# Branch 1 (host path): identity set, canonical login PRESENT -> that login wins
# over the shared `mosaicstack` one, which is what host-matching alone would pick.
ladder_hit=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
')
if [[ "$ladder_hit" != "mosaicstack-testseat" ]]; then
echo "Expected identity ladder to select 'mosaicstack-testseat'; got '$ladder_hit'" >&2
exit 1
fi
# CONTROL for branch 1: the same mock, no identity, must still resolve by host.
# Without this, branch 1 passing proves nothing about the ladder specifically --
# it would also pass if the code just picked the last matching login.
ladder_none=$(run_in_repo3 bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
')
if [[ "$ladder_none" != "mosaicstack" ]]; then
echo "Expected no-identity host resolution to stay 'mosaicstack'; got '$ladder_none'" >&2
exit 1
fi
# Branch 2 (host path): identity set, canonical login ABSENT -> fail closed with a
# named error. Two assertions, and they are not the same one twice: rc!=0 proves
# it refused, and the ABSENCE of any login on stdout proves it did not borrow the
# `mosaicstack` login that is sitting right there matching the host.
ladder_err=$(run_in_repo2 env MOSAIC_GIT_IDENTITY=testseat bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
' 2>&1 1>/dev/null || true)
ladder_out=$(run_in_repo2 env MOSAIC_GIT_IDENTITY=testseat bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
' 2>/dev/null || true)
if [[ -n "$ladder_out" ]]; then
echo "Identity ladder BORROWED login '$ladder_out' instead of failing closed" >&2
exit 1
fi
if ! grep -q "mosaicstack-testseat" <<<"$ladder_err"; then
echo "Expected the error to name the login it wanted; got: $ladder_err" >&2
exit 1
fi
# Branch 3: `git config mosaic.gitIdentity` is the second rung and must work when
# the environment variable is absent -- a seat may be configured either way.
git -C "$REPO_DIR" config mosaic.gitIdentity testseat
ladder_gitcfg=$(run_in_repo3 bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
')
git -C "$REPO_DIR" config --unset mosaic.gitIdentity || true
if [[ "$ladder_gitcfg" != "mosaicstack-testseat" ]]; then
echo "Expected git-config identity rung to select 'mosaicstack-testseat'; got '$ladder_gitcfg'" >&2
exit 1
fi
# Branch 4 (--repo override path): same rule, owner-derived instead of host-derived.
override_ladder=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_repo_override mosaicstack/stack
')
if [[ "$override_ladder" != "mosaicstack-testseat" ]]; then
echo "Expected --repo override ladder to select 'mosaicstack-testseat'; got '$override_ladder'" >&2
exit 1
fi
# Branch 5: explicit GITEA_LOGIN outranks the ladder. An operator naming a login
# by hand is a deliberate act, not an accident to be second-guessed.
override_explicit=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat GITEA_LOGIN=mosaicstack bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_repo_override mosaicstack/stack
')
if [[ "$override_explicit" != "mosaicstack" ]]; then
echo "Expected explicit GITEA_LOGIN to outrank the identity ladder; got '$override_explicit'" >&2
exit 1
fi
# Branch 6 (#1357 S1): with tea ABSENT from PATH, the override path must say tea is
# missing, not "no tea login named X exists" (a cause that was never checked) and
# not the seat-logins.sh advice, which cannot be followed without tea.
NOTEA_BIN="$WORK_DIR/notea-bin"; mkdir -p "$NOTEA_BIN"
for t in bash git python3 sed grep cat mktemp dirname basename readlink env sort head tr cut; do
_p="$(command -v "$t" 2>/dev/null || true)"; [[ -n "$_p" ]] && ln -sf "$_p" "$NOTEA_BIN/$t"
done
override_notea_rc=0
override_notea_err=$(cd "$REPO_DIR" && env -u GITEA_LOGIN \
PATH="$NOTEA_BIN" HOME="$HOME_DIR" MOSAIC_GIT_IDENTITY=testseat \
bash -c '
command -v tea >/dev/null 2>&1 && { echo "SETUP: tea still on PATH"; exit 99; }
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_repo_override mosaicstack/stack
' 2>&1 >/dev/null) || override_notea_rc=$?
if [[ "$override_notea_rc" != 1 ]]; then
echo "Expected --repo override path to fail (rc=1) with tea absent; got rc=$override_notea_rc: $override_notea_err" >&2
exit 1
fi
if ! grep -q 'tea is not installed' <<<"$override_notea_err"; then
echo "Expected --repo override path to name tea as absent; got: $override_notea_err" >&2
exit 1
fi
if grep -q 'has no tea login\|seat-logins.sh' <<<"$override_notea_err"; then
echo "Override path diagnosed a missing LOGIN while tea itself is absent: $override_notea_err" >&2
exit 1
fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
# ---------------------------------------------------------------------------
# #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as
# PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must
@@ -76,22 +76,7 @@ exit 0
EOF
chmod +x "$MOCK_BIN/tea"
}
# #1356: login resolution is now identity-aware, so the tea-branch fixture must
# offer the login the RUNNER's identity resolves to; otherwise every case below
# fails closed before reaching the branch under test.
#
# This does NOT make the suite hermetic, and it is not trying to. The API-path
# cases (5-7) need a usable Gitea token, and with an identity set the token path
# reads that seat's credential file rather than the GITEA_TOKEN exported above.
# So this suite passes only where the runner owns a real credential for its own
# identity, and fails with no identity at all -- on this branch and on its base
# alike. That is a pre-existing hole in the fixture, filed separately; pinning a
# synthetic identity here would only convert it into a confident-looking green.
_LOGIN_IDENT="${MOSAIC_GIT_IDENTITY:-}"
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
if [[ -n "$_LOGIN_IDENT" ]]; then
LOGIN_JSON='[{"name":"mosaicstack-'"$_LOGIN_IDENT"'","url":"https://git.mosaicstack.dev"},{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
fi
# The mocks must be the ones that run. Without this, a failed setup silently falls through
# to the real tea/curl and the "test" mutates the real provider.
@@ -10,12 +10,6 @@
set -euo pipefail
# HERMETICITY (#1356): this suite's subject is body quoting, not identity. An
# ambient MOSAIC_GIT_IDENTITY (every provisioned seat exports one) would make the
# identity ladder demand a per-seat login this fixture does not define, and the
# suite would fail for a reason it is not testing.
unset MOSAIC_GIT_IDENTITY
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-body-safety}"
REPO_DIR="$WORK_DIR/repo"
@@ -1,137 +0,0 @@
#!/usr/bin/env bash
# Regression: issue-view.sh must show comment BODIES, on both paths, and must name
# the failure tea actually reported instead of guessing a credential cause (#1357).
#
# Four defects, each with its own case below:
# F1 tea exits 1 in any repo with extensions.worktreeconfig=true; the wrapper must
# say so (git-config condition) and fall back to the API.
# F2 the API fallback dumped raw issue JSON, which carries only a comment COUNT.
# F3 the tea path never passed --comments, so tea prompted (non-interactively: nothing).
# F4 on ANY tea failure the wrapper printed the REVOKED OR STALE TOKEN note.
#
# Verification bar (plan §6): assert a real comment BODY appears, not a count and not
# `grep -c comment` (that instrument matched the issue title and read inverted).
#
# Hermetic: mock tea and curl on PATH, sandboxed repo. Resolves no real credentials.
set -euo pipefail
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
SANDBOX="$WORK_ROOT/issue-view-comments-test-$$"
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
cleanup() { rm -rf "$SANDBOX"; }
trap cleanup EXIT
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="$SCRIPT_DIR/issue-view.sh"
[ -f "$TARGET" ] || { echo "FAIL: issue-view.sh not found beside this test"; exit 1; }
fail() { echo "FAIL: $*"; exit 1; }
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
git init -q || fail "setup: git init failed"
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
export PATH="$MOCK_BIN:$PATH" CALLS
export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
# The identity ladder must not reach for this seat's real login; the mock tea below
# defines the only login that exists in this sandbox.
unset MOSAIC_GIT_IDENTITY
# No fleet in the sandbox: on a host that runs one, get_gitea_token fails closed for an
# identity-less caller (by design), which would make this test measure the host, not
# the wrapper. An empty brain home makes the sandbox the same on every host.
export MOSAIC_BRAIN_HOME="$SANDBOX/brain"
mkdir -p "$MOSAIC_BRAIN_HOME" || fail "setup: cannot create sandbox brain home"
# Distinctive strings: a comment body that appears nowhere else, and an issue title
# that contains the word "comment" so a count-of-the-word instrument would misread.
BODY_MARKER="zebra-quill-comment-body-7731"
ISSUE_TITLE="wrapper never shows a comment"
# --- mock curl: serves the issue and its comments; logs every call --------------
cat > "$MOCK_BIN/curl" <<EOF
#!/bin/bash
url=""
while [ \$# -gt 0 ]; do
case "\$1" in
http*) url="\$1"; shift ;;
*) shift ;;
esac
done
printf 'curl %s\n' "\$url" >> "$CALLS"
case "\$url" in
*/issues/77/comments)
if [ "\${MOCK_NO_COMMENTS:-}" = "1" ]; then echo '[]'; else
echo '[{"id":1,"user":{"login":"alice"},"created_at":"2026-08-21T00:00:00Z","body":"$BODY_MARKER"}]'; fi ;;
*/issues/77)
echo '{"number":77,"title":"$ISSUE_TITLE","state":"open","user":{"login":"bob"},"created_at":"2026-08-21T00:00:00Z","labels":[],"milestone":null,"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/issues/77","body":"issue body","comments":1}' ;;
*) echo '{}' ;;
esac
exit 0
EOF
chmod +x "$MOCK_BIN/curl"
# --- mock tea: MOCK_TEA_MODE selects the behaviour under test --------------------
# ok : prints the issue, and the comment body ONLY when --comments is passed (F3)
# wtconfig : exits 1 with the repositoryformatversion error (F1/F4)
# badtoken : exits 1 with tea's credential error (F4 control: credential wording allowed)
cat > "$MOCK_BIN/tea" <<EOF
#!/bin/bash
printf 'tea %s\n' "\$*" >> "$CALLS"
if [[ "\$*" == *"login list"* ]]; then
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'; exit 0
fi
case "\${MOCK_TEA_MODE:-ok}" in
wtconfig) echo 'Error: core.repositoryformatversion does not support extension: worktreeconfig' >&2; exit 1 ;;
badtoken) echo 'Failed to create Gitea client: invalid username, password or token' >&2; exit 1 ;;
esac
echo "# #77 $ISSUE_TITLE (open)"
echo "issue body"
if [[ "\$*" == *"--comments"* ]]; then echo "$BODY_MARKER"; fi
exit 0
EOF
chmod +x "$MOCK_BIN/tea"
[ "$(command -v tea)" = "$MOCK_BIN/tea" ] || fail "setup: tea does not resolve inside the sandbox"
[ "$(command -v curl)" = "$MOCK_BIN/curl" ] || fail "setup: curl does not resolve inside the sandbox"
run() { bash "$TARGET" -i 77 >"$SANDBOX/out" 2>"$SANDBOX/err"; echo $?; }
# F3: tea path shows the comment body, which the mock emits only under --comments.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=ok run)
[ "$rc" = 0 ] || fail "F3: expected rc=0 on the tea path, got $rc: $(cat "$SANDBOX/err")"
grep -q -- '--comments' "$CALLS" || fail "F3: tea was not invoked with --comments: $(cat "$CALLS")"
grep -q "$BODY_MARKER" "$SANDBOX/out" || fail "F3: comment body missing from tea-path output"
if grep -q '^curl' "$CALLS"; then fail "F3: tea path succeeded but the API fallback ran anyway"; fi
# F1 + F2: worktreeconfig failure is named as a git-config condition, falls back to
# the API, and the API rendering includes the comment BODY.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=wtconfig run)
[ "$rc" = 0 ] || fail "F1: expected rc=0 via API fallback, got $rc: $(cat "$SANDBOX/err")"
grep -q 'worktreeconfig' "$SANDBOX/err" || fail "F1: stderr does not name the worktreeconfig cause: $(cat "$SANDBOX/err")"
grep -q 'not a credential problem' "$SANDBOX/err" || fail "F1: stderr does not rule out the credential cause"
grep -q 'issues/77/comments' "$CALLS" || fail "F2: API fallback never fetched /comments: $(cat "$CALLS")"
grep -q "$BODY_MARKER" "$SANDBOX/out" || fail "F2: comment body missing from API-path output"
grep -q "$ISSUE_TITLE" "$SANDBOX/out" || fail "F2: issue title missing from API-path output"
if grep -q 'REVOKED OR STALE' "$SANDBOX/err"; then fail "F4: stale-token note printed for a git-config failure"; fi
if grep -q '"comments": 1' "$SANDBOX/out"; then fail "F2: output is still raw JSON (comment count instead of bodies)"; fi
# F4 control: a real credential error from tea may still carry the credential note,
# and tea's own line must be relayed so the reader sees the actual cause.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=badtoken run)
[ "$rc" = 0 ] || fail "F4 control: expected rc=0 via API fallback, got $rc"
grep -q 'invalid username, password or token' "$SANDBOX/err" || fail "F4: tea's own error line was not relayed"
if grep -q 'worktreeconfig' "$SANDBOX/err"; then fail "F4: git-config wording printed for a credential failure"; fi
# Negative control: an issue with no comments prints no comment section on the API
# path. Without this, a renderer that always prints a section would pass F2.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=wtconfig MOCK_NO_COMMENTS=1 run)
[ "$rc" = 0 ] || fail "negative control: expected rc=0, got $rc"
if grep -q -- '--- Comments' "$SANDBOX/out"; then fail "negative control: comment section printed for an issue with no comments"; fi
if grep -q "$BODY_MARKER" "$SANDBOX/out"; then fail "negative control: a comment body appeared for an issue with no comments"; fi
echo "issue-view comments regression harness passed"
@@ -13,6 +13,7 @@
# --- tools/git: the #1007 five — non-hermetic, resolve real credentials ---
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
@@ -32,9 +32,7 @@
# 0 delivered (submitted) or queued (agent busy; will process when free)
# 1 tmux target not found
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
# input box could not be located to confirm the message actually landed.
# Locating the box is runtime-specific; see locate_input_box() below, and
# add a shape there before pointing this tool at a new runtime.
# input prompt could not be located to confirm the message actually landed.
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
# see the input box clear of the message (or the queued banner), we fail loud
# so the sender learns immediately instead of a silent worker->lead stall.
@@ -99,50 +97,10 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
# would otherwise accumulate forever.
sleep 0.5
# Locate the REPL input box in a captured pane. Prints the box's contents on
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
# located at all. Found-but-empty is a real, distinct answer (an empty input box
# is what a submitted message leaves behind), so the caller must branch on the
# return code, never on whether the output is empty.
#
# Two REPL shapes are recognised:
# * a prompt-glyph line — ``, a leading `>`, or `│ >`. Claude Code and most
# readline REPLs.
# * a box drawn as two horizontal `─` rules with the input between them and NO
# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is
# what makes it safe: agent output can contain its own rules, but nothing is
# drawn below the input box except the status line.
#
# Adding a runtime means adding its shape HERE. A shape that is missing does not
# degrade gracefully: it turns every send to that runtime into a false
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
# arm of the same probe.
locate_input_box() {
local pane=$1 glyph_line rule_lines top bottom
glyph_line=$(printf '%s\n' "$pane" | grep -E '|^>|│ >' | tail -1)
if [ -n "$glyph_line" ]; then printf '%s\n' "$glyph_line"; return 0; fi
rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*─{4,}[[:space:]]*$' | cut -d: -f1 | tail -2)
[ -n "$rule_lines" ] || return 1
# Split the (at most two) captured line numbers with parameter expansion. Not
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
# With one rule captured both halves resolve to the same value and the
# ordering test below rejects it, which is the answer we want anyway.
top=${rule_lines%%$'\n'*}
bottom=${rule_lines##*$'\n'}
[ "$top" != "$bottom" ] || return 1
[ "$bottom" -gt "$top" ] || return 1
# An empty range (adjacent rules) prints nothing and still returns 0: found,
# empty, which is the delivered shape.
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
return 0
}
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
# still a draft. Success requires positive evidence — the queued banner, OR the
# REPL input box located AND clear of our message tail. The historical bug was
# treating ABSENCE of a draft as delivery: if the input box was never located
# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
@@ -155,14 +113,15 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
if grep -qF "$QUEUED_RE" <<<"$pane"; then
status="queued"; break
fi
# If we cannot see the input box, we have NO evidence of submission state —
# stay UNCONFIRMED and retry; never infer delivery.
if ! inputbox=$(locate_input_box "$pane"); then
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
# evidence of submission state — stay UNCONFIRMED and retry; never infer delivery.
promptline=$(printf '%s' "$pane" | grep -E '|^>|│ >' | tail -1)
if [ -z "$promptline" ]; then
status="unconfirmed"; continue
fi
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
# (Submitted messages scroll up into history; a draft stays in the box.)
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
# (Submitted messages scroll up into history; a draft stays on the line.)
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
status="draft"; continue
fi
# Input box located AND clear of our tail => positively submitted. This is the
@@ -176,6 +135,6 @@ case "$status" in
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input prompt not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
esac
@@ -10,13 +10,6 @@
# "could not confirm submission").
# 3. DRAFT — a ` `-prompt pane that never submits (message stays on the
# input line) => exit 2, stderr "unsubmitted draft".
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362
# the glyph probe could not see this box at all, so EVERY send
# to such a pane reported "may be UNDELIVERED" while landing.
# 5. DRAFT — the same glyphless box, holding our tail across every flush
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
# also reported unconfirmed, so the true state was invisible.
set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
@@ -76,56 +69,6 @@ else
fi
fi
# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the
# text between them and NO prompt glyph anywhere, so the glyph probe alone can
# never locate it and every send reports "may be UNDELIVERED" (#1362). The
# renderer below is the shape, not the runtime: MODE=clear submits (box empties),
# MODE=keep leaves the text sitting in the box.
cat > "$TMP/pibox.sh" <<'PIBOX'
#!/usr/bin/env bash
MODE=${1:-clear}
RULE=$(printf '─%.0s' $(seq 1 60))
buf=""
draw() {
printf '\033[H\033[2J'
printf 'fixture output line\n\n'
printf '%s\n' "$RULE"
printf '%s\n' "$buf"
printf '%s\n' "$RULE"
printf '~/fixture (main)\n'
printf 'tok 0 model fixture\n'
}
draw
while IFS= read -r line; do
# keep: hold the tail across every flush Enter, which is what a stuck draft does.
if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else buf=""; fi
draw
done
PIBOX
chmod +x "$TMP/pibox.sh"
tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear"
sleep 0.3
out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$?
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered"
else
no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]"
fi
tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep"
sleep 0.3
if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then
no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])"
else
rc=$?
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then
ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'"
else
no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]"
fi
fi
echo "---"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]
+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 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/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/lease-broker/revoke_noop_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-edit.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-no-status.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-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.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 && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/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/lease-broker/revoke_noop_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-edit.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-no-status.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-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.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 && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -1,177 +0,0 @@
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { placeUnitFile, resolveLeaseBrokerSocketForPreflight } from './fleet.js';
/**
* Unit-placement regression harness for #1292.
*
* The two measured defects this suite pins:
* 1. `systemctl enable <name>` does NOT rewrite an existing by-path
* wants-symlink — so placement must remove stale residue explicitly, and
* acceptance asserts on the RESULTING SYMLINK TARGET, never on the enable
* call's argument (asserting the call cannot see where the link ended up).
* 2. Node's copyFile FOLLOWS a by-path symlink at the destination and
* overwrites the SEED template. Acceptance asserts on the SEED's bytes
* AND mtime — unchanged — which is the only check that can redden for
* finding 2. The symlink-target assertion catches finding 1; these are
* different defects with different failure modes.
*
* Fixtures are entirely inside tmpdirs (source template, active systemd dir,
* wants dir) — no real host paths are touched by this suite.
*/
describe('placeUnitFile (#1292 unit placement)', () => {
const cleanup: string[] = [];
afterEach(async () => {
while (cleanup.length > 0) {
await rm(cleanup.pop()!, { recursive: true, force: true });
}
});
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'place-unit-'));
cleanup.push(root);
const seedDir = join(root, 'seed');
const activeDir = join(root, 'active');
await mkdir(seedDir, { recursive: true });
await mkdir(activeDir, { recursive: true });
const seedTemplate = join(seedDir, 'unit-under-test.service');
await writeFile(
seedTemplate,
'[Unit]\nDescription=seed template\n[Service]\nType=oneshot\nExecStart=/bin/true\n[Install]\nWantedBy=default.target\n',
);
const activeSource = join(root, 'active-source.service');
await writeFile(
activeSource,
'[Unit]\nDescription=active copy v2\n[Service]\nType=oneshot\nExecStart=/bin/true\n[Install]\nWantedBy=default.target\n',
);
return { root, seedDir, activeDir, seedTemplate, activeSource };
}
it('places a regular file on a clean host (negative control: no residue anywhere)', async () => {
const f = await fixture();
const result = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
expect(result.unlinkedDestinationSymlink).toBe(false);
expect(result.removedStaleWantsSymlink).toBe(false);
const info = await lstat(join(f.activeDir, 'unit-under-test.service'));
expect(info.isSymbolicLink()).toBe(false);
expect(await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8')).toContain(
'active copy v2',
);
// Seed untouched by construction — but assert it, so the clean-host case
// cannot silently regress into seed-mutation.
expect(await readFile(f.seedTemplate, 'utf8')).toContain('seed template');
});
it('by-path residue: unlinks destination symlink, places the file, seed bytes AND mtime unchanged (finding 2)', async () => {
const f = await fixture();
const seedBefore = await readFile(f.seedTemplate, 'utf8');
const mtimeBefore = (await lstat(f.seedTemplate)).mtimeMs;
// The fomo-lin convention: by-path enable left a symlink AT the unit name
// pointing at the seed template, plus a wants-symlink doing the same.
await symlink(f.seedTemplate, join(f.activeDir, 'unit-under-test.service'));
const wantsDir = join(f.activeDir, 'default.target.wants');
await mkdir(wantsDir, { recursive: true });
await symlink(f.seedTemplate, join(wantsDir, 'unit-under-test.service'));
const result = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
expect(result.unlinkedDestinationSymlink).toBe(true);
expect(result.removedStaleWantsSymlink).toBe(true);
// FINDING 2's check: the seed is byte-identical and its mtime did not move.
expect(await readFile(f.seedTemplate, 'utf8')).toBe(seedBefore);
expect((await lstat(f.seedTemplate)).mtimeMs).toBe(mtimeBefore);
// The destination is now a regular file carrying the ACTIVE content.
const destInfo = await lstat(join(f.activeDir, 'unit-under-test.service'));
expect(destInfo.isSymbolicLink()).toBe(false);
expect(await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8')).toContain(
'active copy v2',
);
});
it('by-path residue: no wants-symlink remains pointing at the seed (finding 1 residue cleared)', async () => {
const f = await fixture();
await symlink(f.seedTemplate, join(f.activeDir, 'unit-under-test.service'));
const wantsDir = join(f.activeDir, 'default.target.wants');
await mkdir(wantsDir, { recursive: true });
await symlink(f.seedTemplate, join(wantsDir, 'unit-under-test.service'));
await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
// After placement the stale wants link is GONE (enable-by-name recreates
// it correctly). A link still present must not point at the seed.
try {
const link = await lstat(join(wantsDir, 'unit-under-test.service'));
if (link.isSymbolicLink()) {
const target = await readFile(join(wantsDir, 'unit-under-test.service'), 'utf8').catch(
async () => '',
);
expect(target).not.toContain('seed template');
}
} catch {
// absent wants link — the expected post-placement state
}
});
it('idempotence: second placement on a reconciled host is a no-op producing the identical final state', async () => {
const f = await fixture();
// Reconciled starting state: regular file at the name, wants link to the active copy.
await writeFile(
join(f.activeDir, 'unit-under-test.service'),
await readFile(f.activeSource, 'utf8'),
);
const wantsDir = join(f.activeDir, 'default.target.wants');
await mkdir(wantsDir, { recursive: true });
await symlink(
join(f.activeDir, 'unit-under-test.service'),
join(wantsDir, 'unit-under-test.service'),
);
const before = await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8');
const result = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
// No destructive step fired: no unlink, no wants removal.
expect(result.unlinkedDestinationSymlink).toBe(false);
expect(result.removedStaleWantsSymlink).toBe(false);
// Identical final state.
expect(await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8')).toBe(before);
const link = await lstat(join(wantsDir, 'unit-under-test.service'));
expect(link.isSymbolicLink()).toBe(true);
});
it('double install on by-path residue converges to the identical reconciled state', async () => {
const f = await fixture();
await symlink(f.seedTemplate, join(f.activeDir, 'unit-under-test.service'));
const wantsDir = join(f.activeDir, 'default.target.wants');
await mkdir(wantsDir, { recursive: true });
await symlink(f.seedTemplate, join(wantsDir, 'unit-under-test.service'));
await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
const first = await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8');
const secondRun = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
const second = await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8');
expect(secondRun.unlinkedDestinationSymlink).toBe(false);
expect(second).toBe(first);
});
});
describe('resolveLeaseBrokerSocketForPreflight (#1292 preflight resolution)', () => {
it('explicit MOSAIC_LEASE_BROKER_SOCKET wins', () => {
expect(
resolveLeaseBrokerSocketForPreflight({ MOSAIC_LEASE_BROKER_SOCKET: '/custom/sock' }, 1000),
).toBe('/custom/sock');
});
it('XDG_RUNTIME_DIR next', () => {
expect(resolveLeaseBrokerSocketForPreflight({ XDG_RUNTIME_DIR: '/run/user/1001' }, 1000)).toBe(
'/run/user/1001/mosaic-lease/broker.sock',
);
});
it('falls back to /run/user/<uid>', () => {
expect(resolveLeaseBrokerSocketForPreflight({}, 1002)).toBe(
'/run/user/1002/mosaic-lease/broker.sock',
);
});
});
@@ -73,10 +73,6 @@ function program(
runner,
reconcileDeps: {
homeDirectory: '/home/mosaic',
// Deterministic broker presence: without a seam the reconciler probes the
// REAL host socket (#1297 F3), making every CLI start test answer the
// host's broker state instead of its own property.
checkBrokerSocket: async () => true,
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
validateRoster: async () => undefined,
prepareProjections: async () => [{ agentName: 'coder0' }],
+2 -193
View File
@@ -1,24 +1,11 @@
import {
chmod,
lstat,
mkdir,
mkdtemp,
readFile,
readlink,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { createServer } from 'node:net';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
acquireRestartLock,
addAgentToRoster,
brokerSocketPresent,
buildAgentSendCommand,
buildAgentWatchAttachCommand,
buildAgentWatchCommand,
@@ -55,7 +42,6 @@ import {
parseSystemdShow,
parseTmuxListPanes,
parseTmuxListSessions,
placeUnitFile,
registerFleetCommand,
removeAgentFromRoster,
resolveFleetPaths,
@@ -64,7 +50,6 @@ import {
RESTART_LOCK_STALE_MS,
RUNTIME_ACCEPTABLE_COMMANDS,
serializeRosterToYaml,
UnitPlacementError,
VERIFY_DEFAULT_TIMEOUT_MS,
VERIFY_POLL_INTERVAL_MS,
type AgentPsRow,
@@ -851,25 +836,13 @@ describe('fleet command construction', () => {
};
const program = new Command();
program.exitOverride();
// #1292: inject a present broker socket so the preflight passes and this
// spec keeps testing its ORIGINAL property (holder-before-agent ordering).
// The preflight's own refusal behavior has dedicated specs below.
registerFleetCommand(program, {
runner,
mosaicHome: home,
checkBrokerSocket: async () => true,
});
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
await program.parseAsync(['node', 'mosaic', 'fleet', 'stop']);
expect(calls).toEqual([
// #1292: fleet start enables + starts the broker FIRST (enable is
// idempotent; the unit exists after install), re-checking the socket
// before any holder/agent lifecycle effect.
['systemctl', '--user', 'enable', 'mosaic-lease-broker.service'],
['systemctl', '--user', 'start', 'mosaic-lease-broker.service'],
['systemctl', '--user', 'start', 'mosaic-tmux-holder.service'],
['systemctl', '--user', 'start', '[email protected]'],
['systemctl', '--user', 'stop', '[email protected]'],
@@ -880,92 +853,6 @@ describe('fleet command construction', () => {
}
});
it('fleet start refuses with a named error when the broker socket does not appear (#1292)', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
const errors: string[] = [];
const origError = console.error;
console.error = (...args: unknown[]) => {
errors.push(args.join(' '));
};
registerFleetCommand(program, {
runner,
mosaicHome: home,
checkBrokerSocket: async () => false,
});
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
// Refused: no holder/agent starts were issued after the broker attempt.
expect(calls).toEqual([
['systemctl', '--user', 'enable', 'mosaic-lease-broker.service'],
['systemctl', '--user', 'start', 'mosaic-lease-broker.service'],
]);
expect(errors.join('\n')).toContain('broker-absent');
expect(errors.join('\n')).toContain('mosaic fleet install');
} finally {
console.error = origError;
await rm(home, { recursive: true, force: true });
}
});
it('fleet start re-probes the broker on the SECOND invocation — no ActiveState trust (#1292 sticky half)', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
// Broker socket NEVER appears — the second start must refuse exactly like
// the first; RemainAfterExit-style stale unit state changes nothing
// because the check is the socket, not systemctl.
registerFleetCommand(program, {
runner,
mosaicHome: home,
checkBrokerSocket: async () => false,
});
const errors: string[] = [];
const origError = console.error;
console.error = (...args: unknown[]) => {
errors.push(args.join(' '));
};
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
// Two invocations, each refusing after its own broker attempt:
expect(
calls.filter((c) => c.join(' ') === 'systemctl --user start [email protected]'),
).toHaveLength(0);
expect(errors.filter((e) => e.includes('broker-absent')).length).toBeGreaterThanOrEqual(2);
} finally {
console.error = origError;
await rm(home, { recursive: true, force: true });
}
});
it('waits for an in-flight restart to clear before relaunching (re-entry guard)', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
@@ -2179,19 +2066,8 @@ describe('fleet install — auto-enable units for boot-survival', () => {
await enableFleetUnits(runner, minimalRoster, {});
expect(calls).toContainEqual(['systemctl', '--user', 'enable', 'mosaic-lease-broker.service']);
expect(calls).toContainEqual(['systemctl', '--user', 'enable', 'mosaic-tmux-holder.service']);
expect(calls).toContainEqual(['systemctl', '--user', 'enable', '[email protected]']);
// The broker must be enabled BEFORE the holder and agents: a start of any
// gated runtime without the broker is exactly the #1292 4-second death.
const brokerIndex = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user enable mosaic-lease-broker.service',
);
const holderIndex = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user enable mosaic-tmux-holder.service',
);
expect(brokerIndex).toBeGreaterThanOrEqual(0);
expect(brokerIndex).toBeLessThan(holderIndex);
});
it('install still succeeds when systemctl enable returns non-zero (non-fatal)', async () => {
@@ -4486,70 +4362,3 @@ describe('fleet ps — heartbeat path resolution', () => {
);
});
});
describe('#1297 review: the real broker probe, exercised without any seam', () => {
it('brokerSocketPresent answers a REAL unix socket via stat().isSocket() (access(S_IFSOCK) threw ERR_OUT_OF_RANGE)', async () => {
const dir = await tempDir();
const sockPath = join(dir, 'broker.sock');
const server = createServer();
await new Promise<void>((resolve) => {
server.listen(sockPath, resolve);
});
try {
// A live unix socket answers true through the REAL probe — no seam.
expect(await brokerSocketPresent({}, { MOSAIC_LEASE_BROKER_SOCKET: sockPath })).toBe(true);
// Discrimination is by file type: a regular file that EXISTS is not a
// socket. The old implementation could not reach either verdict — it
// threw ERR_OUT_OF_RANGE (node >= 24) and the catch answered false.
const notASocket = join(dir, 'not-a-sock');
await writeFile(notASocket, 'x');
expect(await brokerSocketPresent({}, { MOSAIC_LEASE_BROKER_SOCKET: notASocket })).toBe(false);
// Absent path: false, not a throw.
expect(
await brokerSocketPresent({}, { MOSAIC_LEASE_BROKER_SOCKET: join(dir, 'gone.sock') }),
).toBe(false);
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
await rm(dir, { recursive: true, force: true });
});
// EACCES-based unlink failure requires a non-root uid: root bypasses
// directory mode bits (CAP_DAC_OVERRIDE), so the abort path cannot be
// triggered this way under CI's root runner. Skipped there, exercised on
// every non-root dev host.
const itUnlessRoot =
typeof process.getuid === 'function' && process.getuid() === 0 ? it.skip : it;
itUnlessRoot(
'placeUnitFile aborts with UnitPlacementError when unlink fails — never copies through a live symlink',
async () => {
const dir = await tempDir();
const unitDir = join(dir, 'systemd', 'user');
await mkdir(unitDir, { recursive: true });
// By-path residue: destination is a symlink pointing somewhere else.
const residueTarget = join(dir, 'residue-target');
await writeFile(residueTarget, 'RESIDUE-BYTES');
const destination = join(unitDir, 'x.service');
await symlink(residueTarget, destination);
const source = join(dir, 'seed.service');
await writeFile(source, 'UNIT-BYTES');
// Read-only unit dir: unlink now fails EACCES (test runs as the owner,
// not root, so mode bits are enforced).
await chmod(unitDir, 0o500);
try {
await expect(placeUnitFile(source, unitDir, 'x.service')).rejects.toThrow(
UnitPlacementError,
);
} finally {
await chmod(unitDir, 0o700);
}
// The copy-through never happened: residue bytes intact, destination
// still the symlink (abort, not overwrite-through).
expect(await readFile(residueTarget, 'utf8')).toBe('RESIDUE-BYTES');
expect(await readlink(destination)).toBe(residueTarget);
await rm(dir, { recursive: true, force: true });
},
);
});
+13 -258
View File
@@ -1,13 +1,11 @@
import { constants, type Stats } from 'node:fs';
import { constants } from 'node:fs';
import {
access,
chmod,
copyFile,
lstat,
mkdir,
open,
readFile,
readlink,
stat,
unlink,
writeFile,
@@ -92,8 +90,6 @@ export type SleepFn = (ms: number) => Promise<void>;
export interface FleetCommandDeps {
runner?: CommandRunner;
/** Test seam for the #1292 fleet-start broker preflight (socket presence). */
checkBrokerSocket?: (path: string) => Promise<boolean> | boolean;
/** Injectable interactive runner for commands needing inherited TTY (e.g., `tmux attach`). */
interactiveRunner?: InteractiveRunner;
/**
@@ -819,135 +815,6 @@ export function buildSystemdEnableCommand(unit: string): string[] {
return ['systemctl', '--user', 'enable', unit];
}
/**
* Place a unit file into the ACTIVE systemd user directory, never through a
* symlink (#1292, measured 2026-08-17).
*
* ⚠ SET-INDEPENDENCE (fomo-lin, 2026-08-17): the set of unit names carrying
* by-path residue and the set of unit names this install copies are
* INDEPENDENT. Until 0.0.50 they were disjoint only by accident of which
* units the install happened to name — fomo-lin survived copy-through solely
* because its one by-path symlink (the broker) was the one unit the install
* did NOT copy. Adding the broker to the copy set made the intersection
* non-empty on the first run. Whoever adds a fifth unit to the placement
* list inherits this helper and its unlink step; do not place units with a
* bare copyFile.
*
* A host provisioned by the enable-by-path convention carries a symlink AT
* the unit-name path in ~/.config/systemd/user/ pointing at the shipped
* template under ~/.config/mosaic/systemd/user/. Node's copyFile FOLLOWS
* that link and overwrites the SEED template instead of placing the active
* unit (verified with fs.copyFile on a throwaway systemd user instance) —
* silent, rc=0, and it mutates the directory every later reseed reads from.
* The same measurement showed `systemctl enable <name>` does NOT rewrite an
* existing by-path wants-symlink, so reconciliation must be explicit.
*
* Placement therefore: if the destination is a symlink, unlink it first
* (unlink → copy — copy-then-unlink would mutate the seed and then destroy
* the evidence that it did); then copy. Also removes a stale
* `default.target.wants/<name>` symlink that points outside the active
* directory (readlink — NOT readFile, which follows the link and returns the
* target's CONTENT), so the subsequent enable-by-name recreates it against
* the active copy. Idempotent: on a clean or already-reconciled destination
* every step is a no-op (the copy rewrites identical bytes).
*
* Returns what was done, for assertions and install reporting.
*/
export interface PlaceUnitResult {
readonly unit: string;
readonly destination: string;
/** A symlink at the unit-name path was unlinked (by-path residue). */
readonly unlinkedDestinationSymlink: boolean;
/** A stale wants-symlink pointing outside the active dir was removed. */
readonly removedStaleWantsSymlink: boolean;
}
/**
* placeUnitFile failed. Thrown BEFORE any copy: no destination bytes were
* written, so a residue target cannot have been clobbered by a copy-through
* (#1297 review F2).
*/
export class UnitPlacementError extends Error {
constructor(
readonly unit: string,
message: string,
) {
super(message);
this.name = UnitPlacementError.name;
}
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error && typeof error.code === 'string';
}
export async function placeUnitFile(
source: string,
systemdUserDir: string,
unit: string,
): Promise<PlaceUnitResult> {
const destination = join(systemdUserDir, unit);
let unlinkedDestinationSymlink = false;
// Destination-absent and unlink-FAILED are different outcomes and must not
// share a catch (#1297 review F2): a swallowed unlink error used to fall
// through to copyFile through the still-live symlink, silently reintroducing
// the exact copy-through this helper exists to prevent.
let destinationInfo: Stats | undefined;
try {
destinationInfo = await lstat(destination);
} catch (error) {
if (!isErrnoException(error) || error.code !== 'ENOENT') {
throw new UnitPlacementError(
unit,
`cannot inspect destination ${destination}: ${error instanceof Error ? error.message : String(error)}`,
);
}
// ENOENT: absent destination — nothing to unlink, copy below is safe.
}
if (destinationInfo?.isSymbolicLink()) {
try {
await unlink(destination);
} catch (error) {
// Abort BEFORE the copy: proceeding would run copyFile through the
// still-live symlink and overwrite the residue target's bytes.
throw new UnitPlacementError(
unit,
`cannot unlink destination symlink ${destination}: ${error instanceof Error ? error.message : String(error)}`,
);
}
unlinkedDestinationSymlink = true;
}
await copyFile(source, destination);
let removedStaleWantsSymlink = false;
const wantsLink = join(systemdUserDir, 'default.target.wants', unit);
try {
const wantsInfo = await lstat(wantsLink);
if (wantsInfo.isSymbolicLink()) {
// readlink — NOT readFile: readFile FOLLOWS the link and returns the
// target file's CONTENT, which is not the question being asked.
let target: string | undefined;
try {
target = await readlink(wantsLink);
} catch {
target = undefined;
}
// Normalize (systemctl writes absolute targets; a relative one resolves
// against the wants dir). A wants-symlink pointing anywhere other than
// the active copy (the by-path convention points at the seed template)
// survives enable-by-name unchanged — remove it so enable recreates it.
if (target !== undefined && resolve(dirname(wantsLink), target) !== destination) {
await unlink(wantsLink);
removedStaleWantsSymlink = true;
}
}
} catch {
// absent wants link — nothing to reconcile
}
return { unit, destination, unlinkedDestinationSymlink, removedStaleWantsSymlink };
}
/**
* Returns the systemctl --user disable command for a given unit.
* Used by `fleet remove` so a removed agent's enabled unit cannot resurrect on
@@ -982,22 +849,6 @@ export async function enableFleetUnits(
let succeeded = 0;
let failed = 0;
// The lease broker ships with the fleet and every gated runtime needs it
// (#1292): seats die at lease registration without it, and no documented
// path ever enabled it. Enabled first — alongside the holder — and the
// unit must have been placed by installFleet's placeUnitFile step.
const brokerResult = await runner(
...splitCommand(buildSystemdEnableCommand('mosaic-lease-broker.service')),
);
if (brokerResult.exitCode === 0) {
succeeded++;
} else {
failed++;
process.stderr.write(
`Warning: could not enable mosaic-lease-broker.service: ${brokerResult.stderr || brokerResult.stdout || 'non-zero exit'}\n`,
);
}
const holderResult = await runner(
...splitCommand(buildSystemdEnableCommand('mosaic-tmux-holder.service')),
);
@@ -1694,7 +1545,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.description('Install local fleet tools and user systemd units')
.option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot, runner);
await installFleet(cmd, frameworkRoot);
// Unit enablement needs agent names only, so it reads either version.
const roster = await loadRosterReadModel(cmd);
await enableFleetUnits(runner, roster, opts);
@@ -1705,7 +1556,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.description('Install local fleet tools and user systemd units')
.option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot, runner);
await installFleet(cmd, frameworkRoot);
// Unit enablement needs agent names only, so it reads either version.
const roster = await loadRosterReadModel(cmd);
await enableFleetUnits(runner, roster, opts);
@@ -1758,37 +1609,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
);
return;
}
if (action === 'start') {
// Broker preflight (#1292), re-probed on EVERY invocation: a
// gated runtime started without a live lease broker dies ~4s in
// while the unit reports active (RemainAfterExit) — enabling +
// starting here and then RE-CHECKING the socket refuses loudly
// instead of reporting rc0 over a doomed start. This is the
// second-start check as much as the first: it never trusts unit
// ActiveState.
await runChecked(runner, [
'systemctl',
'--user',
'enable',
'mosaic-lease-broker.service',
]);
await runChecked(runner, [
'systemctl',
'--user',
'start',
'mosaic-lease-broker.service',
]);
if (!(await brokerSocketPresent(deps))) {
console.error(
'[fleet] broker-absent: lease broker socket did not appear after enable+start (#1292).',
);
console.error(
'[fleet] remedy: mosaic fleet install (it reconciles either enable convention)',
);
process.exitCode = 1;
return;
}
}
if (action === 'restart') {
// Serialize the holder+agents teardown/relaunch behind the restart lock
// so a re-entrant restart waits for clean shutdown before relaunching,
@@ -2547,11 +2367,7 @@ export function registerFleetAgentCommands(
});
}
async function installFleet(
cmd: Command,
frameworkRoot: string,
runner: CommandRunner,
): Promise<void> {
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
// Read model first: every file this function places is roster-independent, and
@@ -2603,40 +2419,18 @@ async function installFleet(
for (const toolPath of executableToolPaths) {
await chmod(toolPath, 0o755);
}
// Unit placement (#1292): every unit goes through placeUnitFile — never a
// bare copyFile — so a by-path-enable symlink at the destination is
// unlinked rather than written through (copy-through would silently
// overwrite the SEED template, measured 2026-08-17). The lease broker unit
// is placed here too: previously the install named three units and omitted
// the broker entirely, which is why no documented path ever enabled it.
const placedUnits = await Promise.all(
[
'mosaic-tmux-holder.service',
'[email protected]',
'[email protected]',
'mosaic-lease-broker.service',
].map((unit) =>
placeUnitFile(join(frameworkRoot, 'systemd', 'user', unit), activePaths.systemdUserDir, unit),
),
await copyFile(
join(frameworkRoot, 'systemd', 'user', 'mosaic-tmux-holder.service'),
join(activePaths.systemdUserDir, 'mosaic-tmux-holder.service'),
);
const reconciled = placedUnits.filter(
(result) => result.unlinkedDestinationSymlink || result.removedStaleWantsSymlink,
await copyFile(
join(frameworkRoot, 'systemd', 'user', '[email protected]'),
join(activePaths.systemdUserDir, '[email protected]'),
);
if (reconciled.length > 0) {
console.log(
`Reconciled ${reconciled.length} unit placement(s) from by-path enable residue: ${reconciled.map((r) => r.unit).join(', ')}`,
await copyFile(
join(frameworkRoot, 'systemd', 'user', '[email protected]'),
join(activePaths.systemdUserDir, '[email protected]'),
);
}
// systemd will not see a replaced unit file without a reload; do it once
// after all placements, before any enable call below. runCommand never
// rejects (it resolves exitCode 127 on spawn error), so a plain await with
// an exitCode check matches the rest of this file's systemctl handling.
const reloadResult = await runner(...splitCommand(['systemctl', '--user', 'daemon-reload']));
if (reloadResult.exitCode !== 0) {
process.stderr.write(
`Warning: systemctl --user daemon-reload after unit placement failed (non-systemd host?): ${reloadResult.stderr || reloadResult.stdout || 'non-zero exit'}\n`,
);
}
// On roster v2 the reconciler owns the generated env: `apply` writes it and
// `regen` rebuilds it, both from projectRosterV2AgentGeneratedEnv. Writing it
@@ -2833,45 +2627,6 @@ function splitCommand(command: string[]): [string, string[]] {
return [bin, args];
}
/**
* Lease-broker socket presence for the fleet-start preflight (#1292).
* Resolution precedence matches launch.ts's defaultLeaseBrokerSocket and
* start-agent-session.sh's broker_socket_path: explicit
* MOSAIC_LEASE_BROKER_SOCKET, else $XDG_RUNTIME_DIR/mosaic-lease/broker.sock,
* else /run/user/<uid>/mosaic-lease/broker.sock. Pure filesystem check — this
* deliberately does NOT consult systemd state: a unit can be active
* (RemainAfterExit) with no live socket, and the socket is the thing the
* gated runtime connects to. Injectable via deps for tests.
*/
export function resolveLeaseBrokerSocketForPreflight(
env: NodeJS.ProcessEnv = process.env,
uid: number = typeof process.getuid === 'function' ? process.getuid() : 0,
): string {
if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET'];
const runtimeDir = env['XDG_RUNTIME_DIR'] ?? `/run/user/${uid}`;
return join(runtimeDir, 'mosaic-lease', 'broker.sock');
}
export async function brokerSocketPresent(
deps: FleetCommandDeps,
env: NodeJS.ProcessEnv = process.env,
): Promise<boolean> {
const check = deps.checkBrokerSocket;
const socketPath = resolveLeaseBrokerSocketForPreflight(env);
if (check) return check(socketPath);
// S_IFSOCK (0xC000) is a file-TYPE constant, not an access() mode (0-7):
// access(path, S_IFSOCK) throws ERR_OUT_OF_RANGE on node >= 24 (measured on
// v24.18.0, #1297 review F1) and cannot succeed on any node — the old catch
// swallowed the throw, so this probe could NEVER return true and every
// un-seamed call reported the broker absent. stat() + isSocket() is the real
// check and matches the bash side's [ -S ].
try {
return (await stat(socketPath)).isSocket();
} catch {
return false;
}
}
/** All supported fleet profile names. */
export type FleetProfile =
| 'general'
@@ -205,12 +205,6 @@ export async function runLeaseEnforcementDoctorCheck(
message:
`Lease-enforcement hooks (${matchedMarkers.join(', ')}) are wired in ~/.claude/settings.json, but ${reasons.join(' and ')}. ` +
'Every gated tool call will fail closed and BRICK this agent (see #869). ' +
// #1292: one remedy, correct under BOTH enable conventions (by-path on
// the seed template, and copy-then-enable in the active dir). Written
// from the 2026-08-17 symlink measurement: `systemctl enable` by name
// does NOT rewrite an existing by-path wants-symlink, so teaching a
// manual systemctl line here could leave a host with two competing
// wants links. fleet install reconciles either shape.
'Remedy: run `mosaic fleet install` (it reconciles either enable convention), or remove the enforcement hooks from ~/.claude/settings.json.',
'Remediate by activating the lease-broker supervisor (systemd unit + socket) or by removing the enforcement hooks from ~/.claude/settings.json.',
};
}
@@ -169,16 +169,6 @@ function reconcileDeps(host: FakeLifecycleHost): FleetReconcileDeps {
applyProjection: async () => undefined,
readRoster: async () => host.roster,
acquireMutationLock: async () => async () => undefined,
// Hermetic broker observation (#1297 F3): without this, the plan probes
// the REAL host filesystem, so the "stable JSON" fixtures answered true
// on any machine with a live lease broker and false elsewhere. Pointing
// both paths at fixtures that do not exist pins socketPresent:false and
// unitInstalled:false on every host, which is what these fixtures assert.
brokerSocketEnv: {
MOSAIC_LEASE_BROKER_SOCKET: '/nonexistent/mosaic-lease/broker.sock',
XDG_CONFIG_HOME: '/nonexistent/mosaic-config',
XDG_RUNTIME_DIR: '/nonexistent/run',
},
};
}
@@ -469,7 +459,6 @@ describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => {
plan: {
generation: 7,
holder: 'owned',
broker: { unitInstalled: false, socketPresent: false },
agents: [
{
name: 'coder0',
@@ -1,7 +1,6 @@
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createServer } from 'node:net';
import { afterEach, describe, expect, it } from 'vitest';
import {
acquirePrivateReconcileLock,
@@ -93,179 +92,6 @@ async function run(command: FleetReconcileCommand, overrides: Partial<FleetRecon
}
describe('fleet roster-owned reconciler', (): void => {
// ── #1292: broker as first-class plan member + broker-first start ordering ──
it('reports broker unit and socket state in the plan (socket is the signal, not unit state)', async (): Promise<void> => {
const result = await run('status', {
statPath: async () => true,
checkBrokerSocket: async () => true,
});
expect(result.plan.broker).toEqual({ unitInstalled: true, socketPresent: true });
});
it('reports a dead broker as socketPresent=false even when the unit is installed (enabled-but-dead is the #1292 shape)', async (): Promise<void> => {
const result = await run('status', {
statPath: async () => true,
checkBrokerSocket: async () => false,
});
expect(result.plan.broker).toEqual({ unitInstalled: true, socketPresent: false });
});
it('probes the REAL filesystem when no seam is injected — live socket and unit report healthy, absent paths report absent (#1297 F3)', async (): Promise<void> => {
const dir = await mkdtemp(join(tmpdir(), 'mosaic-broker-probe-'));
cleanup = dir;
const configHome = join(dir, 'config');
const unitDir = join(configHome, 'systemd', 'user');
await mkdir(unitDir, { recursive: true });
await writeFile(join(unitDir, 'mosaic-lease-broker.service'), '[Unit]\n');
const sockPath = join(dir, 'broker.sock');
const server = createServer();
await new Promise<void>((resolve) => {
server.listen(sockPath, resolve);
});
try {
const result = await run('status', {
brokerSocketEnv: {
MOSAIC_LEASE_BROKER_SOCKET: sockPath,
XDG_CONFIG_HOME: configHome,
XDG_RUNTIME_DIR: dir,
},
});
expect(result.plan.broker).toEqual({ unitInstalled: true, socketPresent: true });
// Absent paths through the SAME seam-less path answer false — this is
// the half the old default got right; healthy is the half it got wrong.
const absent = await run('status', {
brokerSocketEnv: {
MOSAIC_LEASE_BROKER_SOCKET: join(dir, 'gone.sock'),
XDG_CONFIG_HOME: join(dir, 'gone-config'),
},
});
expect(absent.plan.broker).toEqual({ unitInstalled: false, socketPresent: false });
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it('command start refuses with a named error when the broker socket does not appear after enable+start (#1297 F3)', async (): Promise<void> => {
const calls: string[][] = [];
await expect(
run('start', {
checkBrokerSocket: async () => false,
runner: async (command, args) => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
).rejects.toThrow(/broker-absent/);
// Refused: broker enable+start attempted, no holder/agent unit touched.
const agentStarts = calls.filter(
(c) => c.join(' ') === 'systemctl --user start [email protected]',
);
expect(agentStarts).toHaveLength(0);
});
it('command start enables and starts the broker BEFORE the holder and any agent unit', async (): Promise<void> => {
const calls: string[][] = [];
const result = await run('start', {
// Deterministic broker presence: without the seam this test answers the
// HOST's broker state (passes on a machine with a live broker, refuses
// on CI), not the ordering property it exists for (#1297 follow-up).
checkBrokerSocket: async () => true,
runner: async (command, args) => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
});
expect(result.lifecycle).toBe('complete');
const brokerEnable = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user enable mosaic-lease-broker.service',
);
const brokerStart = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user start mosaic-lease-broker.service',
);
const holderStart = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user start mosaic-tmux-holder.service',
);
const agentStart = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user start [email protected]',
);
expect(brokerEnable).toBeGreaterThanOrEqual(0);
expect(brokerStart).toBeGreaterThan(brokerEnable);
// Holder start may be absent (holder 'owned' in this fixture); if present it must follow the broker.
if (holderStart >= 0) expect(holderStart).toBeGreaterThan(brokerStart);
expect(agentStart).toBeGreaterThan(brokerStart);
});
it('apply with a running desired agent also enables and starts the broker first', async (): Promise<void> => {
const calls: string[][] = [];
const runningRoster: FleetRosterV2 = {
...roster,
agents: roster.agents.map((agent) =>
agent.name === 'coder0'
? { ...agent, lifecycle: { enabled: true, desiredState: 'running' as const } }
: agent,
),
};
const result = await executeFleetReconcile({
roster: runningRoster,
command: 'apply',
expectedGeneration: 7,
deps: deps({
readRoster: async () => runningRoster,
// Deterministic broker presence (see start-ordering test note).
checkBrokerSocket: async () => true,
runner: async (command, args) => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
});
expect(result.applied).toBe(true);
const brokerStart = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user start mosaic-lease-broker.service',
);
const agentStart = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user start [email protected]',
);
expect(brokerStart).toBeGreaterThanOrEqual(0);
expect(agentStart).toBeGreaterThan(brokerStart);
});
it('fails closed on a symlinked fleet ancestor without touching its target', async (): Promise<void> => {
const home = await lockHome();
const fleet = join(home, 'fleet');
@@ -549,8 +375,6 @@ describe('fleet roster-owned reconciler', (): void => {
expectedGeneration: 7,
deps: deps({
readRoster: async () => runningRoster,
// Deterministic broker presence (see start-ordering test note).
checkBrokerSocket: async () => true,
runner: async (command, args) => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
+13 -149
View File
@@ -1,5 +1,5 @@
import { constants } from 'node:fs';
import { lstat, open, readFile, stat, unlink, type FileHandle } from 'node:fs/promises';
import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
@@ -44,10 +44,6 @@ export interface FleetReconcileDeps {
readonly overrideDir?: string;
readonly homeDirectory?: string;
readonly readHolderIdentity?: () => Promise<string>;
/** Test/observation seams for the lease-broker plan member (#1292). */
readonly statPath?: (path: string) => Promise<boolean> | boolean;
readonly checkBrokerSocket?: (path: string) => Promise<boolean> | boolean;
readonly brokerSocketEnv?: NodeJS.ProcessEnv;
readonly validateRoster?: (roster: FleetRosterV2) => Promise<void>;
readonly prepareProjections?: (roster: FleetRosterV2) => Promise<readonly unknown[]>;
readonly applyProjection?: (prepared: unknown) => Promise<unknown>;
@@ -79,17 +75,6 @@ export interface FleetReconcileObservedAgent {
export interface FleetReconcilePlan {
readonly generation: number;
readonly holder: 'owned' | 'missing' | 'ownership-mismatch';
/**
* Lease broker observation (#1292): every gated runtime registers with the
* broker or dies ~4s in a broker not in the plan cannot be reported as
* drifted, which made "broker died an hour ago" and "broker fine"
* produce identical output. `unitInstalled` = unit file present in the
* active dir; `socketPresent` = live broker at the resolved socket path.
*/
readonly broker: {
readonly unitInstalled: boolean;
readonly socketPresent: boolean;
};
readonly agents: readonly FleetReconcileObservedAgent[];
readonly unmanagedSessions: readonly string[];
}
@@ -261,17 +246,7 @@ export async function executeFleetReconcile(
lifecycle: 'complete',
plan,
};
} catch (error: unknown) {
// A named lifecycle precondition (broker-absent after enable+start,
// #1297 F3) must surface as itself — converting it to the generic
// recoverable result would hide the diagnosis and report a clean
// refusal where a loud one is the point.
if (
error instanceof FleetReconcileError &&
error.code === 'lifecycle-precondition-failed'
) {
throw error;
}
} catch {
result = {
applied: false,
authoritativeRoster: 'unchanged',
@@ -340,63 +315,6 @@ function isObservational(command: FleetReconcileCommand): boolean {
return command === 'plan' || command === 'status' || command === 'verify' || command === 'doctor';
}
/**
* Observe the lease broker for the plan (#1292). Unit presence via systemctl
* is-system-running is NOT the signal a unit can be enabled-but-dead. The
* authoritative signal is the socket the gated runtimes connect to, matching
* broker-supervisor.ts's `checkBrokerSupervisorHealth` (healthy ===
* socketPresent). Injectable so tests drive every branch without a broker.
*/
function resolveBrokerSocketPath(env: NodeJS.ProcessEnv): string {
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
const runtimeDir = env['XDG_RUNTIME_DIR'] ?? `/run/user/${uid}`;
return env['MOSAIC_LEASE_BROKER_SOCKET'] ?? join(runtimeDir, 'mosaic-lease', 'broker.sock');
}
/**
* Probe the broker socket. Seams take precedence, but with no seam injected
* the REAL stat().isSocket() runs (#1297 review F3): production passes no
* seams, and defaulting to false made plan/status/doctor report a healthy
* broker as absent a dead broker was indistinguishable from noise.
*/
async function brokerSocketPresent(
deps: FleetReconcileDeps,
env: NodeJS.ProcessEnv,
): Promise<boolean> {
const socketPath = resolveBrokerSocketPath(env);
const check = deps.checkBrokerSocket;
if (check) return check(socketPath);
try {
return (await stat(socketPath)).isSocket();
} catch {
return false;
}
}
async function observeBroker(deps: FleetReconcileDeps): Promise<FleetReconcilePlan['broker']> {
const homeDirectory = deps.homeDirectory ?? homedir();
const env = (deps.brokerSocketEnv ?? process.env) as NodeJS.ProcessEnv;
const configHome = env['XDG_CONFIG_HOME'] ?? join(homeDirectory, '.config');
const unitPath = join(configHome, 'systemd', 'user', 'mosaic-lease-broker.service');
const statPath = deps.statPath;
let unitInstalled = false;
let socketPresent = false;
try {
// Same principle as the socket probe: no seam → look at the real
// filesystem. A unit file placed by installFleet (or a by-path residue
// symlink resolving to it) satisfies stat().isFile().
unitInstalled = statPath ? await statPath(unitPath) : (await stat(unitPath)).isFile();
} catch {
unitInstalled = false;
}
try {
socketPresent = await brokerSocketPresent(deps, env);
} catch {
socketPresent = false;
}
return { unitInstalled, socketPresent };
}
async function observeFleet(
roster: FleetRosterV2,
deps: FleetReconcileDeps,
@@ -407,12 +325,10 @@ async function observeFleet(
'-F',
'#{session_name}',
]);
const broker = await observeBroker(deps);
if (sessionsResult.exitCode !== 0) {
return {
generation: roster.generation,
holder: 'missing',
broker,
agents: await observeAgents(roster, deps, new Set<string>()),
unmanagedSessions: [],
};
@@ -435,7 +351,6 @@ async function observeFleet(
return {
generation: roster.generation,
holder,
broker,
agents: await observeAgents(roster, deps, sessions),
unmanagedSessions: Object.freeze(unmanagedSessions.sort()),
};
@@ -592,17 +507,6 @@ async function executeExplicitLifecycle(
plan: FleetReconcilePlan,
agents: readonly FleetRosterV2Agent[],
): Promise<FleetReconcileResult> {
const lifecycleApplyFailed = (): FleetReconcileResult => ({
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'incomplete',
plan,
recovery: {
code: 'lifecycle-apply-failed',
action: 'rerun-after-inspecting-owned-resources',
},
});
if (request.command === 'start') {
for (const agent of agents) {
if (!agent.lifecycle.enabled) {
@@ -613,40 +517,6 @@ async function executeExplicitLifecycle(
}
}
}
// Broker FIRST (#1292): a gated runtime started without a running lease
// broker dies ~4 seconds in at registration — enable the unit (install
// places it) and start it before any holder/agent lifecycle effect.
try {
if (request.command === 'start') {
await runChecked(request.deps, 'systemctl', [
'--user',
'enable',
'mosaic-lease-broker.service',
]);
await runChecked(request.deps, 'systemctl', [
'--user',
'start',
'mosaic-lease-broker.service',
]);
}
} catch {
return lifecycleApplyFailed();
}
if (request.command === 'start') {
// Socket re-check after start, as a NAMED precondition (#1297 review
// F3) — the same protection the v1 path in commands/fleet.ts has had all
// along: the unit reporting active is not the signal; the socket is.
// Deliberately outside the try/catch above: a swallowed FleetReconcileError
// here read as a generic recoverable failure, hiding the named refusal.
// Runs BEFORE any holder/agent unit is touched so nothing doomed starts.
const env = (request.deps.brokerSocketEnv ?? process.env) as NodeJS.ProcessEnv;
if (!(await brokerSocketPresent(request.deps, env))) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'broker-absent: lease broker socket did not appear after enable+start (#1292; #1297 F3). Remedy: mosaic fleet install.',
);
}
}
try {
if (request.command === 'start' && plan.holder === 'missing') {
await runChecked(request.deps, 'systemctl', [
@@ -663,7 +533,17 @@ async function executeExplicitLifecycle(
]);
}
} catch {
return lifecycleApplyFailed();
return {
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'incomplete',
plan,
recovery: {
code: 'lifecycle-apply-failed',
action: 'rerun-after-inspecting-owned-resources',
},
};
}
return {
applied: true,
@@ -683,22 +563,6 @@ async function applyDesiredLifecycle(
(agent: FleetRosterV2Agent): boolean =>
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running',
);
// Broker before any running agent, same ordering and reason as the
// command-driven path above (#1292).
if (needsRunningAgent) {
await runChecked(deps, 'systemctl', ['--user', 'enable', 'mosaic-lease-broker.service']);
await runChecked(deps, 'systemctl', ['--user', 'start', 'mosaic-lease-broker.service']);
// Same socket re-check as the explicit start path (#1297 F3): apply with
// running desired agents starts gated runtimes too, and a broker that
// starts but never binds dooms them the same way.
const env = (deps.brokerSocketEnv ?? process.env) as NodeJS.ProcessEnv;
if (!(await brokerSocketPresent(deps, env))) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'broker-absent: lease broker socket did not appear after enable+start (#1292; #1297 F3). Remedy: mosaic fleet install.',
);
}
}
if (needsRunningAgent && plan.holder === 'missing') {
await runChecked(deps, 'systemctl', ['--user', 'start', 'mosaic-tmux-holder.service']);
}
-3
View File
@@ -61,9 +61,6 @@ export const STAGES = [
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test',
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh',
'bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh',
'bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh',
'bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh',
'bash packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh',
'bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh',
'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh',
],