diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index 348a9975471..abce3830b46 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -300,6 +300,16 @@ Extend the completion receipt with one entry per secondmate alongside the primar Keep those entries in the same plain captain-facing language the rest of the receipt uses. The session is reset-safe only when every home is within its own budget with no unresolved exception. +## Automatic /stow markers + +Every `/stow` invocation in every home - primary or secondmate - updates the staleness markers below after that home's own pass (and, in a primary home, after the cascade above). +A secondmate home still performs this step even though it never cascades further. + +When, and only when, the whole pass for this home - including the cascade above in a primary home - is reset-safe, touch `state/.last-stow` (`touch state/.last-stow`); never touch it when reset-safe cannot be claimed. +Then touch `state/.last-stow-attempt` (`touch state/.last-stow-attempt`) as the pass's true final step, unconditionally, on every `/stow` invocation - reset-safe or not, and whatever exceptions stayed unresolved. +Both are bare-mtime markers mirroring `state/.last-heartbeat` (`bin/fm-watch.sh`): `state/.last-stow` records the last fully reset-safe pass, while `state/.last-stow-attempt` records that a pass ran at all and is the marker the automatic `/stow` triggers in `AGENTS.md` read to decide whether another pass is due. +A home carrying a sticky exception it cannot clear on its own - a `deferred` secondmate, an unresolved over-budget home, a shared preference still routing to the primary - therefore stays throttled to one automatic pass per interval instead of re-running on every heartbeat. + ## Scope exclusion: no skill storage by the pass The stow pass itself must never store, create, or edit a skill as a destination for any finding. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..b7e6f9c1a56 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ + + + +## CEO overview + +- **What is changing:** +- **Why it matters:** +- **Customer or business impact:** +- **Risk and rollout:** + +## What changed technically + + + +## Validation + +- **Checks passed:** +- **Checks not run:** +- **Evidence and limitations:** + + + +## Module-boundary decision + +## Decision needed + +No decision required. diff --git a/.github/workflows/pr-communication-sot.yml b/.github/workflows/pr-communication-sot.yml new file mode 100644 index 00000000000..fd1af43d92b --- /dev/null +++ b/.github/workflows/pr-communication-sot.yml @@ -0,0 +1,53 @@ +# Trusted source-of-truth verification for the vendored PR communication +# assessor. This workflow executes only base-branch code and treats the +# proposed head checkout as untrusted data. + +name: pr-communication-sot + +on: + pull_request_target: + branches: + - main + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: pr-communication-sot-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + verify-source-of-truth: + name: verify-source-of-truth + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out trusted verifier + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Check out proposed files as untrusted data + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: .pr-head + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Compare proposed assessor with lalo-admin SoT + env: + PR_COMMUNICATION_CANDIDATE_ROOT: .pr-head + PR_COMMUNICATION_SOT_TOKEN: ${{ secrets.PR_COMMUNICATION_SOT_TOKEN }} + run: node scripts/pr-communication/check-drift.mjs + + - name: Assess PR communication with verified code + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: node --experimental-strip-types .pr-head/scripts/check-pr-communication.ts diff --git a/.github/workflows/pr-communication.yml b/.github/workflows/pr-communication.yml new file mode 100644 index 00000000000..75d3964ce8d --- /dev/null +++ b/.github/workflows/pr-communication.yml @@ -0,0 +1,49 @@ +# Immediate PR communication gate (CEO overview, Decision needed, +# Module-boundary decision, Validation). Re-runs on description edits. +# +# Assessor is vendored from lalo-admin; the trusted pr-communication-sot +# workflow verifies pull-request copies against the private source of truth. +# Kept separate from CI so body-only edits do not re-run the full matrix. +# +# This repo has no package.json, so Node's built-in type stripping executes the +# assessor without downloading a package from the npm registry. +# +# Use pull_request, not pull_request_target. The introducing PR adds the +# checker; checking out the base branch cannot run it until that lands. +# No private secret is exposed to code from the proposed head. + +name: pr-communication + +on: + pull_request: + branches: + - main + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: pr-communication-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + pr-communication: + name: pr-communication + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Assess PR communication + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: node --experimental-strip-types scripts/check-pr-communication.ts diff --git a/AGENTS.md b/AGENTS.md index a50f6afe5cf..5fbb187616d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,6 +134,8 @@ state/ runtime records and signals; gitignored .hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .writing-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch .watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete .last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it + .last-stow bare-mtime marker touched only by the stow skill, only at the end of a reset-safe pass; the durable record of the last clean /stow + .last-stow-attempt bare-mtime marker touched by the stow skill at the end of every /stow pass, reset-safe or not; read by fm-session-start.sh's compact/clear re-emit and by section 8 rule 4's heartbeat check to gate automatic /stow .subsuper-* .supervise-daemon.* sub-supervisor internals; never touch .no-mistakes/ local validation state and evidence; gitignored ``` @@ -257,7 +259,7 @@ Route durable knowledge to its most specific owner: Firstmate never writes a project's `AGENTS.md` directly. A crewmate creates or updates it lazily through the project's selected delivery path, using `bin/fm-ensure-agents-md.sh` and preferring pointers to authoritative sources over copied detail. Keep fleet delivery posture and captain-private strategy out of project memory. -When the captain invokes `/stow`, load the `stow` skill for its memory curation, knowledge routing, and persistence of the open work records this session is holding; it files and corrects only the open work that session is holding, and never reconciles the backlog against repository or PR reality. +When the captain invokes `/stow` or an automatic trigger says it is due, load the `stow` skill for its memory curation, knowledge routing, and persistence of the open work records this session is holding; it files and corrects only the open work that session is holding, and never reconciles the backlog against repository or PR reality. ## 7. Task lifecycle @@ -409,6 +411,7 @@ Handle actionable wakes as follows: 2. For `stale:`, inspect the recorded endpoint and load `stuck-crewmate-recovery` for a stopped, looping, confused, or unresponsive worker; a deep-inspection reason also requires current-state and validation-log inspection. 3. For `check:`, act on the named poll result, including merges, Relay events, process-to-event source results, and captain inbox notes; a handled inbox note is also acknowledged with `bin/fm-inbox.sh drain --ack `, or it stays counted as still waiting for firstmate. 4. For `heartbeat:`, review the whole fleet from the structured fleet view, reconcile suspicious tasks and PR state, update the backlog, and never report an unchanged fleet as progress. + When the captain has explicitly enabled automatic stow for this home with `config/auto-stow`, also check `state/.last-stow-attempt`'s age against `FM_AUTO_STOW_INTERVAL_SECS` (default ~24h, a separate and larger clock than the heartbeat's own cadence); when due, run `/stow` first, before the rest of this review, so an automatic pass does not run on every heartbeat. That marker records an attempted pass rather than a reset-safe one, so a home holding an exception `/stow` cannot clear still waits out the full interval before the next automatic pass. When any wake reports a merged PR for a project cloned in this home, refresh that clone through the guarded fleet-sync path. When Relay-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, use its promised-final reconciliation when a typed public commitment exists, otherwise post the final completion follow-up so the link clears even if earlier follow-ups were spent. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19aa158b093..e878c645cc7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,11 @@ A GitHub Actions check (`Require no-mistakes`) runs on PRs targeting `main` and It evaluates every PR opening and body edit independently, so a later edit cannot replace an earlier pending compliance check. GitHub Actions and Dependabot are exempt so their automation keeps working, but regular contributor PRs without the signature will not be reviewed or merged. +A second check (`pr-communication`) enforces the pull request communication structure shared with the Lalo repos. +Use [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md) for the required CEO overview, validation, module-boundary decision, and decision-needed sections. +The assessment rules are vendored from `lalo-admin`; the local pin always guards that copy against unreviewed changes, and the companion `pr-communication-sot` check compares it with that remote source of truth using only `PR_COMMUNICATION_SOT_TOKEN`. +The remote comparison fails closed when the credential is missing or rejected, and only network errors, HTTP 408 or 429, and server-side HTTP 5xx responses may fall back to the trusted local pin. + ## Workflow 1. Fork the repo, then clone the parent repo or set your local `origin` back to the parent (`git@github.com:kunchenguid/firstmate.git`). diff --git a/bin/fm-quota-dashboard-serve.py b/bin/fm-quota-dashboard-serve.py new file mode 100755 index 00000000000..ffd5d0f5cff --- /dev/null +++ b/bin/fm-quota-dashboard-serve.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""fm-quota-dashboard-serve.py - serve the fleet's remaining AI credits on the tailnet. + +Shells out to `quota-axi --json` on every request and serves one self-contained +HTML page: a fleet summary line plus one card per provider, with live, +signed-out, and error states rendered using quota-axi's own strings. No +collector, no framework, no client-side quota math beyond a countdown +formatted from each window's `resetsAt`. Design: data/fm-quota-dashboard/report.md. + +Binds only to this host's own Tailscale IPv4 address (`tailscale ip -4`), +never 0.0.0.0 or any other interface, and refuses to start if that address +cannot be confirmed. Point a browser already on the tailnet at it, the phone +included - the exact URL is also printed on startup: + + http://:8787/ + http://:8787/ (e.g. http://lalo-dev.tailnet-name.ts.net:8787/) + +Usage: + fm-quota-dashboard-serve.py [--port PORT] [--bind-host HOST] + +Optional user-level systemd unit, so it survives logout/reboot (run +`loginctl enable-linger $USER` once, save this as +~/.config/systemd/user/fm-quota-dashboard.service, then +`systemctl --user enable --now fm-quota-dashboard`). The user manager does +not inherit your shell's PATH, and quota-axi usually lives in an nvm or +npm-global bin directory that is not on the default one, so the unit has to +name that directory or every /data.json request 502s with "No such file or +directory: 'quota-axi'"; `dirname "$(command -v quota-axi)"` prints it: + + [Unit] + Description=Firstmate quota dashboard + + [Service] + Environment=PATH=:/usr/local/bin:/usr/bin:/bin + ExecStart=/usr/bin/python3 /path/to/bin/fm-quota-dashboard-serve.py + Restart=on-failure + + [Install] + WantedBy=default.target +""" +import argparse +import json +import subprocess +import sys +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +DEFAULT_PORT = 8787 +# quota-axi's own per-provider HTTP budget is 15s, and some providers chain +# two of those sequentially, so 45s lets a partial report reach the page +# instead of killing the whole sweep. +QUOTA_AXI_TIMEOUT_SECS = 45 +TAILSCALE_IP_TIMEOUT_SECS = 10 + + +class BindHostError(SystemExit): + """The resolved or requested bind host is not this host's own tailnet address.""" + + +def tailscale_ipv4_addresses(tailscale_bin="tailscale"): + """Return this host's Tailscale IPv4 addresses, or [] if that cannot be confirmed.""" + try: + proc = subprocess.run( + [tailscale_bin, "ip", "-4"], + capture_output=True, text=True, timeout=TAILSCALE_IP_TIMEOUT_SECS, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + if proc.returncode != 0: + return [] + return [line.strip() for line in proc.stdout.splitlines() if line.strip()] + + +def resolve_bind_host(requested, tailscale_bin="tailscale"): + """Resolve and validate the bind host against this host's own tailnet addresses. + + Never falls back to 0.0.0.0 or any other interface: with no requested host + the first confirmed tailnet address wins, and a requested host is only + accepted when it is one of them. + """ + addrs = tailscale_ipv4_addresses(tailscale_bin) + if not addrs: + raise BindHostError( + "fm-quota-dashboard-serve: could not confirm this host's Tailscale " + "IPv4 address (`tailscale ip -4` returned none); is tailscale " + "installed and this host joined to a tailnet?" + ) + if requested is None: + return addrs[0] + if requested not in addrs: + raise BindHostError( + "fm-quota-dashboard-serve: refusing to bind " + f"{requested!r}: not one of this host's Tailscale IPv4 addresses " + f"({', '.join(addrs)})" + ) + return requested + + +PAGE = """ + + + +Quota + + + +

