Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98f3384554 | ||
|
|
4211fb87f8 | ||
|
|
ff40794bfe | ||
|
|
94a7d5b692 | ||
|
|
12d5258e20 | ||
|
|
f6fbeaf57a | ||
|
|
8a55c04108 | ||
|
|
bea47543f3 | ||
|
|
37cd00e60d | ||
|
|
f0d2dd9920 |
+1
-8
@@ -1,11 +1,4 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"integration_trunk": "next",
|
||||
"release_branch": "main",
|
||||
"flow": "trunk-release",
|
||||
"canonical_remote": "https://git.mosaicstack.dev/mosaicstack/stack",
|
||||
"canonical_clone": "host:/src/mosaic-stack",
|
||||
"worktree_root": "host:/src/mosaic-stack-worktrees",
|
||||
"worktree_policy": "orchestrator-precreated",
|
||||
"notes": "next=development/integration; main=production release. Never branch work off main. worktree_policy is TRANSITIONAL: the wrapper worktree consumer is BLOCKED on the J3/#1174 amendment (checked roots + capacity guard); pre-creation is the interim orchestration choice, not closed policy — it becomes a timing choice only after the wrapper can validate this root."
|
||||
"release_branch": "main"
|
||||
}
|
||||
|
||||
@@ -91,15 +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 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 —
|
||||
@@ -113,40 +104,6 @@ steps:
|
||||
# stub supplies the scale instead of the host's own checkout.
|
||||
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
|
||||
|
||||
# Canonical repo-structure declaration gate (T51 WP5c, spec §5.4 point 2):
|
||||
# .mosaic/repo.json is the machine-readable structure SSOT consumed by git
|
||||
# wrappers and the T32 gate seat; this is its repo-side CI enforcement.
|
||||
# Path-conditional: runs when the declaration, the vendored validator, or this
|
||||
# pipeline config changes (manual runs always include it). Fails the pipeline
|
||||
# on any VALIDATION_ERROR and enforces the schema_version 2 authoring rule
|
||||
# (--require-v2: edited/new declarations may not stay v1). The validator is
|
||||
# vendored into the framework tree (spec §5.1 final home) — provenance in its
|
||||
# header; the hostile-input suite (101 arms, hermetic) runs alongside so the
|
||||
# gate's own instrument ships in the same commit as the gate.
|
||||
structure-declaration:
|
||||
image: *node_image
|
||||
commands:
|
||||
- apk add --no-cache bash git
|
||||
# MOSAIC_HOST_ROOT is a runtime anchor (spec §1.2a: unset fails closed
|
||||
# for managed validation). CI has no host, so the step provisions an
|
||||
# EXPLICIT fixture root — honest configuration for the resolution path,
|
||||
# never a guess about a real host; the per-host containment checks are
|
||||
# runtime concerns and do not run against a fixture. Grammar, schema,
|
||||
# refs, flow, remote normalization, and path grammar all prove here.
|
||||
- mkdir -p /tmp/t51-ci-hostroot
|
||||
- bash packages/mosaic/framework/tools/structure/validate-repo-json.sh .mosaic/repo.json --require-v2
|
||||
- bash packages/mosaic/framework/tools/structure/test-validate-repo-json.sh
|
||||
environment:
|
||||
MOSAIC_HOST_ROOT: /tmp/t51-ci-hostroot
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include:
|
||||
- '.mosaic/repo.json'
|
||||
- 'packages/mosaic/framework/tools/structure/**'
|
||||
- '.woodpecker/ci.yml'
|
||||
- event: manual
|
||||
|
||||
# Canonical verify:release stage `upgrade-guard`.
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
|
||||
@@ -111,7 +111,7 @@ approve path carries the trap.) `pr-review.sh` sends the correct token for the d
|
||||
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
|
||||
placed.
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. For a repository with no CI configured at all, `pr-merge.sh --no-ci-expected` is the sanctioned merge path: it forwards to `ci-queue-wait.sh --no-ci-expected`, which reclassifies a zero-context merge head as queue-clear only when the acting token holds repository admin and `MOSAIC_GIT_IDENTITY` names the asserting identity (a caller without one is refused with exit 78 before the admin lookup), and records the assertion (or its refusal) in the same JSONL audit log. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
@@ -219,23 +219,6 @@ Multi-instance support: `-a <instance>` selects a named instance (e.g. `personal
|
||||
~/.config/mosaic/tools/health/stack-health.sh -f json
|
||||
```
|
||||
|
||||
### Repo Structure Declaration (T51)
|
||||
|
||||
```bash
|
||||
# Validate a .mosaic/repo.json declaration (schema v1/v2, host:/ grammar,
|
||||
# ref grammar, cross-field rules, remote normalization; spec §5)
|
||||
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json
|
||||
|
||||
# CI authoring rule: new/edited declarations must be schema_version 2
|
||||
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json --require-v2
|
||||
|
||||
# Display mode (warns and omits root-dependent checks when MOSAIC_HOST_ROOT unset)
|
||||
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json --mode display
|
||||
|
||||
# Hermetic hostile-input suite (101 arms)
|
||||
~/.config/mosaic/tools/structure/test-validate-repo-json.sh
|
||||
```
|
||||
|
||||
### Shared Credential Loader
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ci-queue-wait.sh - Wait until project CI queue is clear (no running/queued pipeline on branch head)
|
||||
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] [--no-ci-expected]
|
||||
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -14,11 +14,10 @@ TIMEOUT_SEC=900
|
||||
INTERVAL_SEC=15
|
||||
PURPOSE="merge"
|
||||
REQUIRE_STATUS=0
|
||||
NO_CI_EXPECTED=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] [--no-ci-expected]
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
|
||||
Options:
|
||||
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
||||
@@ -28,7 +27,6 @@ Options:
|
||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||
--purpose VALUE Log context: push|merge (default: merge)
|
||||
--require-status Fail if no CI status contexts are present
|
||||
--no-ci-expected Assert this repository has no CI configured: a merge guard on a zero-context head becomes queue-clear (requires the acting token to hold repository admin); refused with exit 78 when MOSAIC_GIT_IDENTITY is unset or empty
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
@@ -177,50 +175,6 @@ PY
|
||||
return 0
|
||||
}
|
||||
|
||||
# Durable audit record for an explicit no-CI assertion event (granted or
|
||||
# refused). Same JSONL sink and field shape as record_cannot_assert so one
|
||||
# reader covers all three outcomes; the outcome value distinguishes them.
|
||||
# rc 70 on an unwritable sink: a merge pass that cannot be audited must not
|
||||
# be reachable, mirroring record_cannot_assert's refusal of a degraded pass.
|
||||
record_assertion_event() {
|
||||
local outcome="$1" reason="$2" asserted_by="$3"
|
||||
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
|
||||
|
||||
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
|
||||
echo "Error: could not write ${outcome} audit record (audit directory unavailable at ${audit_log})." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if ! python3 - "$audit_log" "$outcome" "$reason" "$asserted_by" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path, outcome, reason, asserted_by, platform, purpose, branch, repo = sys.argv[1:]
|
||||
record = {
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"outcome": outcome,
|
||||
"reason": reason,
|
||||
"platform": platform,
|
||||
"purpose": purpose,
|
||||
"branch": branch,
|
||||
"repo": repo,
|
||||
"asserted_by": asserted_by,
|
||||
}
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
PY
|
||||
then
|
||||
echo "Error: could not write ${outcome} audit record at ${audit_log}; refusing to proceed unaudited." >&2
|
||||
return 70
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
github_get_branch_head_sha() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
@@ -228,24 +182,6 @@ github_get_branch_head_sha() {
|
||||
gh api "repos/${owner}/${repo}/branches/${branch}" --jq '.commit.sha'
|
||||
}
|
||||
|
||||
# Repository-admin state for the acting credential, GitHub flavor. The
|
||||
# repository object's permissions.admin is the field; read through the same
|
||||
# gh CLI the guard already authenticates with. rc 0 = admin, 1 = not admin
|
||||
# (or field absent), 2 = indeterminate (transport/API failure).
|
||||
github_repo_admin_state() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local perm
|
||||
if ! perm=$(gh api "repos/${owner}/${repo}" --jq '.permissions.admin' 2>/dev/null); then
|
||||
return 2
|
||||
fi
|
||||
case "$perm" in
|
||||
true) return 0 ;;
|
||||
false|null|"") return 1 ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
github_get_commit_status_json() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
@@ -370,41 +306,6 @@ gitea_get_commit_status_json() {
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
||||
}
|
||||
|
||||
# Repository-admin state for the acting credential, Gitea flavor. The guard's
|
||||
# existing fetches (branch head, combined status) carry no permissions object
|
||||
# (measured: neither response includes one), so the elevation check reads the
|
||||
# repository object's permissions.admin, the one documented carrier of that
|
||||
# field. rc 0 = admin, 1 = not admin (or field absent), 2 = indeterminate
|
||||
# (non-200 or unparseable).
|
||||
gitea_repo_admin_state() {
|
||||
local host="$1"
|
||||
local repo="$2"
|
||||
local token="$3"
|
||||
local url="https://${host}/api/v1/repos/${repo}"
|
||||
local resp code body
|
||||
resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") || return 2
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
return 2
|
||||
fi
|
||||
printf '%s' "$body" | python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit(2)
|
||||
if not isinstance(payload, dict):
|
||||
raise SystemExit(2)
|
||||
permissions = payload.get("permissions")
|
||||
if not isinstance(permissions, dict) or permissions.get("admin") is not True:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
'
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-B|--branch)
|
||||
@@ -435,10 +336,6 @@ while [[ $# -gt 0 ]]; do
|
||||
REQUIRE_STATUS=1
|
||||
shift
|
||||
;;
|
||||
--no-ci-expected)
|
||||
NO_CI_EXPECTED=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -468,10 +365,6 @@ if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||
echo "Error: --purpose must be push or merge." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$NO_CI_EXPECTED" -eq 1 && "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||
echo "Error: --no-ci-expected and --require-status contradict each other: one asserts the repository has no CI, the other demands status contexts. Pass at most one." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER="unknown"
|
||||
REPO="unknown"
|
||||
@@ -591,48 +484,6 @@ while true; do
|
||||
echo "[ci-queue-wait] queue-clear state=no-status purpose=push branch=${BRANCH}; no queued or running CI."
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$NO_CI_EXPECTED" -eq 1 ]]; then
|
||||
# Explicit, elevated, audit-visible assertion that this
|
||||
# repository has no CI to wait on. The zero-context case is
|
||||
# the ONLY state the flag reclassifies: a pending or failed
|
||||
# context still holds or fails exactly as without it, and a
|
||||
# non-admin token is refused rather than trusted.
|
||||
# The assertion must name an asserting identity: "unknown"
|
||||
# attributes nothing, so a caller with MOSAIC_GIT_IDENTITY
|
||||
# unset or empty is refused (exit 78) BEFORE the permission
|
||||
# lookup -- an unattributable caller never triggers that
|
||||
# network call.
|
||||
if [[ -z "${MOSAIC_GIT_IDENTITY:-}" ]]; then
|
||||
record_assertion_event "ASSERTION_UNATTRIBUTABLE" "actor-unattributable" "unknown" \
|
||||
|| echo "Warning: could not write the ASSERTION_UNATTRIBUTABLE audit record; the refusal itself stands." >&2
|
||||
echo "Error: ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=${BRANCH}; --no-ci-expected requires MOSAIC_GIT_IDENTITY to name the asserting identity and it is unset or empty (exit 78)." >&2
|
||||
exit 78
|
||||
fi
|
||||
ASSERTED_BY="${MOSAIC_GIT_IDENTITY}"
|
||||
ADMIN_STATE=2
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if github_repo_admin_state "$OWNER" "$REPO"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi
|
||||
else
|
||||
if gitea_repo_admin_state "$HOST" "$OWNER/$REPO" "$TOKEN"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi
|
||||
fi
|
||||
case "$ADMIN_STATE" in
|
||||
0)
|
||||
record_assertion_event "NO_CI_ASSERTED" "no-ci-expected" "$ASSERTED_BY" || exit $?
|
||||
echo "[ci-queue-wait] queue-clear state=no-status purpose=merge asserted-by=${ASSERTED_BY} reason=no-ci-expected branch=${BRANCH}"
|
||||
exit 0
|
||||
;;
|
||||
1)
|
||||
record_assertion_event "ASSERTION_REFUSED" "actor-not-repo-admin" "$ASSERTED_BY" \
|
||||
|| echo "Warning: could not write the ASSERTION_REFUSED audit record; the refusal itself stands." >&2
|
||||
echo "Error: ASSERTION_REFUSED state=no-status purpose=merge asserted-by=${ASSERTED_BY} reason=no-ci-expected branch=${BRANCH}; --no-ci-expected requires repository admin and the acting token is not an admin of ${OWNER}/${REPO} (exit 77)." >&2
|
||||
exit 77
|
||||
;;
|
||||
*)
|
||||
record_cannot_assert "repo-permissions-unavailable"
|
||||
exit $?
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
|
||||
@@ -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"
|
||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
fi
|
||||
gitea_issue_view_api
|
||||
else
|
||||
|
||||
@@ -49,27 +49,11 @@ 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 ;;
|
||||
esac
|
||||
fi
|
||||
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
|
||||
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
|
||||
|
||||
@@ -19,41 +19,6 @@ ISSUE=""
|
||||
|
||||
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
|
||||
|
||||
gitea_default_branch() {
|
||||
# Forge default branch for the current repo (T51-P2 WP5a / spec E4): the
|
||||
# API fallback must not guess a base. Empty output or any lookup failure
|
||||
# returns nonzero so the caller fails loud instead of mistargeting a PR.
|
||||
local host repo token url body branch
|
||||
host=$(get_remote_host) || return 1
|
||||
repo=$(get_repo_info) || return 1
|
||||
token=$(get_gitea_token "$host") || return 1
|
||||
url="https://${host}/api/v1/repos/${repo}"
|
||||
# Fetch and parse as separate steps (T51P2WP5AR B2): a piped
|
||||
# `curl | python` reports only python's status, so an HTTP failure that
|
||||
# still emits parseable JSON would masquerade as success. curl's own
|
||||
# exit status is authoritative here.
|
||||
if ! body=$(curl -fsS \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token ${token}" \
|
||||
"$url" 2>/dev/null); then
|
||||
return 1
|
||||
fi
|
||||
# A valid base is a NONBLANK JSON STRING (T51P2WP5AR B3): null, numbers,
|
||||
# and whitespace-only values are failed resolution, never a POSTed base.
|
||||
branch=$(printf '%s' "$body" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
value = json.load(sys.stdin).get("default_branch")
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
sys.exit(1)
|
||||
print(value.strip())
|
||||
' 2>/dev/null) || return 1
|
||||
[[ -n "$branch" ]] || return 1
|
||||
printf '%s' "$branch"
|
||||
}
|
||||
|
||||
gitea_pr_create_api() {
|
||||
local host repo token url payload
|
||||
host=$(get_remote_host) || {
|
||||
@@ -73,28 +38,14 @@ gitea_pr_create_api() {
|
||||
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
|
||||
fi
|
||||
|
||||
# Base resolution (spec E4): an explicit -B always wins; with none, the
|
||||
# forge default branch is resolved from the provider API -- never the
|
||||
# historical "main" literal, which mistargeted every fallback PR on
|
||||
# repos whose trunk is not main (e.g. mosaicstack/stack -> next).
|
||||
local api_base=""
|
||||
if [[ -n "$BASE_BRANCH" ]]; then
|
||||
api_base="$BASE_BRANCH"
|
||||
else
|
||||
api_base=$(gitea_default_branch) || {
|
||||
echo "Error: could not resolve the forge default branch for the API-fallback base; pass -B <branch> explicitly" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" API_BASE="$api_base" python3 - <<'PY'
|
||||
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" BASE_BRANCH="$BASE_BRANCH" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
payload = {
|
||||
"title": os.environ["TITLE"],
|
||||
"head": os.environ["HEAD_BRANCH"],
|
||||
"base": os.environ["API_BASE"],
|
||||
"base": os.environ["BASE_BRANCH"] or "main",
|
||||
}
|
||||
body = os.environ.get("BODY", "")
|
||||
if body:
|
||||
@@ -121,7 +72,7 @@ Create a pull request on the current repository (Gitea or GitHub).
|
||||
Options:
|
||||
-t, --title TITLE PR title (required, or use --issue)
|
||||
-b, --body BODY PR description/body
|
||||
-B, --base BRANCH Base branch to merge into (default: the forge repository's default branch)
|
||||
-B, --base BRANCH Base branch to merge into (default: main/master)
|
||||
-H, --head BRANCH Head branch with changes (default: current branch)
|
||||
-l, --labels LABELS Comma-separated labels
|
||||
-m, --milestone NAME Milestone name
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--no-ci-expected] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -16,7 +16,6 @@ DRY_RUN=false
|
||||
EXPECT_HEAD=""
|
||||
CO_AUTHOR_TRAILERS=false
|
||||
ESCALATE_TO=""
|
||||
NO_CI_EXPECTED=false
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -30,7 +29,6 @@ Options:
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
--no-ci-expected Assert the target repository has no CI: forward --no-ci-expected to the queue guard (requires repository admin)
|
||||
--co-author-trailers Build verified trailers from linked PR commit authors
|
||||
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
||||
-h, --help Show this help message
|
||||
@@ -72,10 +70,6 @@ while [[ $# -gt 0 ]]; do
|
||||
EXPECT_HEAD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-ci-expected)
|
||||
NO_CI_EXPECTED=true
|
||||
shift
|
||||
;;
|
||||
--co-author-trailers)
|
||||
CO_AUTHOR_TRAILERS=true
|
||||
shift
|
||||
@@ -160,18 +154,13 @@ if [[ "$DRY_RUN" != true ]]; then
|
||||
if [[ -z "$BASE_REPO" ]]; then
|
||||
BASE_REPO="$(get_repo_owner)/$(get_repo_name)"
|
||||
fi
|
||||
guard_args=(
|
||||
--purpose merge
|
||||
-B "$HEAD_BRANCH"
|
||||
-R "$BASE_REPO"
|
||||
--sha "$HEAD_SHA"
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||
--purpose merge \
|
||||
-B "$HEAD_BRANCH" \
|
||||
-R "$BASE_REPO" \
|
||||
--sha "$HEAD_SHA" \
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||
)
|
||||
if [[ "$NO_CI_EXPECTED" == true ]]; then
|
||||
guard_args+=(--no-ci-expected)
|
||||
fi
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" "${guard_args[@]}"
|
||||
fi
|
||||
|
||||
PLATFORM=$(detect_platform)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for ci-queue-wait.sh's --no-ci-expected assertion:
|
||||
# the sanctioned merge path for a repository with no CI configured at all.
|
||||
#
|
||||
# Zero status contexts ("no-status") stays fail-closed for --purpose merge
|
||||
# by default, because at merge time no-status can also mean "CI has not
|
||||
# reported yet". --no-ci-expected reclassifies ONLY that zero-context case
|
||||
# as queue-clear, and only for a caller whose acting token holds repository
|
||||
# admin. This harness pins:
|
||||
# (a) merge + no-status + flag + admin -> exit 0, audit line + JSONL.
|
||||
# (b) merge + no-status, no flag -> exit 3, existing text (unchanged).
|
||||
# (c) merge + no-status + flag + non-admin -> exit 77 ASSERTION_REFUSED
|
||||
# (distinct text, exit code NOT 3) + JSONL refusal record.
|
||||
# (c2) flag + admin payload without the admin field -> fail closed as (c).
|
||||
# (d) flag + --require-status -> usage error, before any network.
|
||||
# (e) flag + a real pending context -> still holds (timeout 124),
|
||||
# and the admin endpoint is never consulted.
|
||||
# (f) push + no-status, with and without the flag -> push queue-clear
|
||||
# unchanged; no admin consultation on push.
|
||||
# (g) flag + admin lookup unreachable -> CANNOT_ASSERT hold (75),
|
||||
# not a silent pass and not a refusal.
|
||||
# (h) flag + admin stub + NO MOSAIC_GIT_IDENTITY -> refusal BEFORE
|
||||
# queue-clear and BEFORE the admin lookup: exit 78, no queue-clear
|
||||
# line, an ASSERTION_UNATTRIBUTABLE JSONL record, no repos/ call.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-ci-expected}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
URL_LOG="$WORK_DIR/urls.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
# Same stub conventions as test-ci-queue-wait-no-status.sh; adds the
|
||||
# repository-object endpoint (admin state) selected by MOSAIC_STUB_ADMIN_MODE.
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
has_w=0
|
||||
url=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-w) has_w=1 ;;
|
||||
http://*|https://*) url="$arg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/branches/*)
|
||||
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
||||
if [[ "$has_w" == 1 ]]; then
|
||||
printf '%s\n200' "$body"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*/status)
|
||||
mode="${MOSAIC_STUB_STATUS_MODE:?MOSAIC_STUB_STATUS_MODE not set}"
|
||||
case "$mode" in
|
||||
no-status) body='{"state":"","statuses":[]}' ;;
|
||||
real-pending) body='{"state":"pending","statuses":[{"context":"ci/woodpecker","status":"running","target_url":""}]}' ;;
|
||||
*) echo "curl stub: unknown status mode=$mode" >&2; exit 2 ;;
|
||||
esac
|
||||
printf '%s' "$body"
|
||||
exit 0
|
||||
;;
|
||||
*/repos/*)
|
||||
mode="${MOSAIC_STUB_ADMIN_MODE:?MOSAIC_STUB_ADMIN_MODE not set}"
|
||||
case "$mode" in
|
||||
admin) body='{"permissions":{"admin":true,"push":true,"pull":true}}' ;;
|
||||
non-admin) body='{"permissions":{"admin":false,"push":true,"pull":true}}' ;;
|
||||
no-admin-field) body='{"permissions":{}}' ;;
|
||||
unreachable) exit 7 ;;
|
||||
*) echo "curl stub: unknown admin mode=$mode" >&2; exit 2 ;;
|
||||
esac
|
||||
if [[ "$has_w" == 1 ]]; then
|
||||
printf '%s\n200' "$body"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "curl stub: unrecognized URL: $url" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
|
||||
failures=0
|
||||
|
||||
run_guard() {
|
||||
local name="$1"; shift
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
|
||||
export MOSAIC_STUB_URL_LOG="$URL_LOG"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
export MOSAIC_GIT_IDENTITY="test-identity"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
|
||||
)
|
||||
}
|
||||
|
||||
# The suite exports test-identity globally, so the unattributable-caller
|
||||
# case must strip it from the child environment at invocation with env -u,
|
||||
# not rely on the export order.
|
||||
run_guard_no_identity() {
|
||||
local name="$1"; shift
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
|
||||
export MOSAIC_STUB_URL_LOG="$URL_LOG"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
export MOSAIC_GIT_IDENTITY="test-identity"
|
||||
env -u MOSAIC_GIT_IDENTITY \
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
|
||||
)
|
||||
}
|
||||
|
||||
expect_rc() {
|
||||
local name="$1" want="$2" got="$3"
|
||||
if [[ "$want" == "not3" ]]; then
|
||||
if [[ "$got" -eq 0 || "$got" -eq 3 ]]; then
|
||||
echo "FAIL $name: expected a refusal rc (nonzero, not 3), got $got" >&2
|
||||
failures=$((failures + 1))
|
||||
return 1
|
||||
fi
|
||||
elif [[ "$got" -ne "$want" ]]; then
|
||||
echo "FAIL $name: expected rc=$want, got rc=$got" >&2
|
||||
failures=$((failures + 1))
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
expect_text() {
|
||||
local name="$1" want="$2" output="$3" polarity="${4:-present}"
|
||||
if [[ "$polarity" == "present" && "$output" != *"$want"* ]]; then
|
||||
echo "FAIL $name: output missing '$want'" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
elif [[ "$polarity" == "absent" && "$output" == *"$want"* ]]; then
|
||||
echo "FAIL $name: output unexpectedly contains '$want'" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
repo_root_fetched() {
|
||||
grep -q 'repos/acme/widgets$' "$URL_LOG"
|
||||
}
|
||||
|
||||
# (a) merge + no-status + flag + admin -> exit 0, assertion line, JSONL record.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_a=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard a --purpose merge --no-ci-expected 2>&1)
|
||||
rc_a=$?
|
||||
set -u
|
||||
if expect_rc a 0 "$rc_a"; then
|
||||
expect_text a "queue-clear state=no-status purpose=merge asserted-by=test-identity reason=no-ci-expected branch=main" "$out_a"
|
||||
expect_text a "ASSERTED_NOT_READY" "$out_a" absent
|
||||
if ! grep -q '"outcome":"NO_CI_ASSERTED"' "$WORK_DIR/audit-a.jsonl" 2>/dev/null; then
|
||||
echo "FAIL a: expected a NO_CI_ASSERTED JSONL audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
elif ! grep -q '"asserted_by":"test-identity"' "$WORK_DIR/audit-a.jsonl"; then
|
||||
echo "FAIL a: audit record does not name the asserting identity" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# (b) merge + no-status, no flag -> exit 3, existing error text unchanged.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_b=$(MOSAIC_STUB_STATUS_MODE=no-status run_guard b --purpose merge 2>&1)
|
||||
rc_b=$?
|
||||
set -u
|
||||
if expect_rc b 3 "$rc_b"; then
|
||||
expect_text b "Error: ASSERTED_NOT_READY state=no-status purpose=merge branch=main." "$out_b"
|
||||
expect_text b "asserted-by" "$out_b" absent
|
||||
fi
|
||||
if repo_root_fetched; then
|
||||
echo "FAIL b: admin endpoint consulted without the flag" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# (c) merge + no-status + flag + non-admin -> distinct refusal, rc NOT 3.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_c=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard c --purpose merge --no-ci-expected 2>&1)
|
||||
rc_c=$?
|
||||
set -u
|
||||
if expect_rc c not3 "$rc_c"; then
|
||||
if [[ "$rc_c" -ne 77 ]]; then
|
||||
echo "FAIL c: expected the documented refusal rc=77, got $rc_c" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
expect_text c "ASSERTION_REFUSED state=no-status purpose=merge asserted-by=test-identity reason=no-ci-expected branch=main" "$out_c"
|
||||
expect_text c "ASSERTED_NOT_READY" "$out_c" absent
|
||||
if ! grep -q '"outcome":"ASSERTION_REFUSED"' "$WORK_DIR/audit-c.jsonl" 2>/dev/null; then
|
||||
echo "FAIL c: expected an ASSERTION_REFUSED JSONL audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# (c2) admin payload with no admin field -> fail closed as non-admin.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_c2=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=no-admin-field run_guard c2 --purpose merge --no-ci-expected 2>&1)
|
||||
rc_c2=$?
|
||||
set -u
|
||||
if expect_rc c2 77 "$rc_c2"; then
|
||||
expect_text c2 "ASSERTION_REFUSED" "$out_c2"
|
||||
fi
|
||||
|
||||
# (d) flag + --require-status -> usage error before any network I/O.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_d=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard d --purpose merge --no-ci-expected --require-status 2>&1)
|
||||
rc_d=$?
|
||||
set -u
|
||||
if expect_rc d 1 "$rc_d"; then
|
||||
expect_text d "--no-ci-expected and --require-status contradict" "$out_d"
|
||||
fi
|
||||
if [[ -s "$URL_LOG" ]]; then
|
||||
echo "FAIL d: usage error must precede every network call" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# (e) flag + a real pending context -> still holds; admin endpoint never asked.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_e=$(MOSAIC_STUB_STATUS_MODE=real-pending MOSAIC_STUB_ADMIN_MODE=admin run_guard e --purpose merge --no-ci-expected 2>&1)
|
||||
rc_e=$?
|
||||
set -u
|
||||
if expect_rc e 124 "$rc_e"; then
|
||||
expect_text e "ASSERTED_NOT_READY" "$out_e"
|
||||
expect_text e "ci/woodpecker=running" "$out_e"
|
||||
fi
|
||||
if repo_root_fetched; then
|
||||
echo "FAIL e: a pending context must not trigger the admin assertion" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# (f) push + no-status stays queue-clear, with and without the flag.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_f=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard f --purpose push 2>&1)
|
||||
rc_f=$?
|
||||
set -u
|
||||
if expect_rc f 0 "$rc_f"; then
|
||||
expect_text f "queue-clear state=no-status purpose=push branch=main; no queued or running CI." "$out_f"
|
||||
fi
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_f2=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard f2 --purpose push --no-ci-expected 2>&1)
|
||||
rc_f2=$?
|
||||
set -u
|
||||
if expect_rc f2 0 "$rc_f2"; then
|
||||
expect_text f2 "queue-clear state=no-status purpose=push branch=main; no queued or running CI." "$out_f2"
|
||||
expect_text f2 "asserted-by" "$out_f2" absent
|
||||
fi
|
||||
if repo_root_fetched; then
|
||||
echo "FAIL f: push must not consult the admin endpoint" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# (g) flag + admin lookup unreachable -> CANNOT_ASSERT hold (75), not a pass.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_g=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=unreachable run_guard g --purpose merge --no-ci-expected 2>&1)
|
||||
rc_g=$?
|
||||
set -u
|
||||
if expect_rc g 75 "$rc_g"; then
|
||||
expect_text g "CANNOT_ASSERT reason=repo-permissions-unavailable" "$out_g"
|
||||
fi
|
||||
if ! grep -q '"outcome":"CANNOT_ASSERT"' "$WORK_DIR/audit-g.jsonl" 2>/dev/null; then
|
||||
echo "FAIL g: expected a CANNOT_ASSERT JSONL audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# (h) flag + admin stub + no asserting identity -> refusal before queue-clear
|
||||
# and before the admin lookup: rc 78, no queue-clear line, an
|
||||
# ASSERTION_UNATTRIBUTABLE JSONL record, and zero repos/ network calls.
|
||||
: > "$URL_LOG"
|
||||
set +e
|
||||
out_h=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard_no_identity h --purpose merge --no-ci-expected 2>&1)
|
||||
rc_h=$?
|
||||
set -u
|
||||
if expect_rc h 78 "$rc_h"; then
|
||||
expect_text h "ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h"
|
||||
expect_text h "queue-clear" "$out_h" absent
|
||||
if ! grep -q '"outcome":"ASSERTION_UNATTRIBUTABLE"' "$WORK_DIR/audit-h.jsonl" 2>/dev/null; then
|
||||
echo "FAIL h: expected an ASSERTION_UNATTRIBUTABLE JSONL audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
if repo_root_fetched; then
|
||||
echo "FAIL h: an unattributable caller must not trigger the permission lookup" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "ci-queue-wait no-ci-expected regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ci-queue-wait no-ci-expected regression passed (all outcome classes)"
|
||||
@@ -100,15 +100,6 @@ case "$url" in
|
||||
*/commits/*/status)
|
||||
printf '{"state":"success","statuses":[{"context":"ci/mock","status":"success"}]}'
|
||||
;;
|
||||
# Repo roots: the pr-create API fallback resolves its base from the forge
|
||||
# default_branch (T51-P2 WP5a). Exact-suffix matches so the /pulls POST
|
||||
# endpoint (no trailing path) still falls through to the catch-all.
|
||||
*/api/v1/repos/USC/uconnect)
|
||||
printf '{"default_branch":"main"}'
|
||||
;;
|
||||
*/api/v1/repos/mosaicstack/stack)
|
||||
printf '{"default_branch":"next"}'
|
||||
;;
|
||||
*)
|
||||
printf '{}'
|
||||
;;
|
||||
@@ -119,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" \
|
||||
@@ -328,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
|
||||
@@ -358,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"
|
||||
|
||||
@@ -67,18 +67,7 @@ cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
# Repo roots: the pr-create API fallback resolves its base from the forge
|
||||
# default_branch (T51-P2 WP5a). Exact-suffix so every other endpoint keeps
|
||||
# the historical answer below.
|
||||
url="${*: -1}"
|
||||
case "$url" in
|
||||
*/api/v1/repos/mosaicstack/stack)
|
||||
printf '%s\n' '{"default_branch":"next"}'
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' '{"number":703}'
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' '{"number":703}'
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -1,178 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-pr-create-fallback-default-base.sh — hermetic test for the API-fallback
|
||||
# base resolution in pr-create.sh (T51-P2 WP5a / spec E4).
|
||||
#
|
||||
# The API-fallback payload historically hardcoded "base": "main", mistargeting
|
||||
# every fallback PR on repos whose trunk is not main (e.g. mosaicstack/stack,
|
||||
# default branch "next"). The fix: an explicit -B always wins; with none, the
|
||||
# base is resolved from the provider API default_branch, and a failed
|
||||
# resolution fails loud instead of guessing.
|
||||
#
|
||||
# Hermetic by construction: every curl invocation is a PATH-first stub; the
|
||||
# fixture repo's remote is git.example.test (never dialed); HOME is a sandbox
|
||||
# with no tea config (so the wrapper takes the API fallback path); GITEA_TOKEN
|
||||
# comes from the environment. No real forge is contacted.
|
||||
|
||||
# shellcheck disable=SC2317
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-fallback-base}"
|
||||
|
||||
PASS=0 FAIL=0 FAILED_CASES=""
|
||||
|
||||
ok() { PASS=$((PASS + 1)); }
|
||||
bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; }
|
||||
|
||||
assert_rc() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected rc=$e got rc=$a)"; }
|
||||
assert_eq() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected [$e] got [$a])"; }
|
||||
assert_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) ok ;; *) bad "$d (missing [$n])" ;; esac; }
|
||||
|
||||
json_field() { # $1 payload file, $2 field
|
||||
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$1" "$2"
|
||||
}
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
|
||||
# ---- fixture -----------------------------------------------------------------
|
||||
ROOT="$WORK_DIR/fixture"
|
||||
TOOLS="$ROOT/tools/git"
|
||||
mkdir -p "$TOOLS" "$ROOT/repo" "$ROOT/home" "$ROOT/stub"
|
||||
cp "$SCRIPT_DIR/pr-create.sh" "$TOOLS/pr-create.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS/detect-platform.sh"
|
||||
|
||||
git -C "$ROOT/repo" init -q -b fix/e4
|
||||
git -C "$ROOT/repo" -c user.name=fixture -c user.email=fixture@test commit -q --allow-empty -m base
|
||||
git -C "$ROOT/repo" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
# curl stub: GET repo -> default_branch JSON (or failure mode); POST pulls ->
|
||||
# capture payload, answer with a minimal PR JSON. Every call is logged.
|
||||
cat > "$ROOT/stub/curl" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
mode="\${CURL_STUB_GET_MODE:-ok}"
|
||||
printf '%s\n' "\$*" >> "$ROOT/curl-calls.log"
|
||||
url="\${!#}"
|
||||
if [[ "\$url" == */api/v1/repos/acme/widgets ]]; then
|
||||
# repo GET (default-branch resolution). Modes cover the value shapes the
|
||||
# resolver must accept or refuse (T51P2WP5AR B2/B3).
|
||||
case "\$mode" in
|
||||
ok) printf '%s\n' '{"id":1,"default_branch":"next","full_name":"acme/widgets"}' ;;
|
||||
fail) echo "curl stub: simulated repo lookup failure" >&2; exit 1 ;;
|
||||
fail-json) printf '%s\n' '{"default_branch":"next"}'; exit 22 ;;
|
||||
null) printf '%s\n' '{"default_branch":null}' ;;
|
||||
numeric) printf '%s\n' '{"default_branch":7}' ;;
|
||||
blank) printf '%s\n' '{"default_branch":" "}' ;;
|
||||
*) echo "curl stub: unknown GET mode \$mode" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
if [[ "\$url" == */api/v1/repos/acme/widgets/pulls ]]; then
|
||||
# PR POST: capture the payload, emit a PR-shaped answer
|
||||
while [[ \$# -gt 0 ]]; do
|
||||
case "\$1" in
|
||||
-d) printf '%s' "\$2" > "$ROOT/payload.json"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' '{"number":42,"html_url":"https://git.example.test/acme/widgets/pulls/42"}'
|
||||
exit 0
|
||||
fi
|
||||
echo "curl stub: unexpected URL \$url" >&2
|
||||
exit 1
|
||||
STUB
|
||||
chmod +x "$ROOT/stub/curl"
|
||||
|
||||
run_pr_create() { # args... -> sets RC/OUT/ERR
|
||||
RC=0
|
||||
OUT=$(cd "$ROOT/repo" && env -i \
|
||||
PATH="$ROOT/stub:/usr/bin:/bin" \
|
||||
HOME="$ROOT/home" \
|
||||
GITEA_TOKEN=stub-token \
|
||||
bash "$TOOLS/pr-create.sh" "$@" 2>"$ROOT/err.txt")
|
||||
RC=$?
|
||||
ERR="$(cat "$ROOT/err.txt")"
|
||||
}
|
||||
|
||||
calls_matching() { grep -c -- "$1" "$ROOT/curl-calls.log" 2>/dev/null || true; }
|
||||
|
||||
echo "== (1) no -B: fallback base resolves to the forge default branch, not main =="
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
run_pr_create -t "fix thing"
|
||||
assert_rc "rc" 0 "$RC"
|
||||
assert_contains "API fallback path taken (tea login unresolvable in fixture)" "$ERR" "trying Gitea API fallback"
|
||||
assert_eq "repo GET performed" 1 "$(calls_matching '/api/v1/repos/acme/widgets$')"
|
||||
assert_eq "POST performed" 1 "$(calls_matching '/pulls$')"
|
||||
assert_eq "payload base is forge default (next)" "next" "$(json_field "$ROOT/payload.json" base)"
|
||||
assert_eq "payload head" "fix/e4" "$(json_field "$ROOT/payload.json" head)"
|
||||
assert_eq "payload title" "fix thing" "$(json_field "$ROOT/payload.json" title)"
|
||||
|
||||
echo "== (2) explicit -B wins; the default branch is not consulted =="
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
run_pr_create -t "fix thing" -B release/1.x
|
||||
assert_rc "rc" 0 "$RC"
|
||||
assert_eq "repo GET not consulted for explicit base" 0 "$(calls_matching '/api/v1/repos/acme/widgets$')"
|
||||
assert_eq "payload base is the explicit -B" "release/1.x" "$(json_field "$ROOT/payload.json" base)"
|
||||
|
||||
echo "== (3) default-branch lookup failure: loud refusal, no POST =="
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
RC=0
|
||||
OUT=$(cd "$ROOT/repo" && env -i \
|
||||
PATH="$ROOT/stub:/usr/bin:/bin" \
|
||||
HOME="$ROOT/home" \
|
||||
GITEA_TOKEN=stub-token \
|
||||
CURL_STUB_GET_MODE=fail \
|
||||
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
|
||||
RC=$?
|
||||
ERR="$(cat "$ROOT/err.txt")"
|
||||
assert_rc "nonzero rc on unresolvable base" 1 "$RC"
|
||||
assert_contains "loud error names -B" "$ERR" "could not resolve the forge default branch"
|
||||
assert_contains "error names the remedy" "$ERR" "pass -B <branch> explicitly"
|
||||
assert_eq "no POST issued" 0 "$(calls_matching '/pulls$')"
|
||||
|
||||
echo "== (4) payload never contains the literal fallback main =="
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
run_pr_create -t "fix thing"
|
||||
assert_rc "rc" 0 "$RC"
|
||||
assert_eq "base field is next, never main" "next" "$(json_field "$ROOT/payload.json" base)"
|
||||
|
||||
echo "== (5) B2: HTTP failure with parseable JSON on stdout is a FAILED resolution =="
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
RC=0
|
||||
OUT=$(cd "$ROOT/repo" && env -i \
|
||||
PATH="$ROOT/stub:/usr/bin:/bin" \
|
||||
HOME="$ROOT/home" \
|
||||
GITEA_TOKEN=stub-token \
|
||||
CURL_STUB_GET_MODE=fail-json \
|
||||
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
|
||||
RC=$?
|
||||
ERR="$(cat "$ROOT/err.txt")"
|
||||
assert_rc "nonzero rc on HTTP failure despite valid JSON" 1 "$RC"
|
||||
assert_contains "loud error names -B" "$ERR" "could not resolve the forge default branch"
|
||||
assert_contains "error names the remedy" "$ERR" "pass -B <branch> explicitly"
|
||||
assert_eq "no POST issued" 0 "$(calls_matching '/pulls$')"
|
||||
|
||||
echo "== (6) B3: null / numeric / blank default_branch are failed resolutions =="
|
||||
for bad in null numeric blank; do
|
||||
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
|
||||
RC=0
|
||||
OUT=$(cd "$ROOT/repo" && env -i \
|
||||
PATH="$ROOT/stub:/usr/bin:/bin" \
|
||||
HOME="$ROOT/home" \
|
||||
GITEA_TOKEN=stub-token \
|
||||
CURL_STUB_GET_MODE="$bad" \
|
||||
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
|
||||
RC=$?
|
||||
ERR="$(cat "$ROOT/err.txt")"
|
||||
assert_rc "B3 $bad: nonzero rc" 1 "$RC"
|
||||
assert_contains "B3 $bad: loud error" "$ERR" "could not resolve the forge default branch"
|
||||
assert_eq "B3 $bad: no POST issued" 0 "$(calls_matching '/pulls$')"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "pass=$PASS fail=$FAIL"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "FAILED CASES:$FAILED_CASES"
|
||||
exit 1
|
||||
fi
|
||||
echo "ALL GREEN"
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# pr-merge must forward --no-ci-expected to the queue guard, and only then.
|
||||
# The flag is the sanctioned merge path for a repository with no CI configured
|
||||
# (see test-ci-queue-wait-no-ci-expected.sh for the guard-side semantics);
|
||||
# this harness pins only the pass-through: present when requested, absent when
|
||||
# not, with the rest of the guard invocation unchanged.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-no-ci-expected}"
|
||||
FIXTURE_DIR="$WORK_DIR/tools/git"
|
||||
CALL_LOG="$WORK_DIR/queue-call.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FIXTURE_DIR"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
||||
exit 42
|
||||
SH
|
||||
chmod +x "$FIXTURE_DIR"/*.sh
|
||||
|
||||
run_merge() {
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
||||
"$FIXTURE_DIR/pr-merge.sh" -n 123 "$@"
|
||||
) >/dev/null 2>&1
|
||||
}
|
||||
|
||||
fail=0
|
||||
|
||||
# With the flag: it must reach the guard invocation.
|
||||
: > "$CALL_LOG"
|
||||
set +e
|
||||
run_merge --no-ci-expected
|
||||
rc_with=$?
|
||||
set -e
|
||||
if [[ "$rc_with" -ne 42 ]]; then
|
||||
echo "FAIL(with): expected queue stub rc=42 to propagate, got $rc_with" >&2
|
||||
fail=1
|
||||
elif ! grep -q -- '--no-ci-expected' "$CALL_LOG"; then
|
||||
echo "FAIL(with): --no-ci-expected did not reach the queue guard" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
fail=1
|
||||
fi
|
||||
# The rest of the guard invocation is unchanged by the flag.
|
||||
for required in '--purpose merge' '-B fix/no-ci-fixture' '-R mosaicstack/stack' \
|
||||
'--sha 0123456789abcdef0123456789abcdef01234567'; do
|
||||
if ! grep -qF -- "$required" "$CALL_LOG"; then
|
||||
echo "FAIL(with): guard invocation lost '$required'" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Without the flag: it must NOT appear in the guard invocation.
|
||||
: > "$CALL_LOG"
|
||||
set +e
|
||||
run_merge
|
||||
rc_without=$?
|
||||
set -e
|
||||
if [[ "$rc_without" -ne 42 ]]; then
|
||||
echo "FAIL(without): expected queue stub rc=42 to propagate, got $rc_without" >&2
|
||||
fail=1
|
||||
elif grep -q -- '--no-ci-expected' "$CALL_LOG"; then
|
||||
echo "FAIL(without): --no-ci-expected reached the guard without being requested" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "pr-merge no-ci-expected pass-through regression passed"
|
||||
fi
|
||||
exit "$fail"
|
||||
@@ -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
|
||||
|
||||
@@ -29,10 +30,6 @@ packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh | unmeasured i
|
||||
# --- tools/tmux: require a live tmux server ---
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||
# The entry below is NOT covered by the #1017 signature block above: it was
|
||||
# signed by jarvis-enhance (dragon-lin, 2026-08-24) at a later base, for the
|
||||
# test added alongside the send-message.sh exact-target fix.
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-target.sh | requires a real tmux server on a throwaway socket, and specifically a MULTI-WINDOW session (the bug it guards is invisible on a single-window fixture); CI image ships no tmux; same burndown condition as its two siblings above
|
||||
|
||||
# --- single-suite directories: unmeasured in CI ---
|
||||
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-validate-repo-json.sh — hostile-input suite for the T51 declaration validator.
|
||||
# (Vendored with validate-repo-json.sh from mosaic-brain @ 515bcbab — see the
|
||||
# validator header for provenance.)
|
||||
#
|
||||
# Hermetic: all fixtures in a tracked mktemp sandbox removed by an EXIT trap
|
||||
# (pass and fail paths both — zero residue). No network, no real repos, no host
|
||||
# state mutated. MOSAIC_HOST_ROOT is set/unset per arm via env only.
|
||||
#
|
||||
# T51P2RW1: arms extended per review T51P2R1 (F1-F5): root gate for ordinary
|
||||
# v2 declarations (unset AND explicitly empty; display warns), git-grammar
|
||||
# branch arms (double slash, dot component, control byte), contract-escape
|
||||
# arms (list enums, invalid UTF-8, NaN — one VALIDATION_ERROR line, never a
|
||||
# traceback), remote normalization (.git/ ordering, port preservation), and
|
||||
# mirror-component fullmatch arms (trailing newline, control bytes).
|
||||
|
||||
set -u
|
||||
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
V="$HERE/validate-repo-json.sh"
|
||||
|
||||
PASS=0; FAIL=0; FAILED=""
|
||||
ok() { PASS=$((PASS+1)); }
|
||||
bad() { FAIL=$((FAIL+1)); FAILED="$FAILED $1"; printf 'FAIL: %s\n' "$1" >&2; }
|
||||
|
||||
SB=$(mktemp -d "${TMPDIR:-/tmp}/vrj-test.XXXXXX")
|
||||
trap 'rm -rf "$SB"' EXIT
|
||||
|
||||
fx() { printf '%s' "$2" > "$SB/$1"; }
|
||||
|
||||
run() { # [env KV=V ...] -- args...
|
||||
local envs=()
|
||||
while [ "$1" != "--" ]; do envs+=("$1"); shift; done; shift
|
||||
OUT=$(env "${envs[@]:-_=_}" bash "$V" "$@" 2>&1 < /dev/null; echo "__RC__$?")
|
||||
RC=${OUT##*__RC__}; OUT=${OUT%__RC__*}; OUT=${OUT%$'\n'}
|
||||
}
|
||||
expect_ok() { local d="$1"; shift; run "$@"; if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '^OK'; then ok; else bad "$d (rc=$RC out=$(printf '%s' "$OUT" | head -1))"; fi; }
|
||||
expect_err() { # desc expected-substring [env... -- args...]
|
||||
local d="$1" sub="$2"; shift 2
|
||||
run "$@"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR.*$sub"; then ok
|
||||
else bad "$d (rc=$RC, wanted error ~$sub, got: ${OUT%%$'\n'*})"; fi
|
||||
}
|
||||
expect_err_notrace() { # like expect_err, plus no traceback anywhere in output
|
||||
local d="$1" sub="$2"; shift 2
|
||||
run "$@"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR.*$sub" && ! printf '%s' "$OUT" | grep -q "Traceback"; then ok
|
||||
else bad "$d (rc=$RC, wanted clean error ~$sub, got: ${OUT%%$'\n'*})"; fi
|
||||
}
|
||||
|
||||
STACK='{"schema_version":2,"integration_trunk":"next","release_branch":"main","flow":"trunk-release","canonical_remote":"https://git.mosaicstack.dev/mosaicstack/stack","canonical_clone":"host:/src/mosaic-stack","worktree_root":"host:/src/mosaic-stack-worktrees","worktree_policy":"orchestrator-precreated","notes":"x"}'
|
||||
BRAIN='{"schema_version":2,"integration_trunk":"main","release_branch":"main","flow":"direct","canonical_remote":"https://git.example.invalid/acme/brain","canonical_clone":"host:/.mosaic","worktree_root":"host:/.mosaic-worktrees","worktree_policy":"orchestrator-precreated"}'
|
||||
ROOT="$SB/hostroot"; mkdir -p "$ROOT"
|
||||
|
||||
echo "== (0) syntax + version =="
|
||||
bash -n "$V" && ok || bad "bash -n"
|
||||
run -- --version; [ "$RC" = 0 ] && case "$OUT" in validate-repo-json\ *) ok ;; *) bad "version output" ;; esac || bad "version rc"
|
||||
|
||||
echo "== (1) spec examples: stack + brain OK (root set) =="
|
||||
fx stack.json "$STACK"; fx brain.json "$BRAIN"
|
||||
expect_ok a1 MOSAIC_HOST_ROOT=$ROOT -- "$SB/stack.json"
|
||||
expect_ok a2 MOSAIC_HOST_ROOT=$ROOT -- "$SB/brain.json"
|
||||
|
||||
echo "== (2) malformed JSON (stable contract, no traceback) =="
|
||||
fx bad.json '{"schema_version": 2, '
|
||||
expect_err_notrace b1 "json:" -- "$SB/bad.json"
|
||||
fx arr.json '[1,2]'
|
||||
expect_err_notrace b2 "top level" -- "$SB/arr.json"
|
||||
printf '\xff\xfe{"schema_version":2}' > "$SB/utf8.json"
|
||||
expect_err_notrace b3 "UTF-8" MOSAIC_HOST_ROOT=$ROOT -- "$SB/utf8.json"
|
||||
fx nan.json '{"schema_version":NaN}'
|
||||
expect_err_notrace b4 "malformed JSON" MOSAIC_HOST_ROOT=$ROOT -- "$SB/nan.json"
|
||||
|
||||
echo "== (3) unknown schema_version = ABSENT-loud =="
|
||||
fx v3.json "${STACK/schema_version\":2/schema_version\":3}"
|
||||
expect_err c1 "schema_version" MOSAIC_HOST_ROOT=$ROOT -- "$SB/v3.json"
|
||||
|
||||
echo "== (4) v1 mode + authoring rule (v1 consumes no paths: no root needed) =="
|
||||
fx v1.json '{"integration_trunk":"next","release_branch":"main"}'
|
||||
expect_ok d1 -- "$SB/v1.json"
|
||||
expect_err d2 "schema_version" -- --require-v2 "$SB/v1.json"
|
||||
fx v1x.json '{"integration_trunk":"next","release_branch":"main","notes":"no"}'
|
||||
expect_err d3 "x_extensions" -- "$SB/v1x.json"
|
||||
|
||||
echo "== (5) unknown top-level key rejected; x_extensions home OK =="
|
||||
fx unk.json "${STACK%\}*},\"typo_key\":1}"
|
||||
expect_err e1 "typo_key" MOSAIC_HOST_ROOT=$ROOT -- "$SB/unk.json"
|
||||
fx ext.json "${STACK%\}*},\"x_extensions\":{\"future\":true}}"
|
||||
expect_ok e2 MOSAIC_HOST_ROOT=$ROOT -- "$SB/ext.json"
|
||||
|
||||
echo "== (6) flow: required (no defaulting) + cross-field =="
|
||||
fx noflow.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); del d["flow"]; print(json.dumps(d))')"
|
||||
expect_err f1 "flow" MOSAIC_HOST_ROOT=$ROOT -- "$SB/noflow.json"
|
||||
fx xdirect.json "${STACK/\"trunk-release\"/\"direct\"}"
|
||||
expect_err f2 "direct" MOSAIC_HOST_ROOT=$ROOT -- "$SB/xdirect.json"
|
||||
fx xtr.json "${BRAIN/\"direct\"/\"trunk-release\"}"
|
||||
expect_err f3 "trunk-release" MOSAIC_HOST_ROOT=$ROOT -- "$SB/xtr.json"
|
||||
|
||||
echo "== (7) dot-segment / empty-segment / tilde escapes =="
|
||||
fx dots.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/../secrets\"}"
|
||||
expect_err g1 "dot segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dots.json"
|
||||
fx dot1.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/./mosaic-stack\"}"
|
||||
expect_err g2 "dot segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dot1.json"
|
||||
fx empty.json "${STACK/host:\/src\/mosaic-stack\"/host://src/mosaic-stack\"}"
|
||||
expect_err g3 "empty segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/empty.json"
|
||||
fx tild.json "${STACK/host:\/src\/mosaic-stack\"/~jw/src/mosaic-stack\"}"
|
||||
expect_err g4 "tilde" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tild.json"
|
||||
fx tailslash.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/mosaic-stack/\"}"
|
||||
expect_err g5 "empty segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tailslash.json"
|
||||
fx noanchor.json "${STACK/host:\/src\/mosaic-stack\"//src/mosaic-stack\"}"
|
||||
expect_err g6 "host:/" MOSAIC_HOST_ROOT=$ROOT -- "$SB/noanchor.json"
|
||||
|
||||
echo "== (8) branch-name grammar (delegated to git check-ref-format, F2) =="
|
||||
fx badbr.json "${STACK/\"next\"/\"bad..name\"}"
|
||||
expect_err h1 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/badbr.json"
|
||||
fx sp.json "${STACK/\"next\"/\"fea ture\"}"
|
||||
expect_err h2 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/sp.json"
|
||||
fx lock.json "${STACK/\"next\"/\"feature/x.lock\"}"
|
||||
expect_err h3 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/lock.json"
|
||||
fx slash.json "${STACK/\"next\"/\"feature/x\"}"
|
||||
expect_ok h4 MOSAIC_HOST_ROOT=$ROOT -- "$SB/slash.json"
|
||||
fx dslash.json "${STACK/\"next\"/\"feature//x\"}"
|
||||
expect_err h5 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dslash.json"
|
||||
fx hidden.json "${STACK/\"next\"/\"feature/.hidden\"}"
|
||||
expect_err h6 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/hidden.json"
|
||||
fx ctrl.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="feature/\x01x"; print(json.dumps(d))')"
|
||||
expect_err h7 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/ctrl.json"
|
||||
|
||||
echo "== (8b) reflog shorthand rejected independent of ambient checkout history (B1) =="
|
||||
# Hermetic repo WITH checkout history: proves '@{-1}' (which git would expand to
|
||||
# 'main' from THIS repo's reflog) is still refused by the pre-delegation gate.
|
||||
HISTREPO="$SB/histrepo"; mkdir -p "$HISTREPO"
|
||||
(cd "$HISTREPO" && git init -q -b main . \
|
||||
&& git -c user.name=t -c user.email=t@t commit -q --allow-empty -m m \
|
||||
&& git checkout -q -b feature/x \
|
||||
&& git checkout -q main \
|
||||
&& git check-ref-format --branch "@{-1}" >/dev/null 2>&1 && echo "ambient-expandable" || echo "not-expandable") \
|
||||
| grep -q ambient-expandable && ok || bad "fixture repo failed to make @{-1} expandable"
|
||||
fx atminus1.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="@{-1}"; print(json.dumps(d))')"
|
||||
# Run the validator from INSIDE the history repo via command substitution so the
|
||||
# assertion runs in the PARENT shell (T51P2R3 B1: the previous ( subshell ) form
|
||||
# mutated ok/bad counters only in a dead subshell — FAIL printed, suite rc 0).
|
||||
OUTX=$(cd "$HISTREPO" && MOSAIC_HOST_ROOT=$ROOT bash "$V" "$SB/atminus1.json" 2>&1 </dev/null; echo "__RC__$?")
|
||||
RCX=${OUTX##*__RC__}
|
||||
if [ "$RCX" = 1 ] && printf '%s' "$OUTX" | grep -q "VALIDATION_ERROR.*@{"; then ok
|
||||
else bad "@{-1} must be rejected inside a repo with checkout history (got rc=$RCX)"; fi
|
||||
fx atbrace.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="@{u}"; print(json.dumps(d))')"
|
||||
expect_err h9 "@{" MOSAIC_HOST_ROOT=$ROOT -- "$SB/atbrace.json"
|
||||
|
||||
echo "== (9) canonical_remote: userinfo, list-type, normalization (F3/F4) =="
|
||||
fx user.json "${STACK/https:\/\/git.mosaicstack.dev/https:\/\/bot:s3cret@git.mosaicstack.dev}"
|
||||
expect_err_notrace i1 "userinfo" MOSAIC_HOST_ROOT=$ROOT -- "$SB/user.json"
|
||||
fx listflow.json "${STACK/\"trunk-release\"/[\"trunk-release\"]}"
|
||||
expect_err_notrace i2 "flow" MOSAIC_HOST_ROOT=$ROOT -- "$SB/listflow.json"
|
||||
fx listpol.json "${STACK/\"orchestrator-precreated\"/[\"tool-managed\"]}"
|
||||
expect_err_notrace i3 "worktree_policy" MOSAIC_HOST_ROOT=$ROOT -- "$SB/listpol.json"
|
||||
run -- --normalize-remote "HTTPS://Git.Example.Invalid/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid/o/r" ] && ok || bad "norm .git/case ($OUT)"
|
||||
run -- --normalize-remote "https://git.mosaicstack.dev/mosaicstack/stack/"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.mosaicstack.dev/mosaicstack/stack" ] && ok || bad "norm trailing slash ($OUT)"
|
||||
run -- --normalize-remote "git.mosaicstack.dev/mosaicstack/stack"
|
||||
[ "$RC" = 1 ] && ok || bad "schemeless must fail"
|
||||
run -- --normalize-remote "HTTPS://Git.Example.Invalid/o/r.git/"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid/o/r" ] && ok || bad "norm .git-then-slash ($OUT)"
|
||||
run -- --normalize-remote "https://Git.Example.Invalid:8443/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid:8443/o/r" ] && ok || bad "port must be preserved ($OUT)"
|
||||
run -- --normalize-remote "https://[2001:db8::1]:8443/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://[2001:db8::1]:8443/o/r" ] && ok || bad "IPv6 must stay bracketed with port ($OUT)"
|
||||
run -- --normalize-remote "https://[2001:db8::1]/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://[2001:db8::1]/o/r" ] && ok || bad "IPv6 must stay bracketed ($OUT)"
|
||||
run -- --normalize-remote "https://Git.Example.Invalid:0/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid:0/o/r" ] && ok || bad "explicit port 0 must be preserved ($OUT)"
|
||||
run -- --normalize-remote "https://[::1].evil.example/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "suffix after ] must be rejected (.evil.example)"
|
||||
run -- --normalize-remote "https://[::1]x:8443/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "suffix after ] must be rejected (x:8443)"
|
||||
run -- --normalize-remote "https://[::1]x/o/r.git"
|
||||
[ "$RC" = 1 ] && ok || bad "suffix after ] must be rejected (x)"
|
||||
echo "== (9b) IPvFuture bracketed authorities (R4-B1: guard keys off raw netloc) =="
|
||||
run -- --normalize-remote "https://[v1.fe80]/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://[v1.fe80]/o/r" ] && ok || bad "valid IPvFuture must keep brackets ($OUT)"
|
||||
run -- --normalize-remote "https://[vF.foo]:8443/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://[vf.foo]:8443/o/r" ] && ok || bad "valid IPvFuture+port must keep brackets ($OUT)"
|
||||
run -- --normalize-remote "https://[v1.fe80]evil/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (evil)"
|
||||
run -- --normalize-remote "https://[v1.fe80].evil.example/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (.evil.example)"
|
||||
run -- --normalize-remote "https://[vF.foo]x:8443/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (x:8443)"
|
||||
echo "== (9d) non-bracketed authority grammar (R6-B1) =="
|
||||
for U in "https://:8443/o/r.git" "https://bad host/o/r.git" "https://bad^host/o/r.git" "https://bad\\host/o/r.git" "https://bad%zz/o/r.git" "https://bad%2/o/r.git" "https://bad%/o/r.git"; do
|
||||
run -- --normalize-remote "$U"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
|
||||
else bad "non-bracketed authority must be rejected: $U (rc=$RC out=$OUT)"; fi
|
||||
done
|
||||
run -- --normalize-remote "https://git.mosaicstack.dev:9000/mosaicstack/stack"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.mosaicstack.dev:9000/mosaicstack/stack" ] && ok || bad "valid host:port unchanged ($OUT)"
|
||||
run -- --normalize-remote "https://192.168.1.10:8443/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://192.168.1.10:8443/o/r" ] && ok || bad "IPv4 reg-name stays valid ($OUT)"
|
||||
run -- --normalize-remote "https://bad%2Fx/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://bad%2fx/o/r" ] && ok || bad "complete %HH must stay legal, case-normalized ($OUT)"
|
||||
echo "== (9e) ASCII-only authority bytes (R7-B1) =="
|
||||
# isolated port arm (R8): VALID ASCII host + full-width-digit port ONLY —
|
||||
# unconfounded, so restoring Unicode-aware isdigit() goes red right here.
|
||||
run -- --normalize-remote "https://git.example.invalid:443/o/r.git"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "ASCII digits only"; then ok
|
||||
else bad "full-width-digit port on a VALID host must be rejected with the port reason (rc=$RC out=$OUT)"; fi
|
||||
for U in "https://éxample.invalid/o/r.git" "https://例え.テスト/o/r.git" "https://full-width.invalid/o/r.git" "https://mosaic-stack.dev:443/o/r.git"; do
|
||||
run -- --normalize-remote "$U"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
|
||||
else bad "non-ASCII authority must be rejected: $U (rc=$RC out=$OUT)"; fi
|
||||
done
|
||||
run -- --normalize-remote "https://xn--xample-9ua.invalid/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://xn--xample-9ua.invalid/o/r" ] && ok || bad "punycode xn-- host must stay legal ($OUT)"
|
||||
echo "== (9c) bracket-payload grammar + raw control bytes (R5-B1) =="
|
||||
for P in "v1. " "v1.a b" "v1.a^b" "v1.a\\b" "v1.%20" "not-an-ip" "::gg::1"; do
|
||||
run -- --normalize-remote "https://[$P]/o/r.git"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
|
||||
else bad "bracket payload [$P] must be rejected (rc=$RC out=$OUT)"; fi
|
||||
done
|
||||
for CB in $'\t' $'\n' $'\r'; do
|
||||
run -- --normalize-remote "https://[v1.a${CB}b]/o/r.git"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "control byte"; then ok
|
||||
else bad "raw control byte must be rejected before urlsplit (rc=$RC out=$OUT)"; fi
|
||||
done
|
||||
run -- --normalize-remote "https://[v1.fe80%zone]/o/r.git"
|
||||
[ "$RC" = 1 ] && ok || bad "percent (not in RFC host grammar) must be rejected ($OUT)"
|
||||
run -- --normalize-remote "https://[fe80::1%eth0]/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPv6 zone-id (not RFC host grammar) must be rejected ($OUT)"
|
||||
|
||||
echo "== (10) root gate: every v2 managed validation fails closed (F1) =="
|
||||
# NOTE (spec §4.5): host:/ paths resolve UNDER MOSAIC_HOST_ROOT by construction,
|
||||
# so tool-managed is not declarable today — the cross-check fails for every
|
||||
# host:/ root until the anchor scheme grows an outside-root form (J3 era).
|
||||
expect_err j1 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/stack.json"
|
||||
expect_err j2 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/brain.json"
|
||||
expect_err j3 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/stack.json"
|
||||
# rider (T51P2R2): genuine ABSENCE, not just explicitly empty — captured via
|
||||
# command substitution, asserted in the parent shell (no subshell-counter shape).
|
||||
OUTU=$(cd "$SB" && env -u MOSAIC_HOST_ROOT bash "$V" "$SB/stack.json" 2>&1 </dev/null; echo "__RC__$?")
|
||||
RCU=${OUTU##*__RC__}
|
||||
if [ "$RCU" = 1 ] && printf '%s' "$OUTU" | grep -q "VALIDATION_ERROR.*MOSAIC_HOST_ROOT"; then ok
|
||||
else bad "unset-by-absence root must fail closed in managed mode (rc=$RCU)"; fi
|
||||
run MOSAIC_HOST_ROOT= -- --mode display "$SB/stack.json"
|
||||
if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '^OK' && printf '%s' "$OUT" | grep -q "host root unset"; then ok
|
||||
else bad "display-mode unset must pass with the specified warning (rc=$RC)"; fi
|
||||
run MOSAIC_HOST_ROOT= -- --mode display "$SB/brain.json"
|
||||
if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q "host root unset"; then ok
|
||||
else bad "display-mode warn missing for brain fixture"; fi
|
||||
TM='{"schema_version":2,"integration_trunk":"next","release_branch":"main","flow":"trunk-release","canonical_remote":"https://git.mosaicstack.dev/mosaicstack/stack","canonical_clone":"host:/src/mosaic-stack","worktree_root":"host:/src/mosaic-stack-worktrees","worktree_policy":"tool-managed"}'
|
||||
fx tm.json "$TM"; mkdir -p "$ROOT/src"
|
||||
expect_err j4 "inside MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tm.json"
|
||||
fx tm_noroot.json "$(printf '%s' "$TM" | python3 -c 'import json,sys; d=json.load(sys.stdin); del d["worktree_root"]; print(json.dumps(d))')"
|
||||
expect_err j5 "worktree_root" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tm_noroot.json"
|
||||
echo "== (10b) symlink escape cannot fake outside-ness (B3 fix: lexical containment) =="
|
||||
OUTSIDE="$SB/outside-target"; mkdir -p "$OUTSIDE"
|
||||
ln -s "$OUTSIDE" "$ROOT/escape"
|
||||
TM_ESC="${TM/host:\/src\/mosaic-stack-worktrees/host:/escape/worktrees}"
|
||||
fx tmsym.json "$TM_ESC"
|
||||
expect_err j6 "inside MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tmsym.json"
|
||||
|
||||
echo "== (11) mirror-path components: collision/delimiter/control fixtures (F5) =="
|
||||
run -- --mirror-path git.mosaicstack.dev mosaicstack stack
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "projects/git.mosaicstack.dev/mosaicstack/stack/repo.json" ] && ok || bad "mirror path ok ($OUT)"
|
||||
expect_err k1 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "a__b" "c"
|
||||
expect_err k2 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "a" "b__c"
|
||||
expect_err k3 "mirror-component" -- --mirror-path "git.mosaicstack.dev/x" "a" "b"
|
||||
expect_err k4 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "A" "B"
|
||||
expect_err k5 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "" "stack"
|
||||
run -- --mirror-path git.mosaicstack.dev a b.c
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "projects/git.mosaicstack.dev/a/b.c/repo.json" ] && ok || bad "distinct path ($OUT)"
|
||||
run -- --mirror-path $'git.example.invalid\n' owner repo
|
||||
[ "$RC" = 1 ] && ok || bad "trailing-newline host must be rejected (fullmatch)"
|
||||
run -- --mirror-path $'git.\texample' owner repo
|
||||
[ "$RC" = 1 ] && ok || bad "control-byte host must be rejected"
|
||||
|
||||
echo "== (12) missing required keys =="
|
||||
for key in release_branch canonical_clone; do
|
||||
fx miss.json "$(printf '%s' "$STACK" | python3 -c "import json,sys; d=json.load(sys.stdin); del d['$key']; print(json.dumps(d))")"
|
||||
expect_err "l-$key" "$key" MOSAIC_HOST_ROOT=$ROOT -- "$SB/miss.json"
|
||||
done
|
||||
|
||||
echo "== (13) absent file =="
|
||||
expect_err m1 "file" -- "$SB/nonexistent.json"
|
||||
|
||||
echo
|
||||
echo "pass=$PASS fail=$FAIL"
|
||||
if [ "$FAIL" -gt 0 ]; then echo "FAILED:$FAILED"; exit 1; fi
|
||||
echo "ALL GREEN"
|
||||
@@ -1,386 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# validate-repo-json.sh — declaration validator for T51 repo structure declarations.
|
||||
#
|
||||
# Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md @ 1896adc1
|
||||
# (R3). Implements the spec's validation surface: schema v1/v2 (§1.2), host:/
|
||||
# path grammar with canonical segment normalization — empty/./.. rejected
|
||||
# BEFORE resolution — and tilde rejection (§1.2a), mirror path component
|
||||
# validation (§3.1), cross-field rules (§5.2), remote normalization (§5.3).
|
||||
#
|
||||
# PROVENANCE (T51 WP5c vendoring): ported verbatim from the mosaic-brain tree —
|
||||
# tools/repo-structure-decl/validate-repo-json.sh @ brain main merge 515bcbab
|
||||
# (PR 28, wave-1 R9 PASS, 101-arm suite green). This file is now the framework
|
||||
# home per spec §5.1 ("shipped in the framework package"); the brain copy is the
|
||||
# development origin. Re-sync rule: changes land here via reviewed PR and are
|
||||
# back-ported to the brain tree (or the brain copy retires) — never fork silently.
|
||||
# Validator version at port: 1.1.0+t51spec-r3+t51p2rw1 (R2-R8 rework included).
|
||||
# No operator literal appears in this file; the host root is read from
|
||||
# MOSAIC_HOST_ROOT configuration only.
|
||||
#
|
||||
# Unset-root semantics (spec §1.2a, fail-closed; T51P2R1 F1): v2 declarations
|
||||
# always consume a path (canonical_clone is required), so in --mode managed an
|
||||
# unset OR EMPTY MOSAIC_HOST_ROOT is a VALIDATION_ERROR for every v2 file —
|
||||
# not only tool-managed. In --mode display it warns and omits root-dependent
|
||||
# resolution; grammar checks still run.
|
||||
#
|
||||
# Error contract (T51P2R1 F3): every malformed input — bad UTF-8, non-RFC JSON
|
||||
# constants (NaN/Infinity), wrong-typed enums, anything unexpected — yields
|
||||
# exactly one stable VALIDATION_ERROR line and exit 1. No traceback ever
|
||||
# escapes. Branch names are validated by delegating to `git check-ref-format
|
||||
# --branch` (F2), translated into this contract.
|
||||
#
|
||||
# Usage:
|
||||
# validate-repo-json.sh <repo.json> [--mode managed|display] [--require-v2]
|
||||
# validate-repo-json.sh --mirror-path <host> <owner> <repo> # §3.1 component check
|
||||
# validate-repo-json.sh --normalize-remote <url> # §5.3, prints normalized
|
||||
# validate-repo-json.sh --version
|
||||
#
|
||||
# Output: OK (exit 0) | VALIDATION_ERROR <key>: <reason> (exit 1) | warnings on stderr.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.1.0+t51spec-r3+t51p2rw1"
|
||||
|
||||
if [ "${1:-}" = "--version" ]; then echo "validate-repo-json $VERSION"; exit 0; fi
|
||||
|
||||
exec python3 - "$@" <<'PYEOF'
|
||||
import json, os, re, subprocess, sys, urllib.parse
|
||||
|
||||
def err(key, reason):
|
||||
print(f"VALIDATION_ERROR {key}: {reason}")
|
||||
sys.exit(1)
|
||||
|
||||
def warn(msg):
|
||||
print(f"warning: {msg}", file=sys.stderr)
|
||||
|
||||
def main():
|
||||
ARGS = sys.argv[1:]
|
||||
MODE = "managed"
|
||||
REQUIRE_V2 = False
|
||||
|
||||
# ---- subcommands first (they take no file argument) ----
|
||||
def check_mirror_components(host, owner, repo):
|
||||
# fullmatch: '$' must bind at true end (F5 — a trailing newline must
|
||||
# NOT pass); the charset excludes control bytes outright.
|
||||
comp_re = re.compile(r"[a-z0-9][a-z0-9.-]*")
|
||||
for label, value in (("host", host), ("owner", owner), ("repo", repo)):
|
||||
if not isinstance(value, str) or not value or not comp_re.fullmatch(value):
|
||||
err("mirror-component", f"{label} {value!r} fails §3.1 charset ^[a-z0-9][a-z0-9.-]*$ (fullmatch, no '/', no delimiter, no control bytes)")
|
||||
return f"projects/{host}/{owner}/{repo}/repo.json"
|
||||
|
||||
def normalize_remote(url):
|
||||
# R5-B1 part 1: reject raw control bytes BEFORE urlsplit — urlsplit
|
||||
# silently strips TAB/LF/CR, so different input bytes would normalize
|
||||
# to a different host. The raw bytes ARE the input; nothing may rewrite them.
|
||||
for ch in url:
|
||||
if ord(ch) < 0x20 or ord(ch) == 0x7F:
|
||||
err("canonical_remote", "control byte in URL rejected before parsing (urlsplit would strip it and change the host)")
|
||||
try:
|
||||
p = urllib.parse.urlsplit(url)
|
||||
except ValueError as e:
|
||||
# py3.12 urlsplit itself validates bracketed hosts (ipaddress) and
|
||||
# raises for garbage authorities — translate, never traceback.
|
||||
err("canonical_remote", f"invalid URL authority: {e}")
|
||||
if not p.scheme or not p.netloc:
|
||||
err("canonical_remote", f"not a URL with scheme+host: {url!r}")
|
||||
if p.username or p.password or "@" in (p.netloc or ""):
|
||||
err("canonical_remote", "userinfo in URL is rejected (§5.3)")
|
||||
scheme = p.scheme.lower()
|
||||
# T51P2R4 B1: bracketing is detected from the RAW netloc ('[' prefix),
|
||||
# not from a ':' in the parsed hostname — IPvFuture literals ([v1.fe80])
|
||||
# contain no colon and must not bypass the raw-authority proof.
|
||||
bracketed = p.netloc.startswith("[")
|
||||
if bracketed:
|
||||
# Prove the RAW authority is exactly '[host]' + optional ':port'
|
||||
# (case-normalized); any text after ']' is hostile/truncated input,
|
||||
# rejected — never silently rewritten.
|
||||
import re as _re
|
||||
import ipaddress as _ip
|
||||
m = _re.fullmatch(r"\[([^\]]*)\](?::([0-9]+))?", p.netloc)
|
||||
if not m:
|
||||
err("canonical_remote", f"malformed bracketed authority {p.netloc!r}: text after ']' is rejected (no silent truncation)")
|
||||
payload = m.group(1)
|
||||
# R5-B1 part 2: the bracket payload must be a REAL RFC literal —
|
||||
# an IPv6 address (ipaddress parse) or an IPvFuture literal
|
||||
# ("v" + HEXDIG+ + "." + unreserved / sub-delims / ":" only).
|
||||
# Anything else inside brackets is rejected, closing the payload
|
||||
# grammar as a class.
|
||||
if _re.fullmatch(r"v[0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]*", payload):
|
||||
pass # IPvFuture (case-normalized below)
|
||||
elif _re.fullmatch(r"[0-9A-Fa-f:.]+", payload):
|
||||
# strict IPv6 lexical form (hex/colon/dot only — ipaddress alone
|
||||
# would also accept scoped zone-ids like fe80::1%eth0, which are
|
||||
# not valid URI host grammar unless %25-encoded)
|
||||
try:
|
||||
_ip.IPv6Address(payload)
|
||||
except ValueError:
|
||||
err("canonical_remote",
|
||||
f"bracket payload {payload!r} is not a valid IPv6 address")
|
||||
else:
|
||||
err("canonical_remote",
|
||||
f"bracket payload {payload!r} is neither a valid IPv6 address nor an IPvFuture literal (v+HEXDIG+.+unreserved/sub-delims/colon)")
|
||||
host = f"[{payload.lower()}]" # brackets preserved (IPv6 + IPvFuture)
|
||||
else:
|
||||
# R6-B1: the non-bracketed branch — urlsplit PARSES but does not
|
||||
# VALIDATE reg-name, and netloc-nonempty is not host presence.
|
||||
# Split the raw authority ourselves (host[:port]) and validate the
|
||||
# raw host against real grammar: unreserved / sub-delims / complete
|
||||
# %HH octets (reg-name), or IPv4 dotted-quad (reg-name's numeric
|
||||
# case). Port must be all digits. No branch trusts urlsplit alone.
|
||||
import re as _re
|
||||
raw_host, sep, raw_port = p.netloc.rpartition(":")
|
||||
if sep and _re.fullmatch(r"[0-9]+", raw_port):
|
||||
pass # host:port split
|
||||
elif sep:
|
||||
err("canonical_remote", f"invalid port {raw_port!r} in authority {p.netloc!r} (ASCII digits only)")
|
||||
else:
|
||||
raw_host, raw_port = p.netloc, None
|
||||
if not raw_host:
|
||||
err("canonical_remote", f"empty host in authority {p.netloc!r}")
|
||||
# strict reg-name / IPv4 scan: unreserved + sub-delims, with '%'
|
||||
# only inside complete %HH octets (IPv4 dotted-quad is a subset of
|
||||
# this charset — digits and dots — so one scan covers both).
|
||||
import re as _re
|
||||
i = 0
|
||||
ok_host = True
|
||||
while i < len(raw_host):
|
||||
c = raw_host[i]
|
||||
if c == "%":
|
||||
if i + 2 >= len(raw_host) or not _re.fullmatch(r"[0-9A-Fa-f]{2}", raw_host[i+1:i+3]):
|
||||
ok_host = False; break
|
||||
i += 3
|
||||
elif c in "!$&'()*+,;=-._~" or ("a" <= c <= "z") or ("A" <= c <= "Z") or ("0" <= c <= "9"):
|
||||
# R7-B1: EXPLICIT ASCII only — str.isalnum() is Unicode-aware
|
||||
# and admits non-ASCII letters/digits (é, full-width 0). Policy
|
||||
# is ASCII-only reg-name; punycode xn-- is the sanctioned
|
||||
# Unicode spelling and remains legal under this charset.
|
||||
i += 1
|
||||
else:
|
||||
ok_host = False; break
|
||||
if not ok_host:
|
||||
err("canonical_remote", f"host {raw_host!r} is not valid reg-name/IPv4 grammar (unreserved/sub-delims/complete %HH only)")
|
||||
host = raw_host.lower()
|
||||
port = p.port # None when absent; preserved whenever explicitly present (B2: incl. 0)
|
||||
authority = host + (f":{port}" if port is not None else "")
|
||||
path = p.path or "/"
|
||||
# canonical trailing-slash + .git strip as ONE operation (F4): slash
|
||||
# first, then .git, then any slash exposed by that strip.
|
||||
path = path.rstrip("/")
|
||||
if path.endswith(".git"):
|
||||
path = path[:-4].rstrip("/")
|
||||
return f"{scheme}://{authority}{path or ''}"
|
||||
|
||||
if "--mirror-path" in ARGS:
|
||||
idx = ARGS.index("--mirror-path")
|
||||
parts = ARGS[idx + 1:]
|
||||
if len(parts) != 3:
|
||||
err("usage", "--mirror-path takes <host> <owner> <repo>")
|
||||
print(check_mirror_components(*parts))
|
||||
sys.exit(0)
|
||||
if "--normalize-remote" in ARGS:
|
||||
idx = ARGS.index("--normalize-remote")
|
||||
vals = ARGS[idx + 1:]
|
||||
if len(vals) != 1:
|
||||
err("usage", "--normalize-remote takes <url>")
|
||||
print(normalize_remote(vals[0]))
|
||||
sys.exit(0)
|
||||
|
||||
# ---- arg parsing ----
|
||||
if not ARGS:
|
||||
err("usage", "a repo.json path is required")
|
||||
path = None
|
||||
i = 0
|
||||
while i < len(ARGS):
|
||||
a = ARGS[i]
|
||||
if a == "--mode":
|
||||
i += 1
|
||||
if i >= len(ARGS) or ARGS[i] not in ("managed", "display"):
|
||||
err("usage", "--mode takes managed|display")
|
||||
MODE = ARGS[i]
|
||||
elif a == "--require-v2":
|
||||
REQUIRE_V2 = True
|
||||
elif a.startswith("--"):
|
||||
err("usage", f"unknown option {a}")
|
||||
else:
|
||||
if path is not None:
|
||||
err("usage", "multiple file arguments")
|
||||
path = a
|
||||
i += 1
|
||||
if path is None:
|
||||
err("usage", "a repo.json path is required")
|
||||
|
||||
# ---- load: strict UTF-8, strict RFC JSON (F3) ----
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
raw_bytes = fh.read()
|
||||
except OSError as e:
|
||||
err("file", str(e))
|
||||
try:
|
||||
raw = raw_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as e:
|
||||
err("json", f"invalid UTF-8: {e}")
|
||||
def _reject_constant(name):
|
||||
raise ValueError(f"non-RFC JSON constant {name}")
|
||||
try:
|
||||
doc = json.loads(raw, parse_constant=_reject_constant)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
err("json", f"malformed JSON: {e}")
|
||||
if not isinstance(doc, dict):
|
||||
err("json", "top level must be an object")
|
||||
|
||||
HOST_ROOT = os.environ.get("MOSAIC_HOST_ROOT", "")
|
||||
|
||||
V1_KEYS = {"integration_trunk", "release_branch"}
|
||||
V2_REQUIRED = ["schema_version", "integration_trunk", "release_branch", "flow",
|
||||
"canonical_remote", "canonical_clone"]
|
||||
V2_OPTIONAL = {"worktree_root", "worktree_policy", "notes", "x_extensions"}
|
||||
ENUM_FLOW = {"direct", "trunk-release"}
|
||||
ENUM_POLICY = {"tool-managed", "orchestrator-precreated"}
|
||||
|
||||
def check_branch(key, value):
|
||||
# Delegate the full git branch grammar to git itself (F2). B1: reject
|
||||
# reflog shorthand BEFORE delegation — `git check-ref-format --branch
|
||||
# '@{-n}'` expands from the CALLER repo's checkout history, making
|
||||
# validation cwd-dependent; a persistent declaration must never bind
|
||||
# to ambient reflog state.
|
||||
if not isinstance(value, str) or not value:
|
||||
err(key, "must be a non-empty string")
|
||||
if "@{" in value:
|
||||
err(key, f"{value!r} contains '@{{' reflog/namespace shorthand — declarations must be literal branch names (B1)")
|
||||
if value.startswith("refs/heads/"): # check-ref-format --branch strips this; we do not allow it
|
||||
err(key, "bare branch name expected, not a full ref")
|
||||
try:
|
||||
r = subprocess.run(["git", "check-ref-format", "--branch", value],
|
||||
capture_output=True)
|
||||
except OSError as e:
|
||||
err(key, f"cannot invoke git check-ref-format: {e}")
|
||||
if r.returncode != 0:
|
||||
err(key, f"{value!r} is not a valid git branch name (git check-ref-format, §5.2)")
|
||||
|
||||
def check_host_path(key, value):
|
||||
# §1.2a: host:/-anchored; canonical segment normalization; empty/./.. rejected
|
||||
# BEFORE resolution; tilde rejected outright.
|
||||
if not isinstance(value, str) or not value:
|
||||
err(key, "must be a non-empty string")
|
||||
if "~" in value:
|
||||
err(key, "tilde-anchored path rejected (§1.2a: ~ binds to caller HOME)")
|
||||
if not value.startswith("host:/"):
|
||||
err(key, "must be host:/-anchored (§1.2a)")
|
||||
rest = value[len("host:/"):]
|
||||
if rest == "":
|
||||
err(key, "no segments after host:/")
|
||||
segments = rest.split("/")
|
||||
for seg in segments:
|
||||
if seg == "":
|
||||
err(key, f"empty segment in {value!r} (canonical normalization, §1.2a)")
|
||||
if seg in (".", ".."):
|
||||
err(key, f"dot segment {seg!r} rejected before resolution (§1.2a)")
|
||||
return segments
|
||||
|
||||
# ---- version ----
|
||||
sv = doc.get("schema_version")
|
||||
if "schema_version" in doc:
|
||||
if not isinstance(sv, int) or isinstance(sv, bool):
|
||||
err("schema_version", "must be an integer")
|
||||
if sv not in (1, 2):
|
||||
err("schema_version", f"unknown schema_version {sv} — treated as ABSENT per keep-list K3; update tooling")
|
||||
version = sv
|
||||
else:
|
||||
version = 1
|
||||
warn("schema_version absent → v1 compatibility mode (two keys only)")
|
||||
|
||||
if REQUIRE_V2 and version != 2:
|
||||
err("schema_version", "CI authoring rule: new or edited declarations must declare schema_version 2")
|
||||
|
||||
# ---- v1 ----
|
||||
if version == 1:
|
||||
for k in V1_KEYS:
|
||||
check_branch(k, doc.get(k))
|
||||
extra = set(doc) - V1_KEYS
|
||||
if extra:
|
||||
err("x_extensions", f"unknown top-level keys in v1: {sorted(extra)}")
|
||||
print("OK (v1)")
|
||||
sys.exit(0)
|
||||
|
||||
# ---- v2 required ----
|
||||
for k in V2_REQUIRED:
|
||||
if k not in doc:
|
||||
err(k, "required for v2 (§1.2)")
|
||||
|
||||
check_branch("integration_trunk", doc["integration_trunk"])
|
||||
check_branch("release_branch", doc["release_branch"])
|
||||
|
||||
# type-check BEFORE membership (F3: list-typed enums must not traceback)
|
||||
if not isinstance(doc["flow"], str) or doc["flow"] not in ENUM_FLOW:
|
||||
err("flow", f"must be one of {sorted(ENUM_FLOW)} (§1.2, required — no defaulting, R7)")
|
||||
|
||||
unknown = set(doc) - set(V2_REQUIRED) - V2_OPTIONAL
|
||||
if unknown:
|
||||
err("x_extensions", f"unknown top-level keys {sorted(unknown)} — place extensions inside x_extensions")
|
||||
|
||||
if "worktree_policy" in doc and (not isinstance(doc["worktree_policy"], str)
|
||||
or doc["worktree_policy"] not in ENUM_POLICY):
|
||||
err("worktree_policy", f"must be one of {sorted(ENUM_POLICY)}")
|
||||
if "notes" in doc and not isinstance(doc["notes"], str):
|
||||
err("notes", "must be a string")
|
||||
if "x_extensions" in doc and not isinstance(doc["x_extensions"], dict):
|
||||
err("x_extensions", "must be an object")
|
||||
for strkey in ("canonical_remote", "canonical_clone", "worktree_root"):
|
||||
if strkey in doc and not isinstance(doc[strkey], str):
|
||||
err(strkey, "must be a string")
|
||||
|
||||
# ---- remote (§5.3) ----
|
||||
if not isinstance(doc["canonical_remote"], str):
|
||||
err("canonical_remote", "must be a string")
|
||||
else:
|
||||
normalize_remote(doc["canonical_remote"])
|
||||
|
||||
# ---- paths (§1.2a) ----
|
||||
canonical_segments = check_host_path("canonical_clone", doc["canonical_clone"])
|
||||
wt_segments = None
|
||||
if "worktree_root" in doc:
|
||||
wt_segments = check_host_path("worktree_root", doc["worktree_root"])
|
||||
|
||||
# ---- cross-field (§5.2) ----
|
||||
trunk, rel, flow = doc["integration_trunk"], doc["release_branch"], doc["flow"]
|
||||
if flow == "direct" and trunk != rel:
|
||||
err("flow", "direct requires integration_trunk == release_branch (§5.2)")
|
||||
if flow == "trunk-release" and trunk == rel:
|
||||
err("flow", "trunk-release requires integration_trunk != release_branch (§5.2)")
|
||||
|
||||
# ---- root gate (§1.2a fail-closed; T51P2R1 F1) ----
|
||||
# Every v2 declaration consumes a path (canonical_clone is required), so
|
||||
# managed mode cannot proceed without a provable host anchor. Display mode
|
||||
# warns and omits root-dependent resolution only.
|
||||
if not HOST_ROOT:
|
||||
if MODE == "managed":
|
||||
err("MOSAIC_HOST_ROOT",
|
||||
"unset or empty — managed validation of a v2 declaration consumes paths "
|
||||
"(canonical_clone required); fail closed (§1.2a, DR3 X2b)")
|
||||
else:
|
||||
warn("host root unset; root-dependent resolution omitted (display mode, §1.2a)")
|
||||
|
||||
if doc.get("worktree_policy") == "tool-managed":
|
||||
if "worktree_root" not in doc:
|
||||
err("worktree_policy", "tool-managed requires worktree_root (containment provable, §4.5)")
|
||||
if HOST_ROOT:
|
||||
root_real = os.path.realpath(HOST_ROOT)
|
||||
# B3 fix: containment is tested on the LEXICAL normalized path, not
|
||||
# on realpath of the joined result — a child symlink under the host
|
||||
# root can no longer fake outside-ness. host:/ segments are always
|
||||
# lexically under the root, so tool-managed fails universally until
|
||||
# the anchor scheme grows a real outside-root form (J3 charter).
|
||||
resolved = os.path.normpath(os.path.join(root_real, *wt_segments))
|
||||
if resolved == root_real or resolved.startswith(root_real + os.sep):
|
||||
err("worktree_policy",
|
||||
f"tool-managed worktree_root resolves inside MOSAIC_HOST_ROOT (§4.5: outside-root requirement)")
|
||||
# no-root case: managed mode already failed at the gate above; display warned
|
||||
|
||||
print("OK")
|
||||
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e: # F3: no traceback may ever escape the contract
|
||||
err("internal", f"input rejected (unexpected condition: {type(e).__name__})")
|
||||
PYEOF
|
||||
@@ -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.
|
||||
@@ -64,31 +62,13 @@ if [ -n "$SOCKET_NAME" ]; then
|
||||
tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
# Normalise the target to an EXACT session plus a window part, because tmux
|
||||
# resolves the two halves with different and individually dangerous defaults:
|
||||
#
|
||||
# * An unpinned name is a PREFIX match. With a session `foobar` alive and
|
||||
# no session `foo`, `-t foo` resolves to `foobar` at rc=0, so a message is
|
||||
# delivered, verified and reported OK against the wrong agent's pane.
|
||||
# * A bare `=name` is not enough on its own: capture-pane REJECTS it
|
||||
# ("can't find pane") while list-panes silently PREFIX-MATCHES it, so the
|
||||
# validation below would pass on a session the capture cannot read.
|
||||
# * A trailing `:` follows the session's ACTIVE window. Pinning `:0.0`
|
||||
# instead addresses window 0 unconditionally, and since the paste, the
|
||||
# Enter and the verifying capture all use EFFECTIVE_TARGET, a multi-window
|
||||
# agent gets typed into window 0 and confirmed by reading window 0 --
|
||||
# a false "delivered" rather than a loud failure.
|
||||
#
|
||||
# Explicit tmux ids (%pane, @window, $session) are passed through untouched;
|
||||
# prefixing `=` to them would break addressing that is already unambiguous.
|
||||
# tmux accepts `=session` for some commands, but pane-level commands such as
|
||||
# capture-pane require a pane-qualified target. Keep exact-session addressing
|
||||
# convenient while avoiding accidental prefix matches.
|
||||
EFFECTIVE_TARGET=$TARGET
|
||||
case "$TARGET" in
|
||||
=*|%*|@*|\$*) ;;
|
||||
*) EFFECTIVE_TARGET="=$TARGET" ;;
|
||||
esac
|
||||
case "$EFFECTIVE_TARGET" in
|
||||
=*) [[ "$EFFECTIVE_TARGET" == *:* ]] || EFFECTIVE_TARGET="${EFFECTIVE_TARGET}:" ;;
|
||||
esac
|
||||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
|
||||
EFFECTIVE_TARGET="${TARGET}:0.0"
|
||||
fi
|
||||
|
||||
# Target must resolve to a live pane.
|
||||
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
|
||||
@@ -117,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.
|
||||
@@ -173,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
|
||||
@@ -194,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
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Target normalisation: send-message.sh must address an EXACT session and the
|
||||
# session's ACTIVE window. Both halves have caused silent wrong-pane delivery:
|
||||
# * an unpinned name prefix-matches, so a message for an absent session is
|
||||
# delivered to a different agent and reported OK;
|
||||
# * a `:0.0` pin addresses window 0 regardless of where the agent is, and
|
||||
# because the paste, the Enter and the verifying capture share one target,
|
||||
# the wrong window is also the window that confirms the send.
|
||||
# Both rows below FAIL against the pre-fix script, which is the point of them.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND_MESSAGE="$SCRIPT_DIR/send-message.sh"
|
||||
SOCKET="mosaic-test-target-$RANDOM-$$"
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
command -v tmux >/dev/null 2>&1 || fail "tmux is required"
|
||||
|
||||
tmux_() { tmux -L "$SOCKET" "$@"; }
|
||||
newsess() { tmux_ new-session -d -s "$1" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'; }
|
||||
hits() { tmux_ capture-pane -p -t "$1" 2>/dev/null | grep -cF "$2" || true; }
|
||||
|
||||
# ── 1. an absent session must not prefix-match a live one ───────────────────
|
||||
newsess sibling-long
|
||||
nonce="absent-target-$RANDOM"
|
||||
rc=0; "$SEND_MESSAGE" -L "$SOCKET" -t sibling -m "$nonce" >/dev/null 2>&1 || rc=$?
|
||||
[ "$rc" -ne 0 ] || fail "send to absent session 'sibling' returned rc=0 (prefix-matched)"
|
||||
[ "$(hits sibling-long "$nonce")" -eq 0 ] || fail "message for absent 'sibling' was delivered to 'sibling-long'"
|
||||
|
||||
# positive control: the detector above can see a real delivery
|
||||
nonce_ok="control-$RANDOM"
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t sibling-long -m "$nonce_ok" >/dev/null 2>&1 \
|
||||
|| fail "send to a live session failed"
|
||||
[ "$(hits sibling-long "$nonce_ok")" -gt 0 ] || fail "control: live delivery not observed — detector is blind"
|
||||
|
||||
# ── 2. delivery follows the ACTIVE window, not window 0 ─────────────────────
|
||||
# A single-window fixture cannot tell `=s:` from `=s:0.0`; the active window
|
||||
# must be non-zero or this test proves nothing.
|
||||
newsess multi
|
||||
tmux_ new-window -t multi -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux_ select-window -t multi:1
|
||||
active=$(tmux_ display-message -p -t multi '#{window_index}')
|
||||
[ "$active" = "1" ] || fail "fixture setup: expected active window 1, got $active"
|
||||
|
||||
for target in multi "=multi"; do
|
||||
nonce="active-win-$RANDOM"
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "$target" -m "$nonce" >/dev/null 2>&1 \
|
||||
|| fail "send to '$target' failed"
|
||||
[ "$(hits multi:1 "$nonce")" -gt 0 ] || fail "'$target' did not deliver to the active window"
|
||||
[ "$(hits multi:0 "$nonce")" -eq 0 ] || fail "'$target' delivered to window 0 instead of the active window"
|
||||
done
|
||||
|
||||
# ── 3. an explicit window part is preserved ─────────────────────────────────
|
||||
nonce="explicit-win-$RANDOM"
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t multi:0 -m "$nonce" >/dev/null 2>&1 || fail "send to 'multi:0' failed"
|
||||
[ "$(hits multi:0 "$nonce")" -gt 0 ] || fail "explicit 'multi:0' did not deliver to window 0"
|
||||
|
||||
# ── 4. a unique prefix of a live session is still refused ───────────────────
|
||||
nonce="prefix-$RANDOM"
|
||||
rc=0; "$SEND_MESSAGE" -L "$SOCKET" -t mult -m "$nonce" >/dev/null 2>&1 || rc=$?
|
||||
[ "$rc" -ne 0 ] || fail "send to prefix 'mult' returned rc=0"
|
||||
[ "$(hits multi:1 "$nonce")" -eq 0 ] || fail "prefix 'mult' was delivered to 'multi'"
|
||||
|
||||
echo "PASS: send-message.sh target normalisation"
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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-create-fallback-default-base.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-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.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' }],
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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]'),
|
||||
);
|
||||
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(', ')}`,
|
||||
);
|
||||
}
|
||||
// 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')) {
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -61,8 +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/git/test-wrapper-guard.sh',
|
||||
'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user