Quota

loading...
+
+
+ +""" + + +class Handler(BaseHTTPRequestHandler): + server_version = "fm-quota-dashboard/1" + + def log_message(self, fmt, *args): + pass + + def do_GET(self): + if self.path == "/data.json": + self._serve_data() + elif self.path == "/": + self._serve_page() + else: + self.send_response(404) + self.end_headers() + + def _serve_data(self): + # Plain `--json`, never `--full`: `--full` is the only flag that adds + # account emails/org names (report.md section 3.3), and nothing here + # needs them. The response body is quota-axi's own stdout, unmodified. + try: + out = subprocess.run( + ["quota-axi", "--json"], + capture_output=True, + timeout=QUOTA_AXI_TIMEOUT_SECS, check=True, + ).stdout + except Exception as exc: + self._write(502, "application/json", json.dumps({"error": str(exc)}).encode("utf-8")) + return + self._write(200, "application/json", out, no_store=True) + + def _serve_page(self): + self._write(200, "text/html; charset=utf-8", PAGE.encode("utf-8")) + + def _write(self, status, content_type, body, no_store=False): + self.send_response(status) + self.send_header("Content-Type", content_type) + if no_store: + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def build_arg_parser(): + parser = argparse.ArgumentParser( + description="Serve the fleet's quota-axi dashboard on this host's tailnet.", + ) + parser.add_argument( + "--port", type=int, default=DEFAULT_PORT, + help=f"TCP port to listen on (default: {DEFAULT_PORT})", + ) + parser.add_argument( + "--bind-host", default=None, + help=( + "Tailscale IPv4 address to bind (default: this host's own, via " + "`tailscale ip -4`); refused if it is not one of this host's own " + "tailnet addresses" + ), + ) + return parser + + +def main(argv): + args = build_arg_parser().parse_args(argv) + bind_host = resolve_bind_host(args.bind_host) + server = ThreadingHTTPServer((bind_host, args.port), Handler) + print( + f"fm-quota-dashboard-serve: listening on http://{bind_host}:{args.port}/ " + f"({datetime.now(timezone.utc).isoformat()})" + ) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 9eb50b4263e..923eb87bfcf 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -351,6 +351,45 @@ QUEUED_LIMIT=${FM_SESSION_START_QUEUED_LIMIT:-20} case "$QUEUED_LIMIT" in ''|*[!0-9]*|0) QUEUED_LIMIT=20 ;; esac BACKLOG_FIELDS=blocked_by,hold_kind,hold_reason +# Automatic /stow, trigger 1 (the compact/clear re-emit path below): an +# explicit home-local config/auto-stow grant plus a staleness gate on +# state/.last-stow-attempt, touched by the stow skill itself +# at the end of every pass whether or not it reached reset-safe (mirrors +# state/.last-heartbeat's bare-mtime marker, bin/fm-watch.sh). Reading the +# attempt marker rather than its reset-safe-only sibling state/.last-stow is +# what holds the once-per-interval throttle in a home whose exceptions /stow +# cannot clear. Read only here, never written by this script. Trigger 2 is the +# heartbeat-handling check in AGENTS.md section 8 rule 4, which reads the same +# marker against the same interval. +STOW_INTERVAL=${FM_AUTO_STOW_INTERVAL_SECS:-86400} +if [[ "$STOW_INTERVAL" =~ ^0*([1-9][0-9]{0,7})$ ]]; then + STOW_INTERVAL=${BASH_REMATCH[1]} +else + STOW_INTERVAL=86400 +fi +if [ "${#STOW_INTERVAL}" -eq 8 ] && [ "$STOW_INTERVAL" -gt 31536000 ]; then + STOW_INTERVAL=86400 +fi + +# stow_due_line: one "STOW DUE: ..." line when state/.last-stow-attempt is +# missing, unreadable, or at least STOW_INTERVAL seconds old, silent (prints +# nothing, exit 0) when current. Detect-only and cheap - a single mtime stat - +# matching the "always check, only speak up when it matters" idiom the +# bootstrap stage already uses. +stow_due_line() { + local marker="$STATE/.last-stow-attempt" m age + m=$(fm_path_mtime "$marker") + if [ -z "$m" ]; then + printf 'STOW DUE: no recorded /stow pass (source=%s); run /stow before other work.\n' "${SESSION_SOURCE:-unknown}" + return 0 + fi + age=$(( $(date +%s) - m )) + [ "$age" -ge 0 ] || age=$STOW_INTERVAL + [ "$age" -ge "$STOW_INTERVAL" ] || return 0 + printf 'STOW DUE: last /stow pass was %ss ago (over the %ss interval, source=%s); run /stow before other work.\n' \ + "$age" "$STOW_INTERVAL" "${SESSION_SOURCE:-unknown}" +} + RULE='================================================================================' SUBRULE='--------------------------------------------------------------------------------' @@ -639,6 +678,13 @@ if [ "$LOCK_RC" -ne 0 ]; then printf '%s\n' "$BAR" } fi +# Automatic /stow, trigger 1. Held until the lock verdict above: /stow mutates +# this home's memory files, so a re-emit that could not verify fleet-lock +# ownership must stay silent about it and leave the still-due marker to the +# next session start or re-emit that does own the lock. +if [ "$REEMIT" -eq 1 ] && [ "$READ_ONLY" -eq 0 ] && [ -e "$CONFIG/auto-stow" ]; then + stow_due_line +fi REBUILDING_SESSION_PID=$(fm_harness_ancestry_pid 2>/dev/null || true) print_agents_refresh_if_required "$REBUILDING_SESSION_PID" diff --git a/docs/architecture.md b/docs/architecture.md index a25bb20430f..4d37f8e722a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -330,6 +330,7 @@ It is deliberately not a reconciliation of durable records against repository or Task-scoped notes use `tasks-axi show --full` followed by `tasks-axi update --body-file `, adding `--archive-body` when the prior body should remain recoverable. The stow pass never writes a skill, but a separately executed, captain-approved migration may move conditional knowledge into a user-owned local skill excluded from the Firstmate clone; changes to Firstmate's tracked skills remain deliberate repository work through the normal PR pipeline. Invoked in a primary home, `/stow` then cascades the same sweep to every registered secondmate, enumerated through `bin/fm-stow-cascade.sh`: each home is accounted and curated against its own startup-memory allowance, a live secondmate sweeps its own session, and a slow or unreachable home is reported as an exception rather than blocking the primary. +Automatic triggers for the same skill - a lock-owning compact/clear re-emit and a staleness-gated heartbeat check - are owned by [configuration.md](configuration.md#automatic-stow). ## Local clones stay fresh diff --git a/docs/configuration.md b/docs/configuration.md index df27bcbda26..d76487d587e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -187,6 +187,20 @@ The flag is per home and is not inherited by secondmate homes, because stow cade Only the file's presence is read, so its contents are ignored; remove it to return to the default contract on the next pass. The skill text owns the marker spelling, the tick order, and the reinforcement rule. +## Automatic /stow + +Create the home-local, gitignored `config/auto-stow` file to grant automatic memory curation for that home; its contents are ignored, and removing it disables both triggers. +This explicit captain opt-in satisfies `VISION.md`'s authority boundary while letting two existing turns decide whether to run the internal [`/stow` skill](../.agents/skills/stow/SKILL.md) instead of waiting for the captain to type it. + +1. An enabled, lock-owning compact/clear session-start re-emit prepends one `STOW DUE:` line when `state/.last-stow-attempt` is missing or older than `FM_AUTO_STOW_INTERVAL_SECS` (default 86400), and stays silent when the marker is current, automatic stow is disabled, or the session could not verify fleet-lock ownership (`bin/fm-session-start.sh`). +2. Enabled heartbeat handling in `AGENTS.md` section 8 rule 4 runs `/stow` first when the same marker is due, using that same larger interval so a pass does not run on every heartbeat wake. + +`FM_AUTO_STOW_INTERVAL_SECS` accepts whole seconds from 1 through 31536000 (365 days); empty, non-numeric, zero, or larger values fall back to 86400. +The stow skill touches `state/.last-stow-attempt` at the end of every pass, reset-safe or not, and touches `state/.last-stow` only when the pass is reset-safe. +The automatic triggers read the attempt marker, so a home holding an exception `/stow` cannot clear still waits out the full interval. +Away-mode heartbeats stay bash-only and never run `/stow`. +On a default Pi primary, heartbeat wakes go to the supervision branch, which does not currently run the `AGENTS.md` check; compact/clear re-emit remains the automatic path there ([Pi supervision branch](pi-supervision-branch.md#heartbeat-routing)). + ## Secondmate routes (data/secondmates.md) Persistent secondmate routes live locally in `data/secondmates.md`. @@ -649,6 +663,7 @@ FM_ZELLIJ_SESSION=firstmate # zellij-only: named session for normal backend ops CMUX_SOCKET_PASSWORD= # cmux-only: socket password fallback when config/cmux-socket-password is absent (docs/cmux-backend.md) FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest; each line is capped by bin/fm-line-cap-lib.sh FM_SESSION_START_QUEUED_LIMIT=20 # plain queued backlog rows in the session-start digest; in-flight, held, and blocked rows are never bounded and done rows are never listed +FM_AUTO_STOW_INTERVAL_SECS=86400 # 1..31536000 seconds; invalid or larger values fall back to 86400; gates automatic /stow against state/.last-stow-attempt's mtime (see "Automatic /stow" above) FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording FM_BOOTSTRAP_NETWORK=all # internal session-start phase split: all, skip (local steps only), or only (network steps only); see bin/fm-bootstrap.sh FM_STARTUP_NETWORK_TIMEOUT=120 # seconds bounding the whole deferred network stage; hitting it prints an actionable NETWORK_CHECKS line diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index bceee95935c..f1f73bdc13b 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -188,6 +188,10 @@ "path": ".agents/skills/updatefirstmate/SKILL.md", "audience": "agent-runtime" }, + { + "path": ".github/PULL_REQUEST_TEMPLATE.md", + "audience": "maintainer-architecture" + }, { "path": ".greptile/rules.md", "audience": "maintainer-architecture" diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 599da22bd0c..1aadab55594 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -58,6 +58,11 @@ A review that found literally nothing worth reporting uses verdict `routine`, `t Only a captain-worthy finding reports verdict `captain` and opens a main turn. Every other fleet-wide or unresolvable wake - including watcher-failure alarms, which are never offered to the branch - keeps today's wake-to-main path. +The branch's heartbeat review does not currently run automatic `/stow`. +`AGENTS.md` section 8 rule 4's staleness-gated `/stow` instruction lives on main, and default-on branch supervision routes heartbeat wakes away from main. +When the captain has enabled `config/auto-stow`, a lock-owning compact/clear session-start re-emit remains the automatic `/stow` path on a default Pi primary (`bin/fm-session-start.sh`; `FM_AUTO_STOW_INTERVAL_SECS` in [configuration.md](configuration.md)). +Wiring heartbeat `/stow` into the branch is tracked in [issue #2944](https://github.com/kunchenguid/firstmate/issues/2944): `bin/fm-branch-prompt.sh`'s byte-stable-prefix contract forbids per-wake state, so that change has to preserve cache identity rather than appending a live marker age. + ## Cost model and the byte-stable prefix The captain accepted the normal provider prompt-caching strategy: a byte-identical branch prefix generated once per firstmate version, the same tool set in the same order on every request, and one shared `prompt_cache_key` per home for all branch sessions (set in a `before_provider_request` hook, and only for providers whose requests already carry that field); main keeps its own per-session key. diff --git a/docs/scripts.md b/docs/scripts.md index 5408ce683d7..5188ef9b107 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -132,3 +132,4 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-voice-client.py` | The laptop end of the spoken interface: capture, playback, and turn timing over SSH; audio devices unverified | | `fm_voice_frame.py` | The wire format both machines share, copied to the laptop beside the client | | `fm_voice_records.py` | What a spoken answer may read, and the handover that queues real work | +| `fm-quota-dashboard-serve.py` | Serve the fleet's remaining AI credits as one phone-friendly page on this host's tailnet, shelling `quota-axi --json` per request | diff --git a/scripts/check-pr-communication.test.ts b/scripts/check-pr-communication.test.ts new file mode 100644 index 00000000000..ba63e6461bc --- /dev/null +++ b/scripts/check-pr-communication.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + emitPrCommunicationCheckOutput, + planPrCommunicationEmission, + runPrCommunicationCheck, +} from './check-pr-communication.ts'; + +const completeBody = `## CEO overview + +- **What is changing:** Members can see the status of their submitted requests. +- **Why it matters:** It reduces support messages asking for updates. +- **Customer or business impact:** Members get clearer communication and the team saves time. +- **Risk and rollout:** Low risk. Release through staging and confirm the main request flow. + +## Validation + +- **Checks passed:** Unit tests and type check. +- **Checks not run:** End-to-end test was not run locally. +- **Evidence and limitations:** Tested with a representative request. + +## Module-boundary decision + +Current module retained: request status rendering belongs with the existing member request page module. + +## Decision needed + +No decision required.`; + +test('CLI reports complete descriptions as exit 0', () => { + const result = runPrCommunicationCheck({ + title: 'Show members the status of their requests', + body: completeBody, + }); + assert.equal(result.exitCode, 0); + assert.ok(result.lines.some((line) => line.includes('PR communication is complete'))); +}); + +test('CLI fails incomplete descriptions with staging-matching copy', () => { + const result = runPrCommunicationCheck({ + title: 'Show members the status of their requests', + body: '## CEO overview\n\n- **What is changing:** A status is shown.\n', + }); + assert.equal(result.exitCode, 1); + const failure = result.lines.find((line) => + line.startsWith('Cannot enter staging until completed:'), + ); + assert.ok(failure); + assert.match(failure!, /CEO overview: Why it matters/); + assert.match(failure!, /Decision needed/); + assert.match(failure!, /Module-boundary decision/); + assert.match(failure!, /Validation: Checks passed/); +}); + +test('failure emission uses stdout and ::error:: (not stderr-only)', () => { + const failure = + 'Cannot enter staging until completed: CEO overview: Why it matters; Decision needed'; + const planned = planPrCommunicationEmission([failure], 1); + assert.deepEqual( + planned.map((item) => item.kind), + ['stdout', 'error_annotation', 'step_summary'], + ); + assert.equal(planned[0]?.text, failure); + assert.equal(planned[1]?.text, `::error::${failure}`); + assert.equal(planned[2]?.text, failure); + + const stdout: string[] = []; + const summary: string[] = []; + emitPrCommunicationCheckOutput({ + exitCode: 1, + lines: [failure], + writeStdout: (text) => stdout.push(text), + appendStepSummary: (text) => summary.push(text), + }); + assert.deepEqual(stdout, [failure, `::error::${failure}`]); + assert.deepEqual(summary, [failure]); +}); + +test('success emission is stdout-only', () => { + const planned = planPrCommunicationEmission(['PR communication is complete.'], 0); + assert.deepEqual(planned, [{ kind: 'stdout', text: 'PR communication is complete.' }]); +}); diff --git a/scripts/check-pr-communication.ts b/scripts/check-pr-communication.ts new file mode 100644 index 00000000000..e1a4007805f --- /dev/null +++ b/scripts/check-pr-communication.ts @@ -0,0 +1,88 @@ +/** + * GitHub Actions entrypoint for the pr-communication check. + * Rules are vendored from lalo-admin src/shared/prCommunication.ts. Do not fork them here. + */ +import { appendFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +import { assessPullRequestCommunication } from './pr-communication/prCommunication.ts'; + +export function runPrCommunicationCheck(input: { + title: string; + body: string | null | undefined; +}): { exitCode: number; lines: string[] } { + const result = assessPullRequestCommunication(input); + const lines: string[] = []; + + for (const warning of result.clarityWarnings) { + lines.push(`Clarity check: ${warning}`); + } + + if (!result.eligible) { + lines.push(`Cannot enter staging until completed: ${result.missing.join('; ')}`); + return { exitCode: 1, lines }; + } + + lines.push('PR communication is complete.'); + return { exitCode: 0, lines }; +} + +export type PrCommunicationEmission = + | { kind: 'stdout'; text: string } + | { kind: 'error_annotation'; text: string } + | { kind: 'step_summary'; text: string }; + +/** Pure plan for how check lines are surfaced (stdout + GHA annotations/summary). */ +export function planPrCommunicationEmission( + lines: string[], + exitCode: number, +): PrCommunicationEmission[] { + const planned: PrCommunicationEmission[] = []; + for (const line of lines) { + // Always stdout so gh --log-failed / job log download see the missing-section list. + planned.push({ kind: 'stdout', text: line }); + if (exitCode !== 0 && line.startsWith('Cannot enter staging')) { + planned.push({ kind: 'error_annotation', text: `::error::${line}` }); + planned.push({ kind: 'step_summary', text: line }); + } + } + return planned; +} + +export function emitPrCommunicationCheckOutput(opts: { + exitCode: number; + lines: string[]; + writeStdout?: (text: string) => void; + appendStepSummary?: (text: string) => void; + githubStepSummaryPath?: string | undefined; +}): void { + const writeStdout = opts.writeStdout ?? ((text: string) => console.log(text)); + const summaryPath = opts.githubStepSummaryPath ?? process.env.GITHUB_STEP_SUMMARY; + const appendStepSummary = + opts.appendStepSummary ?? + ((text: string) => { + if (!summaryPath) return; + appendFileSync(summaryPath, `${text}\n`, 'utf8'); + }); + + for (const item of planPrCommunicationEmission(opts.lines, opts.exitCode)) { + if (item.kind === 'stdout' || item.kind === 'error_annotation') { + writeStdout(item.text); + continue; + } + appendStepSummary(item.text); + } +} + +function main(): void { + const title = process.env.PR_TITLE ?? ''; + const body = process.env.PR_BODY ?? ''; + const { exitCode, lines } = runPrCommunicationCheck({ title, body }); + emitPrCommunicationCheckOutput({ exitCode, lines }); + process.exit(exitCode); +} + +const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''; +if (entry && import.meta.url === entry) { + main(); +} diff --git a/scripts/pr-communication/ENTRYPOINT.sha256 b/scripts/pr-communication/ENTRYPOINT.sha256 new file mode 100644 index 00000000000..63a9433a97a --- /dev/null +++ b/scripts/pr-communication/ENTRYPOINT.sha256 @@ -0,0 +1 @@ +9ba7dfe56d7476c58a071819c5f96e8f24a985fc7509effab6fb3c6ce39969d8 diff --git a/scripts/pr-communication/SOURCE.sha256 b/scripts/pr-communication/SOURCE.sha256 new file mode 100644 index 00000000000..26710f29889 --- /dev/null +++ b/scripts/pr-communication/SOURCE.sha256 @@ -0,0 +1 @@ +6430222bb93b348e6e9caa8c30c3bf5243a64aa22e0409d747d0cd1073d23349 diff --git a/scripts/pr-communication/check-drift.mjs b/scripts/pr-communication/check-drift.mjs new file mode 100755 index 00000000000..296142fc32d --- /dev/null +++ b/scripts/pr-communication/check-drift.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * Fail if the vendored assessor drifts from the pinned SoT hash, or (when + * reachable) from bingb0t5/lalo-admin@main:src/shared/prCommunication.ts. + * + * Remote comparison requires PR_COMMUNICATION_SOT_TOKEN. GITHUB_TOKEN and + * GH_TOKEN are not substitutes. A missing or unusable token fails closed so + * a PR cannot compare the vendored file against SOURCE.sha256 alone. + * Local-pin fallback is only for network errors, HTTP 408, 429, and 5xx. + * HTTP 401, 403, and 404 fail closed. + */ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const TRUSTED_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); +const ROOT = process.env.PR_COMMUNICATION_CANDIDATE_ROOT + ? join(TRUSTED_ROOT, process.env.PR_COMMUNICATION_CANDIDATE_ROOT) + : TRUSTED_ROOT; +const VENDORED_PATH = join(ROOT, 'scripts/pr-communication/prCommunication.ts'); +const CANDIDATE_PIN_PATH = join(ROOT, 'scripts/pr-communication/SOURCE.sha256'); +const TRUSTED_PIN_PATH = join(TRUSTED_ROOT, 'scripts/pr-communication/SOURCE.sha256'); +const ENTRYPOINT_PATH = join(ROOT, 'scripts/check-pr-communication.ts'); +const ENTRYPOINT_PIN_PATH = join(TRUSTED_ROOT, 'scripts/pr-communication/ENTRYPOINT.sha256'); +const SOURCE_REPO = 'bingb0t5/lalo-admin'; +const SOURCE_PATH = 'src/shared/prCommunication.ts'; +const SOURCE_REF = 'main'; + +function stripSourceHeader(text) { + const marker = '\n\n'; + const idx = text.indexOf(marker); + if (idx < 0 || !text.startsWith('// SOURCE:')) { + throw new Error(`${VENDORED_PATH} is missing the expected SOURCE header`); + } + return text.slice(idx + marker.length); +} + +function sha256(text) { + return createHash('sha256').update(text, 'utf8').digest('hex'); +} + +async function fetchSourceOfTruth(token) { + const url = `https://api.github.com/repos/${SOURCE_REPO}/contents/${SOURCE_PATH}?ref=${SOURCE_REF}`; + const headers = { + Accept: 'application/vnd.github.raw', + 'User-Agent': 'lalo-platform-pr-communication-drift-check', + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetch(url, { headers }); + if (!response.ok) { + const body = await response.text(); + const error = new Error( + `Failed to fetch ${SOURCE_REPO}@${SOURCE_REF}:${SOURCE_PATH} (${response.status}): ${body.slice(0, 300)}`, + ); + error.status = response.status; + throw error; + } + return await response.text(); +} + +const vendoredBody = stripSourceHeader(readFileSync(VENDORED_PATH, 'utf8')); +const actualHash = sha256(vendoredBody); +const candidatePinnedHash = readFileSync(CANDIDATE_PIN_PATH, 'utf8').trim(); +const trustedPinnedHash = readFileSync(TRUSTED_PIN_PATH, 'utf8').trim(); + +if (actualHash !== candidatePinnedHash) { + console.error('Vendored PR communication assessor does not match SOURCE.sha256.'); + console.error(`expected: ${candidatePinnedHash}`); + console.error(`actual: ${actualHash}`); + console.error('Re-vendor from lalo-admin and refresh SOURCE.sha256.'); + process.exit(1); +} + +console.log(`Local pin OK (${actualHash}).`); + +const entrypointHash = sha256(readFileSync(ENTRYPOINT_PATH, 'utf8')); +const pinnedEntrypointHash = readFileSync(ENTRYPOINT_PIN_PATH, 'utf8').trim(); + +if (entrypointHash !== pinnedEntrypointHash) { + console.error('PR communication entrypoint does not match ENTRYPOINT.sha256.'); + console.error(`expected: ${pinnedEntrypointHash}`); + console.error(`actual: ${entrypointHash}`); + process.exit(1); +} + +console.log(`Entrypoint pin OK (${entrypointHash}).`); + +const token = String(process.env.PR_COMMUNICATION_SOT_TOKEN || '').trim(); +const requireRemote = String(process.env.PR_COMMUNICATION_REQUIRE_REMOTE_SOT || '').trim() === '1'; + +if (!token) { + console.error( + `PR_COMMUNICATION_SOT_TOKEN is required to verify the private ${SOURCE_REPO} source of truth.`, + ); + process.exit(1); +} + +try { + const remoteBody = await fetchSourceOfTruth(token); + if (vendoredBody !== remoteBody) { + console.error(`Vendored assessor drifted from ${SOURCE_REPO}@${SOURCE_REF}:${SOURCE_PATH}.`); + console.error( + 'Re-vendor from lalo-admin, refresh SOURCE.sha256, and keep the SOURCE header intact.', + ); + process.exit(1); + } + console.log(`Remote SoT matches ${SOURCE_REPO}@${SOURCE_REF}:${SOURCE_PATH}.`); +} catch (error) { + const status = error && error.status; + const networkCodes = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ENETDOWN', + 'ENETUNREACH', + 'ENOTFOUND', + 'ETIMEDOUT', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_SOCKET', + ]); + const networkFailure = + error instanceof TypeError && networkCodes.has(error.cause && error.cause.code); + const transientFailure = + networkFailure || status === 408 || status === 429 || (status >= 500 && status <= 599); + if (!transientFailure || requireRemote) { + console.error(error.message || error); + process.exit(1); + } + if (actualHash !== trustedPinnedHash) { + console.error('Vendored PR communication assessor does not match trusted SOURCE.sha256.'); + console.error(`expected: ${trustedPinnedHash}`); + console.error(`actual: ${actualHash}`); + process.exit(1); + } + console.warn( + `Remote SoT check skipped (${status || 'network error'}). Using the verified local pin.`, + ); +} diff --git a/scripts/pr-communication/prCommunication.ts b/scripts/pr-communication/prCommunication.ts new file mode 100644 index 00000000000..7443dff7c95 --- /dev/null +++ b/scripts/pr-communication/prCommunication.ts @@ -0,0 +1,182 @@ +// SOURCE: bingb0t5/lalo-admin@main:src/shared/prCommunication.ts +// Re-vendor from that path when the SoT changes. Do not edit assessment rules here. + +export type PrCeoOverview = { + what: string | null; + why: string | null; + impact: string | null; + riskAndRollout: string | null; +}; + +export type PrCommunicationAssessment = { + eligible: boolean; + ceoOverview: PrCeoOverview; + decisionNeeded: string | null; + moduleBoundaryDecision: string | null; + missing: string[]; + clarityWarnings: string[]; +}; + +const REQUIRED_OVERVIEW_FIELDS = [ + ['What is changing', 'what'], + ['Why it matters', 'why'], + ['Customer or business impact', 'impact'], + ['Risk and rollout', 'riskAndRollout'], +] as const; +const REQUIRED_VALIDATION_FIELDS = ['Checks passed', 'Checks not run', 'Evidence and limitations'] as const; + +const PLACEHOLDER_PATTERN = /^(?:\(?\s*(?:fill\s*(?:this|in)?|todo|tbd|n\/a|none|pending|not provided)\s*\)?)\.?$/i; +const JARGON_PATTERN = /\b(?:api|orm|typescript|javascript|tsx|jsx|lint|eslint|tsc|refactor|hook|schema|migration|webhook|ci\/cd|regex)\b/i; + +function normalize(value: string, options?: { allowNone?: boolean }): string | null { + const compact = value.replace(/\s+/g, ' ').trim(); + if (!compact) return null; + if (PLACEHOLDER_PATTERN.test(compact) && !(options?.allowNone && /^none\.?$/i.test(compact))) return null; + return compact; +} + +const FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})(.*)$/; + +/** + * Marks the lines that sit inside a fenced code block (including the fence + * lines themselves). Generated PR bodies embed evidence transcripts that quote + * markdown, so a fenced `## ` line must neither satisfy a required section nor + * truncate a real one. + */ +function fencedLineFlags(lines: string[]): boolean[] { + const flags: boolean[] = []; + let fence: { marker: string; length: number } | null = null; + for (const line of lines) { + const fenceMatch = line.match(FENCE_PATTERN); + if (fence) { + flags.push(true); + const closes = + fenceMatch !== null && + fenceMatch[1][0] === fence.marker && + fenceMatch[1].length >= fence.length && + fenceMatch[2].trim() === ''; + if (closes) fence = null; + continue; + } + if (fenceMatch && !(fenceMatch[1][0] === '`' && fenceMatch[2].includes('`'))) { + fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length }; + } + flags.push(fenceMatch !== null && fence !== null); + } + return flags; +} + +function section(body: string, heading: string): string | null { + const wanted = `## ${heading}`.toLowerCase(); + const lines = body.split(/\r?\n/); + const fenced = fencedLineFlags(lines); + const start = lines.findIndex( + (line, index) => !fenced[index] && line.trim().toLowerCase() === wanted, + ); + if (start < 0) return null; + const content: string[] = []; + for (let index = start + 1; index < lines.length; index += 1) { + if (!fenced[index] && /^##\s+/.test(lines[index])) break; + content.push(lines[index]); + } + return content.join('\n'); +} + +/** Optional short parenthetical qualifier between label and colon, e.g. (commit abc). */ +const LABEL_QUALIFIER = '(?:\\s*\\(([^\\n)]{0,80})\\))?'; + +function labelledMatch( + content: string | null, + label: string, +): { value: string | null; line: string | null } { + if (!content) return { value: null, line: null }; + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const plainContent = content.replace(/\*\*/g, ''); + // Keep colon-adjacent whitespace on the same line so an empty value cannot + // accidentally capture the next labelled line via \\s matching newlines. + // Require a non-empty trailing value after the colon; a parenthetical + // qualifier alone (or following bullets) does not satisfy the field. + const expression = new RegExp( + `^(\\s*(?:[-*]\\s*)?${escaped}${LABEL_QUALIFIER}[^\\S\\n]*:[^\\S\\n]*)(.*)$`, + 'im', + ); + const match = plainContent.match(expression); + if (match) { + const trailing = String(match[3] ?? ''); + const line = match[0].replace(/\s+/g, ' ').trim(); + return { value: trailing.trim() ? trailing : null, line }; + } + + const nearMissExpression = new RegExp(`(?:^|\\s)(?:[-*]\\s*)?${escaped}\\b`, 'i'); + const nearMiss = plainContent + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0 && nearMissExpression.test(line)); + return { value: null, line: nearMiss ? nearMiss.replace(/\s+/g, ' ') : null }; +} + +export function labelledValue( + content: string | null, + label: string, + options?: { allowNone?: boolean }, +): string | null { + const match = labelledMatch(content, label); + return normalize(match.value || '', options); +} + +function missingLabelMessage( + sectionName: string, + label: string, + content: string | null, + options?: { allowNone?: boolean }, +): string | null { + const match = labelledMatch(content, label); + if (normalize(match.value || '', options)) return null; + const lineNote = match.line ? `found line: ${match.line}` : 'no line found'; + return `${sectionName}: ${label} (${lineNote})`; +} + +export function assessPullRequestCommunication(input: { + title: string; + body: string | null | undefined; +}): PrCommunicationAssessment { + const body = input.body || ''; + const overview = section(body, 'CEO overview'); + const ceoOverview = { + what: labelledValue(overview, 'What is changing'), + why: labelledValue(overview, 'Why it matters'), + impact: labelledValue(overview, 'Customer or business impact'), + riskAndRollout: labelledValue(overview, 'Risk and rollout'), + } satisfies PrCeoOverview; + const decisionNeeded = normalize(section(body, 'Decision needed') || ''); + const moduleBoundaryDecision = normalize(section(body, 'Module-boundary decision') || ''); + const missing = REQUIRED_OVERVIEW_FIELDS.flatMap(([label, key]) => { + if (ceoOverview[key]) return []; + const message = missingLabelMessage('CEO overview', label, overview); + return [message || `CEO overview: ${label} (no line found)`]; + }); + if (!decisionNeeded) missing.push('Decision needed'); + if (!moduleBoundaryDecision) missing.push('Module-boundary decision'); + const validation = section(body, 'Validation'); + for (const label of REQUIRED_VALIDATION_FIELDS) { + const message = missingLabelMessage('Validation', label, validation, { allowNone: true }); + if (message) missing.push(message); + } + + const clarityWarnings: string[] = []; + if (input.title.trim().length < 12) { + clarityWarnings.push('The PR title is very short. State the user or business outcome.'); + } + if (JARGON_PATTERN.test(input.title) || JARGON_PATTERN.test(overview || '')) { + clarityWarnings.push('The CEO overview may contain technical jargon. Rewrite it in plain language where possible.'); + } + + return { + eligible: missing.length === 0, + ceoOverview, + decisionNeeded, + moduleBoundaryDecision, + missing, + clarityWarnings, + }; +} diff --git a/tests/fixtures/pr-communication-fetch.mjs b/tests/fixtures/pr-communication-fetch.mjs new file mode 100644 index 00000000000..f575a0f7a45 --- /dev/null +++ b/tests/fixtures/pr-communication-fetch.mjs @@ -0,0 +1,15 @@ +import { readFileSync } from 'node:fs'; + +const failure = process.env.PR_COMMUNICATION_FETCH_FAILURE; +const bodyPath = process.env.PR_COMMUNICATION_FETCH_BODY_PATH; + +if (bodyPath) { + globalThis.fetch = async () => new Response(readFileSync(bodyPath, 'utf8')); +} else if (failure === 'network') { + globalThis.fetch = async () => { + throw new TypeError('simulated network failure', { cause: { code: 'ENETUNREACH' } }); + }; +} else if (failure) { + const status = Number(failure); + globalThis.fetch = async () => new Response('simulated remote failure', { status }); +} diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index ad98a1e1170..3fd765516e7 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -1771,6 +1771,7 @@ TS local session_arg=${5:-} local shape=${6:-single} local extensions + local peak_captain_answer_count captain_answer_count tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true if [ "$calm_state" = absent ]; then @@ -1817,7 +1818,31 @@ TS fail "Pi follow-up $label case did not process the monitoring notification" fi - pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) + # The session file above is the authoritative record of what Pi processed and + # settles as soon as the model turn completes; the pane is a separate, later + # redraw of that same state. Two adjacent followUp deliveries queue more + # presentation work (an extra operational-user row plus its Calm-hiding + # invalidation) than a single one, so the redraw that finally paints the + # already-settled captain answer can still be in flight the instant the + # session file confirms processing. Poll the same way the session-file wait + # above does rather than reading one immediate, possibly pre-redraw snapshot, + # and track the highest count seen along the way so a captain answer that + # is genuinely rendered twice for even one intermediate frame still fails + # this assertion even if a later redraw were to self-correct down to one. + i=0 + peak_captain_answer_count=0 + while [ "$i" -lt 100 ]; do + pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) + captain_answer_count=$(printf '%s\n' "$pane" | grep -Fc "CAPTAIN_ANSWER_$label" || true) + [ "$captain_answer_count" -gt "$peak_captain_answer_count" ] && peak_captain_answer_count=$captain_answer_count + if printf '%s\n' "$pane" | grep -Fq "MONITOR_HANDLED_${label}_ONE"; then + break + fi + sleep 0.05 + i=$((i + 1)) + done + [ "$peak_captain_answer_count" -le 1 ] \ + || fail "Pi follow-up $label case rendered a duplicate captain answer" [ "$(printf '%s\n' "$pane" | grep -Fc "CAPTAIN_ANSWER_$label" || true)" -eq 1 ] \ || fail "Pi follow-up $label case rendered a duplicate captain answer" assert_contains "$pane" "CAPTAIN_PROMPT_$label" "Pi follow-up $label case hid the genuine captain prompt" diff --git a/tests/fm-quota-dashboard-serve.test.sh b/tests/fm-quota-dashboard-serve.test.sh new file mode 100755 index 00000000000..94e4e28403b --- /dev/null +++ b/tests/fm-quota-dashboard-serve.test.sh @@ -0,0 +1,331 @@ +#!/usr/bin/env bash +# tests/fm-quota-dashboard-serve.test.sh - the phone-facing quota dashboard server. +# +# Three things matter here, all exercised through the running server's public +# HTTP interface rather than its source: it must refuse to bind anywhere but +# a confirmed Tailscale address of this host (never 0.0.0.0, never a public +# IP, never an address `tailscale ip -4` does not vouch for); the /data.json +# route must pass quota-axi's own `--json` output straight through, calling +# it with no extra flag that would add account emails (`--full`); and the two +# real routes (page, data) must behave, with everything else 404. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v python3 >/dev/null 2>&1 || { echo "skip: python3 not found"; exit 0; } + +SERVER="$ROOT/bin/fm-quota-dashboard-serve.py" +TMP_ROOT=$(fm_test_tmproot fm-quota-dashboard-serve) +FAKEBIN=$(fm_fakebin "$TMP_ROOT") + +SERVER_PID= + +stop_server() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + SERVER_PID= + fi +} +trap 'stop_server; fm_test_cleanup || true' EXIT + +# fake_tailscale ...: a `tailscale ip -4` stub that reports exactly the +# given addresses (one per line), and refuses every other subcommand loudly +# rather than silently succeeding, so a wrong invocation fails a test instead +# of passing by accident. +fake_tailscale() { + local addr + { + printf '#!/usr/bin/env bash\n' + # shellcheck disable=SC2016 # writing the stub's own literal source, not expanding here + printf 'if [ "${1:-}" = ip ] && [ "${2:-}" = -4 ]; then\n' + for addr in "$@"; do + printf ' echo %q\n' "$addr" + done + printf ' exit 0\n' + printf 'fi\n' + printf 'echo "fake_tailscale: unexpected invocation: $*" >&2\n' + printf 'exit 1\n' + } > "$FAKEBIN/tailscale" + chmod +x "$FAKEBIN/tailscale" +} + +# fake_tailscale_absent: simulate a host where `tailscale ip -4` cannot +# confirm any address (not installed, or not joined to a tailnet). +fake_tailscale_absent() { + cat > "$FAKEBIN/tailscale" <<'SH' +#!/usr/bin/env bash +exit 1 +SH + chmod +x "$FAKEBIN/tailscale" +} + +# fake_quota_axi : a `quota-axi --json` stub that records every +# invocation's argv (so a test can prove no extra flag, such as --full, was +# ever passed) and answers only the exact `--json` call; anything else fails +# loudly instead of quietly returning something plausible. +fake_quota_axi() { + local json=$1 + printf '%s' "$json" > "$TMP_ROOT/quota-axi.stdout" + cat > "$FAKEBIN/quota-axi" <> "$TMP_ROOT/quota-axi.invocations" +if [ "\$#" -eq 1 ] && [ "\$1" = --json ]; then + cat "$TMP_ROOT/quota-axi.stdout" + exit 0 +fi +echo "fake_quota_axi: unexpected invocation: \$*" >&2 +exit 1 +SH + chmod +x "$FAKEBIN/quota-axi" +} + +FIXTURE_JSON='{"generatedAt":"2026-08-24T00:00:00Z","schemaVersion":5,"providers":[{"provider":"claude","plan":"max","windows":[{"id":"five_hour","label":"session","resetsAt":"2026-08-24T05:00:00Z","percentRemaining":68,"pace":{"status":"behind"}}],"state":{"status":"fresh","stale":false}}]}' + +free_port() { + local host=${1:-127.0.0.1} + python3 -c " +import socket +s = socket.socket() +s.bind(('$host', 0)) +print(s.getsockname()[1]) +s.close() +" +} + +wait_for_port() { + local host=$1 port=$2 attempt + # shellcheck disable=SC2034 # attempt only bounds the retry count + for attempt in $(seq 1 50); do + python3 -c " +import socket, sys +s = socket.socket() +s.settimeout(0.2) +try: + s.connect(('$host', $port)) +except OSError: + sys.exit(1) +s.close() +" 2>/dev/null && return 0 + sleep 0.1 + done + return 1 +} + +http_get() { + # http_get : print "\n". + python3 -c " +import urllib.request, sys +req = urllib.request.Request('http://$1:$2$3') +try: + with urllib.request.urlopen(req, timeout=5) as r: + print(r.status) + sys.stdout.write(r.read().decode('utf-8', 'replace')) +except urllib.error.HTTPError as e: + print(e.code) + sys.stdout.write(e.read().decode('utf-8', 'replace')) +" +} + +# --- bind-host refusal ------------------------------------------------------- + +test_refuses_wildcard_bind() { + fake_tailscale 100.99.99.1 + local out rc + out=$(PATH="$FAKEBIN:$PATH" python3 "$SERVER" --bind-host 0.0.0.0 --port 1 2>&1) + rc=$? + [ "$rc" -ne 0 ] || fail "server accepted 0.0.0.0 as a bind host" + case "$out" in + *refusing*0.0.0.0*) ;; + *) fail "refusal message did not name 0.0.0.0: $out" ;; + esac + pass "refuses to bind the wildcard address 0.0.0.0" +} + +test_refuses_public_address_not_owned_by_this_host() { + fake_tailscale 100.99.99.1 + local out rc + out=$(PATH="$FAKEBIN:$PATH" python3 "$SERVER" --bind-host 203.0.113.5 --port 1 2>&1) + rc=$? + [ "$rc" -ne 0 ] || fail "server accepted a public address as a bind host" + case "$out" in + *refusing*203.0.113.5*) ;; + *) fail "refusal message did not name 203.0.113.5: $out" ;; + esac + pass "refuses a public address that is not one of this host's tailnet addresses" +} + +test_refuses_when_tailscale_address_unconfirmed() { + fake_tailscale_absent + local out rc + out=$(PATH="$FAKEBIN:$PATH" python3 "$SERVER" --port 1 2>&1) + rc=$? + [ "$rc" -ne 0 ] || fail "server started with no confirmed Tailscale address" + case "$out" in + *"could not confirm"*) ;; + *) fail "refusal message did not explain the missing Tailscale address: $out" ;; + esac + pass "refuses to start when this host's Tailscale address cannot be confirmed" +} + +test_accepts_this_hosts_own_tailnet_address() { + # Bind a different loopback than 127.0.0.1 so a wildcard listener cannot + # pass this test: the server must answer on the confirmed address and + # refuse the other loopback. + fake_tailscale 127.0.0.2 + fake_quota_axi "$FIXTURE_JSON" + local port + port=$(free_port 127.0.0.2) + PATH="$FAKEBIN:$PATH" python3 "$SERVER" --port "$port" >"$TMP_ROOT/server.log" 2>&1 & + SERVER_PID=$! + wait_for_port 127.0.0.2 "$port" || fail "server never opened its port after accepting its own tailnet address" + python3 -c " +import socket, sys +s = socket.socket() +s.settimeout(0.5) +try: + s.connect(('127.0.0.1', $port)) +except OSError: + sys.exit(0) +s.close() +sys.exit(1) +" || fail "server accepted a connection on 127.0.0.1 while bound to 127.0.0.2 (listener is not address-scoped)" + local resp status + resp=$(http_get 127.0.0.2 "$port" /) + status=$(printf '%s' "$resp" | head -n1) + stop_server + [ "$status" = 200 ] || fail "bound address 127.0.0.2:$port returned status $status" + pass "starts once the requested bind host matches this host's own tailnet address, and the listener is not a wildcard" +} + +# --- routes and JSON passthrough -------------------------------------------- + +# start_server_for_routes: start the server against the stub tailscale/quota-axi +# and publish its port as $SERVER_PORT. It must run in the caller's own shell, +# not a command substitution, or the SERVER_PID it records dies with the +# subshell and stop_server leaks the server process. +SERVER_PORT= +start_server_for_routes() { + fake_tailscale 127.0.0.1 + fake_quota_axi "$FIXTURE_JSON" + SERVER_PORT=$(free_port) + PATH="$FAKEBIN:$PATH" python3 "$SERVER" --port "$SERVER_PORT" >"$TMP_ROOT/server.log" 2>&1 & + SERVER_PID=$! + wait_for_port 127.0.0.1 "$SERVER_PORT" || fail "server never opened its port" +} + +test_data_route_passes_quota_axi_json_through_unchanged() { + local port resp status body + start_server_for_routes + port=$SERVER_PORT + resp=$(http_get 127.0.0.1 "$port" /data.json) + status=$(printf '%s' "$resp" | head -n1) + body=$(printf '%s' "$resp" | tail -n +2) + stop_server + [ "$status" = 200 ] || fail "/data.json returned status $status" + [ "$body" = "$FIXTURE_JSON" ] || fail "/data.json body was not quota-axi's --json output unchanged: $body" + grep -qx -- '--json' "$TMP_ROOT/quota-axi.invocations" \ + || fail "quota-axi was not invoked with exactly --json: $(cat "$TMP_ROOT/quota-axi.invocations")" + grep -q -- '--full' "$TMP_ROOT/quota-axi.invocations" \ + && fail "quota-axi was invoked with --full, which surfaces account emails" + pass "/data.json is quota-axi's own --json output, unchanged, called with no --full" +} + +test_data_route_sets_no_store_and_json_content_type() { + local port resp status headers + start_server_for_routes + port=$SERVER_PORT + headers=$(python3 -c " +import urllib.request +with urllib.request.urlopen('http://127.0.0.1:$port/data.json', timeout=5) as r: + for k, v in r.headers.items(): + print(f'{k}: {v}') +") + resp=$(http_get 127.0.0.1 "$port" /data.json) + status=$(printf '%s' "$resp" | head -n1) + stop_server + [ "$status" = 200 ] || fail "/data.json returned status $status" + case "$headers" in + *"Content-Type: application/json"*) ;; + *) fail "/data.json did not set an application/json content type: $headers" ;; + esac + case "$headers" in + *"Cache-Control: no-store"*) ;; + *) fail "/data.json did not set Cache-Control: no-store, so a phone browser could show stale quota: $headers" ;; + esac + pass "/data.json is served as fresh, uncached JSON" +} + +test_page_route_serves_self_contained_html() { + local port resp status body headers + start_server_for_routes + port=$SERVER_PORT + headers=$(python3 -c " +import urllib.request +with urllib.request.urlopen('http://127.0.0.1:$port/', timeout=5) as r: + for k, v in r.headers.items(): + print(f'{k}: {v}') +") + resp=$(http_get 127.0.0.1 "$port" /) + status=$(printf '%s' "$resp" | head -n1) + body=$(printf '%s' "$resp" | tail -n +2) + stop_server + [ "$status" = 200 ] || fail "/ returned status $status" + case "$headers" in + *"Content-Type: text/html"*) ;; + *) fail "/ was not served as HTML: $headers" ;; + esac + # The page's self-containment is an owned contract of this served output: a + # phone on the tailnet can reach this server and nothing else, so any + # external subresource would leave the dashboard blank or unstyled. + case "$body" in + ' "$FAKEBIN/quota-axi" <<'SH' +#!/usr/bin/env bash +echo "quota-axi: boom" >&2 +exit 1 +SH + chmod +x "$FAKEBIN/quota-axi" + local port resp status + port=$(free_port) + PATH="$FAKEBIN:$PATH" python3 "$SERVER" --port "$port" >"$TMP_ROOT/server.log" 2>&1 & + SERVER_PID=$! + wait_for_port 127.0.0.1 "$port" || fail "server never opened its port" + resp=$(http_get 127.0.0.1 "$port" /data.json) + status=$(printf '%s' "$resp" | head -n1) + stop_server + [ "$status" = 502 ] || fail "a failing quota-axi call returned status $status, expected 502" + pass "a failing quota-axi call surfaces as a 502, not a silent empty page" +} + +test_refuses_wildcard_bind +test_refuses_public_address_not_owned_by_this_host +test_refuses_when_tailscale_address_unconfirmed +test_accepts_this_hosts_own_tailnet_address +test_data_route_passes_quota_axi_json_through_unchanged +test_data_route_sets_no_store_and_json_content_type +test_page_route_serves_self_contained_html +test_unknown_route_is_404 +test_data_route_surfaces_upstream_failure_as_502 diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index e74eceb7abf..e3d83301f9f 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -2016,6 +2016,301 @@ EOF pass "--reemit reprints the digest without repeating startup's mutating sweeps and still drains queued wakes" } +# --- automatic /stow trigger 1: STOW DUE on compact/clear re-emit ------------ +# An explicit config/auto-stow grant and staleness gate on +# state/.last-stow-attempt that surface one STOW DUE line in a lock-owning +# compact/clear re-emit, silent when disabled, when the marker is current, and +# when the re-emit could not verify fleet-lock ownership. These exercise the +# real digest's public output only - never source bytes. + +# Set 's mtime to exactly seconds (touch -t takes a local-time +# stamp, not an epoch, on both platforms, so convert via BSD `date -r` or GNU +# `date -d @`). +set_stow_marker_mtime() { # + local epoch=$1 f=$2 stamp + touch "$f" + if stamp=$(date -r "$epoch" +%Y%m%d%H%M.%S 2>/dev/null); then + touch -t "$stamp" "$f" + else + stamp=$(date -d "@$epoch" +%Y%m%d%H%M.%S) + touch -t "$stamp" "$f" + fi +} + +run_reemit_for_stow() { # [source] + local home=$1 root=$2 path=$3 source=${4:-compact} + touch "$home/config/auto-stow" + FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_FAKE_HARNESS_PID=$$ PATH="$path" \ + env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + "$SESSION_START" --reemit --source "$source" +} + +test_stow_due_silent_without_captain_opt_in() { + local rec root home fakebin out + rec=$(new_world stow-due-disabled) + IFS='|' read -r root home fakebin <, or empty when absent. +stow_line_no() { # + printf '%s\n' "$1" | grep -n -F -- "$2" | head -1 | cut -d: -f1 +} + +test_stow_due_surfaced_when_marker_absent_on_reemit() { + local rec root home fakebin out due_at bootstrap_at + rec=$(new_world stow-due-absent) + IFS='|' read -r root home fakebin < "$home/state/.lock" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"-p 999999"*) printf 'claude\n'; exit 0 ;; + *"comm="*|*"args="*) printf 'bash\n'; exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/ps" + + out=$(run_reemit_for_stow "$home" "$root" "$fakebin:$BASE_PATH") + + assert_contains "$out" "READ-ONLY SESSION" \ + "the read-only re-emit fixture did not actually refuse the lock" + assert_not_contains "$out" "STOW DUE:" \ + "a re-emit without verified fleet-lock ownership was told to run the mutating /stow pass" + + pass "a re-emit that lacks verified fleet-lock ownership stays silent about /stow" +} + +test_stow_due_never_appears_on_ordinary_startup() { + local rec root home fakebin out + rec=$(new_world stow-due-startup) + IFS='|' read -r root home fakebin </dev/null 2>&1; then + echo "skip: node is required to run the PR communication gate" + exit 0 +fi + +complete_body() { + cat <<'EOF' +## CEO overview + +- **What is changing:** Members can see the status of their submitted requests. +- **Why it matters:** It reduces support messages asking for updates. +- **Customer or business impact:** Members get clearer communication and the team saves time. +- **Risk and rollout:** Low risk. Release through staging and confirm the main request flow. + +## Validation + +- **Checks passed:** Unit tests and type check. +- **Checks not run:** End-to-end test was not run locally. +- **Evidence and limitations:** Tested with a representative request. + +## Module-boundary decision + +Current module retained: request status rendering belongs with the existing member request page module. + +## Decision needed + +No decision required. +EOF +} + +incomplete_body() { + cat <<'EOF' +## Summary +This is a quick change. +EOF +} + +test_missing_remote_token_fails_closed() { + local out rc + set +e + out=$( + env -u PR_COMMUNICATION_SOT_TOKEN -u GITHUB_TOKEN -u GH_TOKEN \ + -u PR_COMMUNICATION_REQUIRE_REMOTE_SOT \ + node "$DRIFT" 2>&1 + ) + rc=$? + set -e + expect_code 1 "$rc" "missing remote credential" + assert_contains "$out" "Local pin OK" "drift check did not confirm the local SoT pin" + assert_contains "$out" "PR_COMMUNICATION_SOT_TOKEN is required" \ + "missing remote credential did not explain the fail-closed result" + pass "missing remote credential fails closed after verifying the local pin" +} + +test_vendored_unit_suite() { + local out rc + set +e + out=$(node --experimental-strip-types --test "$UNIT" 2>&1) + rc=$? + set -e + expect_code 0 "$rc" "vendored pr-communication unit suite" + pass "vendored pr-communication unit suite passes" +} + +test_cli_rejects_incomplete_description() { + local out rc + set +e + out=$( + PR_TITLE='WIP' PR_BODY="$(incomplete_body)" \ + node --experimental-strip-types "$CHECK" 2>&1 + ) + rc=$? + set -e + expect_code 1 "$rc" "incomplete PR description" + assert_contains "$out" "Cannot enter staging until completed:" \ + "incomplete description did not use the proven failure prefix" + assert_contains "$out" "CEO overview: What is changing" \ + "incomplete description did not require What is changing" + assert_contains "$out" "CEO overview: Why it matters" \ + "incomplete description did not require Why it matters" + assert_contains "$out" "CEO overview: Customer or business impact" \ + "incomplete description did not require Customer or business impact" + assert_contains "$out" "CEO overview: Risk and rollout" \ + "incomplete description did not require Risk and rollout" + assert_contains "$out" "Decision needed" \ + "incomplete description did not require Decision needed" + assert_contains "$out" "Module-boundary decision" \ + "incomplete description did not require Module-boundary decision" + assert_contains "$out" "Validation: Checks passed" \ + "incomplete description did not require Validation: Checks passed" + pass "CLI fails a non-compliant PR description" +} + +test_cli_rejects_untouched_module_boundary_template() { + local out rc + set +e + out=$( + PR_TITLE='Describe a complete customer-facing change' PR_BODY="$(cat "$TEMPLATE")" \ + node --experimental-strip-types "$CHECK" 2>&1 + ) + rc=$? + set -e + expect_code 1 "$rc" "untouched PR template" + assert_contains "$out" "Module-boundary decision" \ + "untouched template guidance incorrectly satisfied Module-boundary decision" + pass "CLI rejects untouched module-boundary template guidance" +} + +test_transient_remote_failure_uses_local_pin() { + local mode out rc + for mode in network 408 429 503; do + set +e + out=$(PR_COMMUNICATION_FETCH_FAILURE="$mode" PR_COMMUNICATION_SOT_TOKEN=test-token \ + node --import "$FETCH_FIXTURE" "$DRIFT" 2>&1) + rc=$? + set -e + expect_code 0 "$rc" "transient remote failure ($mode)" + assert_contains "$out" "Using the verified local pin" \ + "transient remote failure ($mode) did not fall back to the local pin" + done + pass "transient remote failures use the local pin" +} + +test_required_remote_failure_fails_closed() { + local out rc + set +e + out=$( + PR_COMMUNICATION_FETCH_FAILURE=503 PR_COMMUNICATION_SOT_TOKEN=test-token \ + PR_COMMUNICATION_REQUIRE_REMOTE_SOT=1 \ + node --import "$FETCH_FIXTURE" "$DRIFT" 2>&1 + ) + rc=$? + set -e + expect_code 1 "$rc" "required remote failure" + assert_contains "$out" "503" "required remote failure did not report its status" + pass "required remote failures fail closed" +} + +test_auth_remote_failure_fails_closed() { + local mode out rc + for mode in 401 403 404; do + set +e + out=$(PR_COMMUNICATION_FETCH_FAILURE="$mode" PR_COMMUNICATION_SOT_TOKEN=invalid \ + node --import "$FETCH_FIXTURE" "$DRIFT" 2>&1) + rc=$? + set -e + expect_code 1 "$rc" "remote authentication failure ($mode)" + assert_contains "$out" "$mode" \ + "remote authentication failure ($mode) did not report its status" + done + pass "401, 403, and 404 remote failures fail closed" +} + +test_invalid_token_header_fails_closed() { + local out rc + set +e + out=$(PR_COMMUNICATION_SOT_TOKEN=$'invalid\nheader' node "$DRIFT" 2>&1) + rc=$? + set -e + expect_code 1 "$rc" "invalid token header" + assert_not_contains "$out" "Using the verified local pin" \ + "invalid token header incorrectly used the local pin" + pass "invalid token header fails closed" +} + +test_tampered_entrypoint_fails_closed() { + local candidate out rc + candidate=$(mktemp -d "$ROOT/.pr-communication-candidate.XXXXXX") + mkdir -p "$candidate/scripts/pr-communication" + cp "$CHECK" "$candidate/scripts/check-pr-communication.ts" + cp "$ROOT/scripts/pr-communication/prCommunication.ts" \ + "$candidate/scripts/pr-communication/prCommunication.ts" + cp "$ROOT/scripts/pr-communication/SOURCE.sha256" \ + "$candidate/scripts/pr-communication/SOURCE.sha256" + printf '\n// tampered\n' >> "$candidate/scripts/check-pr-communication.ts" + set +e + out=$(PR_COMMUNICATION_CANDIDATE_ROOT="${candidate#"$ROOT"/}" \ + PR_COMMUNICATION_SOT_TOKEN=test-token node "$DRIFT" 2>&1) + rc=$? + set -e + rm -rf "$candidate" + expect_code 1 "$rc" "tampered PR communication entrypoint" + assert_contains "$out" "entrypoint does not match" \ + "tampered entrypoint did not fail its trusted pin" + pass "tampered entrypoint fails closed" +} + +test_candidate_pin_cannot_authorize_tampered_assessor() { + local candidate out rc + candidate=$(mktemp -d "$ROOT/.pr-communication-candidate.XXXXXX") + mkdir -p "$candidate/scripts/pr-communication" + cp "$CHECK" "$candidate/scripts/check-pr-communication.ts" + cp "$ROOT/scripts/pr-communication/prCommunication.ts" \ + "$candidate/scripts/pr-communication/prCommunication.ts" + printf '\n// tampered\n' >> "$candidate/scripts/pr-communication/prCommunication.ts" + node -e \ + 'const fs=require("node:fs"),c=require("node:crypto"); const p=process.argv[1]; const s=fs.readFileSync(p,"utf8"); const b=s.slice(s.indexOf("\n\n")+2); process.stdout.write(c.createHash("sha256").update(b).digest("hex")+"\n")' \ + "$candidate/scripts/pr-communication/prCommunication.ts" \ + > "$candidate/scripts/pr-communication/SOURCE.sha256" + set +e + out=$(PR_COMMUNICATION_CANDIDATE_ROOT="${candidate#"$ROOT"/}" \ + PR_COMMUNICATION_FETCH_FAILURE=503 PR_COMMUNICATION_SOT_TOKEN=test-token \ + node --import "$FETCH_FIXTURE" "$DRIFT" 2>&1) + rc=$? + set -e + rm -rf "$candidate" + expect_code 1 "$rc" "candidate-controlled assessor pin" + assert_contains "$out" "does not match trusted SOURCE.sha256" \ + "candidate-controlled pin authorized a tampered assessor" + pass "candidate pin cannot authorize a tampered assessor" +} + +test_remote_sot_authorizes_synchronized_assessor() { + local candidate body out rc + candidate=$(mktemp -d "$ROOT/.pr-communication-candidate.XXXXXX") + mkdir -p "$candidate/scripts/pr-communication" + cp "$CHECK" "$candidate/scripts/check-pr-communication.ts" + cp "$ROOT/scripts/pr-communication/prCommunication.ts" \ + "$candidate/scripts/pr-communication/prCommunication.ts" + printf '\n// synchronized update\n' >> "$candidate/scripts/pr-communication/prCommunication.ts" + body="$candidate/remote.ts" + sed '1,/^$/d' "$candidate/scripts/pr-communication/prCommunication.ts" > "$body" + node -e \ + 'const fs=require("node:fs"),c=require("node:crypto"); const p=process.argv[1]; process.stdout.write(c.createHash("sha256").update(fs.readFileSync(p,"utf8")).digest("hex")+"\n")' \ + "$body" > "$candidate/scripts/pr-communication/SOURCE.sha256" + set +e + out=$(PR_COMMUNICATION_CANDIDATE_ROOT="${candidate#"$ROOT"/}" \ + PR_COMMUNICATION_FETCH_BODY_PATH="$body" PR_COMMUNICATION_SOT_TOKEN=test-token \ + node --import "$FETCH_FIXTURE" "$DRIFT" 2>&1) + rc=$? + set -e + rm -rf "$candidate" + expect_code 0 "$rc" "synchronized remote assessor update" + assert_contains "$out" "Remote SoT matches" \ + "remote SoT did not authorize its synchronized assessor update" + pass "remote SoT authorizes a synchronized assessor update" +} + +test_cli_accepts_complete_description() { + local out rc + set +e + out=$( + PR_TITLE='Show members the status of their requests' \ + PR_BODY="$(complete_body)" \ + node --experimental-strip-types "$CHECK" 2>&1 + ) + rc=$? + set -e + expect_code 0 "$rc" "complete PR description" + assert_contains "$out" "PR communication is complete." \ + "complete description did not report success" + pass "CLI passes a compliant PR description" +} + +test_missing_remote_token_fails_closed +test_vendored_unit_suite +test_cli_rejects_incomplete_description +test_cli_rejects_untouched_module_boundary_template +test_cli_accepts_complete_description +test_transient_remote_failure_uses_local_pin +test_required_remote_failure_fails_closed +test_auth_remote_failure_fails_closed +test_invalid_token_header_fails_closed +test_tampered_entrypoint_fails_closed +test_candidate_pin_cannot_authorize_tampered_assessor +test_remote_sot_authorizes_synchronized_assessor