diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a32eee9d9e2..609e0b1530c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,6 +23,9 @@ jobs:
# Single owner of the lint definition (file set + config + version). Do not
# re-spell the shellcheck command here; keep CI and the pre-push gate on it.
- run: bin/fm-lint.sh
+ # ShellCheck cannot see the .mjs tools, and nothing else parsed them: a
+ # syntax error in one reached main green.
+ - run: bin/fm-lint-node.sh
behavior-test-plan:
name: Behavior test shard plan
@@ -66,6 +69,28 @@ jobs:
set -eu
npm install -g tasks-axi
tasks-axi --version
+ # The Pi credential store is what bin/fm-pi-refresh.mjs holds a lock in
+ # and writes through. Without Pi here its store contract skipped, and a
+ # skip that cannot fail is indistinguishable from coverage: two mutations
+ # of the actuator passed this job green. FM_PI_REQUIRED turns a failed
+ # install into a red test rather than a silent skip.
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - name: Install Pi for the credential-store contract
+ run: |
+ set -eu
+ npm install -g @earendil-works/pi-coding-agent
+ # By absolute path: a bare specifier does not resolve from the global
+ # prefix, and FM_PI_PACKAGE_DIR spares the test a PATH lookup for a
+ # binary it never runs.
+ root="$(npm root -g)/@earendil-works/pi-coding-agent"
+ test -f "$root/dist/core/auth-storage.js"
+ node -e "console.log(require('$root/package.json').version)"
+ {
+ echo "FM_PI_PACKAGE_DIR=$root"
+ echo "FM_PI_REQUIRED=1"
+ } >> "$GITHUB_ENV"
- name: Prepare private shard state
run: |
set -eu
diff --git a/bin/fm-credential-expiry.py b/bin/fm-credential-expiry.py
index 2905fe67ce3..66db9d27422 100755
--- a/bin/fm-credential-expiry.py
+++ b/bin/fm-credential-expiry.py
@@ -40,11 +40,13 @@
file, over the byte bound, malformed, or carrying no token
material at all
-`refreshable` is deliberately distinct from `usable`. Firstmate has no token
-refresh anywhere - no job, no timer, no call site - so nothing on the host
-turns a `refreshable` profile into a `usable` one. Only an interactive
-provider login, or the provider CLI reaching its own auth host from wherever
-the profile runs, does that.
+`refreshable` is deliberately distinct from `usable`, and stays distinct even
+where a refresher exists. `bin/fm-pi-refresh.py` renews Pi profiles on the
+host, so a `refreshable` Pi profile can become `usable` there; nothing renews
+a codex or claude profile, and no caller whose network excludes the provider's
+auth host can turn a `refreshable` profile of any harness into a usable one
+where it runs. Reporting `refreshable` therefore still means "not usable
+here", and a caller that needs a live credential asks for `usable`.
Usage:
fm-credential-expiry.py report [--json] [--margin-seconds N]
@@ -336,13 +338,13 @@ def inspect_profile(
if facts.get("access_expires_at") is not None and facts["access_expires_at"] > moment:
record["detail"] = (
f"{resolved_harness} access token expires at {expiry}, inside the "
- "window this caller needs it for; refresh material is present but "
- "firstmate never refreshes it"
+ "window this caller needs it for; refresh material is present, "
+ "which this caller cannot use where it runs"
)
else:
record["detail"] = (
f"{resolved_harness} access token expired at {expiry}; refresh "
- "material is present but firstmate never refreshes it"
+ "material is present, which this caller cannot use where it runs"
)
return record
diff --git a/bin/fm-lint-node.sh b/bin/fm-lint-node.sh
new file mode 100755
index 00000000000..34632f199a3
--- /dev/null
+++ b/bin/fm-lint-node.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# fm-lint-node.sh - parse every JavaScript tool in bin/, the way fm-lint.sh
+# parses every shell script.
+#
+# Usage:
+# bin/fm-lint-node.sh [file...]
+#
+# ShellCheck's file set is `bin/*.sh bin/backends/*.sh tests/*.sh`, so the .mjs
+# and .cjs tools in bin/ were parsed by nothing. A syntax error in one of them
+# reached main with a green lint. `node --check` is not a linter and does not
+# pretend to be one; it is the parse that was missing.
+set -eu
+
+ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
+
+command -v node >/dev/null 2>&1 || {
+ echo "fm-lint-node: node is required to parse the JavaScript tools" >&2
+ exit 1
+}
+
+if [ "$#" -gt 0 ]; then
+ files=("$@")
+else
+ # Canonical file set, the same shape fm-lint.sh owns for shell.
+ files=("$ROOT"/bin/*.mjs "$ROOT"/bin/*.cjs)
+fi
+
+printf 'fm-lint-node: %s (%s files)\n' "$(node --version)" "${#files[@]}" >&2
+status=0
+for file in "${files[@]}"; do
+ [ -e "$file" ] || continue
+ # --check parses without executing, so a tool with import-time side effects
+ # is still safe to lint.
+ node --check "$file" || status=1
+done
+exit "$status"
diff --git a/bin/fm-pi-refresh.mjs b/bin/fm-pi-refresh.mjs
new file mode 100755
index 00000000000..d1d876cc40c
--- /dev/null
+++ b/bin/fm-pi-refresh.mjs
@@ -0,0 +1,279 @@
+#!/usr/bin/env node
+// fm-pi-refresh.mjs - rotate Pi OAuth credentials through Pi's own refresh and
+// its own credential lock.
+//
+// Usage:
+// fm-pi-refresh.mjs --pi-root
--pool --slot [--slot ...]
+// [--timeout-ms ]
+//
+// This is the actuator half of `bin/fm-pi-refresh.py`, which owns selection,
+// backup, re-projection, and the operator contract. This file owns exactly one
+// thing: performing the rotation the way Pi itself performs it.
+//
+// Why Node rather than a Python HTTP call: the rotation has two halves and only
+// one of them is the HTTP request. The other is the write-back, which must land
+// under the same lock Pi takes, or a running Pi overwrites it. Pi's lock is
+// `proper-lockfile` on the credential path, held across the refresh so a
+// concurrent refresher cannot burn the same refresh token twice. Reimplementing
+// that protocol in another language would be re-deriving an interop contract;
+// calling Pi's own `AuthStorage` uses it. `~/.pi/agent/fm-patches/reauth.sh` is
+// the prior art that writes the pool WITHOUT the lock, and its own header warns
+// that a running Pi may overwrite what it writes. This does not have that flaw.
+//
+// The two modules are imported by absolute path because Pi's package `exports`
+// map publishes neither: `@earendil-works/pi-coding-agent/dist/core/...` is
+// refused with ERR_PACKAGE_PATH_NOT_EXPORTED, and `@earendil-works/pi-ai` is a
+// nested dependency that does not resolve as a bare specifier at all. The
+// package barrel is not an option either: it exports `ModelRuntime` but not
+// `AuthStorage`, and importing it pulls in the whole TUI.
+//
+// Output is one JSON record per slot on stdout, diagnostics on stderr. No token
+// value is ever emitted: accounts and tokens appear only as truncated digests.
+
+import { createHash } from "node:crypto";
+import { statSync } from "node:fs";
+import { pathToFileURL } from "node:url";
+import { join } from "node:path";
+
+// A rotation is one HTTPS round trip. Pi itself allows 15s; this is a little
+// wider because it runs unattended and a retry costs a whole scheduled cycle.
+const DEFAULT_TIMEOUT_MS = 20_000;
+
+// Digest prefix length. Long enough that two accounts in one fleet cannot
+// collide in practice, short enough that it is obviously not a token.
+const DIGEST_CHARACTERS = 12;
+
+// Anything longer than this in the base64url alphabet is treated as token
+// material and redacted out of an error message. Pi's own refresh error text
+// interpolates the provider's JSON response, and one failure mode of that
+// response is "carries an access_token but no expires_in" - so the error path
+// is a real leak path, not a theoretical one.
+const TOKEN_LIKE = /[A-Za-z0-9_-]{40,}/g;
+const MAX_ERROR_CHARACTERS = 300;
+
+class RefreshError extends Error {}
+
+function fail(message) {
+ throw new RefreshError(message);
+}
+
+export function digest(value) {
+ if (typeof value !== "string" || value.trim() === "") return "none";
+ return createHash("sha256").update(value).digest("hex").slice(0, DIGEST_CHARACTERS);
+}
+
+export function instant(milliseconds) {
+ if (typeof milliseconds !== "number" || !Number.isFinite(milliseconds)) return null;
+ return new Date(milliseconds).toISOString();
+}
+
+/** Strip token-shaped runs out of a provider error before it is reported. */
+export function safeErrorText(error) {
+ const raw = error instanceof Error ? error.message : String(error);
+ return raw.replace(TOKEN_LIKE, "[redacted]").slice(0, MAX_ERROR_CHARACTERS);
+}
+
+/**
+ * Load Pi's credential store and its OpenAI Codex OAuth flow from one install.
+ *
+ * Both paths are asserted before import so a moved or upgraded Pi produces an
+ * operator sentence naming the file, rather than a Node module-resolution
+ * stack trace.
+ */
+export async function loadPiModules(piRoot) {
+ const storeModule = join(piRoot, "dist/core/auth-storage.js");
+ const oauthModule = join(
+ piRoot,
+ "node_modules/@earendil-works/pi-ai/dist/auth/oauth/openai-codex.js",
+ );
+ for (const path of [storeModule, oauthModule]) {
+ try {
+ if (!statSync(path).isFile()) fail(`Pi module is not a regular file at ${path}`);
+ } catch (error) {
+ if (error instanceof RefreshError) throw error;
+ fail(
+ `Pi install at ${piRoot} does not carry ${path}; this Pi version moved or ` +
+ "renamed the module this refresher drives",
+ );
+ }
+ }
+ const { AuthStorage } = await import(pathToFileURL(storeModule).href);
+ const { openaiCodexOAuth } = await import(pathToFileURL(oauthModule).href);
+ if (typeof AuthStorage?.create !== "function") {
+ fail(`Pi credential store at ${storeModule} exposes no AuthStorage.create`);
+ }
+ if (typeof openaiCodexOAuth?.refresh !== "function") {
+ fail(`Pi OAuth flow at ${oauthModule} exposes no openaiCodexOAuth.refresh`);
+ }
+ return { storeFactory: (path) => AuthStorage.create(path), oauth: openaiCodexOAuth };
+}
+
+/**
+ * Rotate each named slot in place, one at a time.
+ *
+ * Sequential on purpose: Pi's store serializes on one lock per credential file,
+ * so concurrent slots would queue on that lock anyway while each held an open
+ * HTTPS request against the same deadline.
+ *
+ * The refresh runs INSIDE `modify`, which is where Pi runs its own, because
+ * that is what makes the read, the rotation, and the write one critical
+ * section. Refreshing outside the lock and writing after would let two
+ * refreshers spend the same refresh token, and a rotating provider invalidates
+ * the loser.
+ */
+export async function refreshSlots({ storeFactory, oauth, poolPath, slots, timeoutMs }) {
+ const store = storeFactory(poolPath);
+ const deadline = typeof timeoutMs === "number" ? timeoutMs : DEFAULT_TIMEOUT_MS;
+ const records = [];
+ for (const slot of slots) {
+ // Everything the outcome depends on is read INSIDE the lock. An optimistic
+ // read outside it is not merely redundant: `AuthStorage.read` gives up
+ // after Pi's 30s lock-acquisition deadline and swallows the failure over an
+ // empty snapshot, so a slot whose credential is merely held by a running Pi
+ // reads as having no credential at all. Reporting a live account as absent
+ // is the wrong diagnosis to hand an unattended run.
+ let before;
+ let rotated_at_provider = false;
+ let after;
+ try {
+ after = await store.modify(slot, async (current) => {
+ before = current;
+ if (current === undefined) return undefined;
+ if (current.type !== "oauth") return undefined;
+ // The Codex flow derives its account from the access token and throws
+ // without one, and only Codex credentials carry `accountId` at all.
+ // Handing an Anthropic credential to this flow would POST its refresh
+ // token to the wrong provider's token endpoint, so the shape is
+ // checked here rather than left to the caller's slot naming.
+ if (typeof current.accountId !== "string" || current.accountId.trim() === "") {
+ return undefined;
+ }
+ const next = await oauth.refresh(current, AbortSignal.timeout(deadline));
+ // Past this line the provider has issued a rotation and invalidated
+ // what we held, whether or not the write below lands.
+ rotated_at_provider = true;
+ return next;
+ });
+ } catch (error) {
+ records.push({
+ slot,
+ // A refusal from the provider and a failure to persist a rotation the
+ // provider already made are not the same event. The second one means
+ // the host is holding a dead refresh token, and no pre-rotation copy
+ // helps: restoring it restores the token the provider just retired.
+ outcome: rotated_at_provider ? "rotated-unpersisted" : "failed",
+ detail: rotated_at_provider
+ ? "the provider rotated this credential and it could not be stored, so the " +
+ "host now holds a retired token and this profile needs an interactive " +
+ `login: ${safeErrorText(error)}`
+ : safeErrorText(error),
+ });
+ continue;
+ }
+ if (before === undefined) {
+ records.push({ slot, outcome: "absent", detail: `no credential stored under ${slot}` });
+ continue;
+ }
+ if (before.type !== "oauth") {
+ records.push({
+ slot,
+ outcome: "not-oauth",
+ detail: `credential under ${slot} is ${before.type}, which has no refresh`,
+ });
+ continue;
+ }
+ if (typeof before.accountId !== "string" || before.accountId.trim() === "") {
+ records.push({
+ slot,
+ outcome: "unsupported-provider",
+ detail: `credential under ${slot} carries no accountId, so it is not an ` +
+ "OpenAI Codex credential and this flow must not rotate it",
+ });
+ continue;
+ }
+ if (after?.type !== "oauth") {
+ records.push({
+ slot,
+ outcome: "not-oauth",
+ detail: `credential under ${slot} stopped being an oauth credential during refresh`,
+ });
+ continue;
+ }
+ // `modify` returns the stored credential unchanged when its callback
+ // returns undefined, so a rotation cannot be inferred from a truthy
+ // result. Only a changed access token proves one happened.
+ const rotated = digest(before.access) !== digest(after.access);
+ records.push({
+ slot,
+ outcome: rotated ? "refreshed" : "unchanged",
+ account: digest(after.accountId),
+ account_stable: digest(before.accountId) === digest(after.accountId),
+ access_rotated: rotated,
+ refresh_rotated: digest(before.refresh) !== digest(after.refresh),
+ expires_before: instant(before.expires),
+ expires_after: instant(after.expires),
+ });
+ }
+ return records;
+}
+
+function parseArguments(argv) {
+ const options = { slots: [], timeoutMs: DEFAULT_TIMEOUT_MS };
+ for (let index = 0; index < argv.length; index += 1) {
+ const flag = argv[index];
+ const value = argv[index + 1];
+ switch (flag) {
+ case "--pi-root":
+ options.piRoot = value;
+ index += 1;
+ break;
+ case "--pool":
+ options.poolPath = value;
+ index += 1;
+ break;
+ case "--slot":
+ if (value === undefined) fail("--slot needs a slot name");
+ options.slots.push(value);
+ index += 1;
+ break;
+ case "--timeout-ms":
+ options.timeoutMs = Number(value);
+ index += 1;
+ break;
+ default:
+ fail(`unknown argument ${flag}`);
+ }
+ }
+ if (!options.piRoot) fail("--pi-root is required");
+ if (!options.poolPath) fail("--pool is required");
+ if (options.slots.length === 0) fail("name at least one --slot");
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
+ fail("--timeout-ms must be a positive number of milliseconds");
+ }
+ return options;
+}
+
+export async function main(argv) {
+ const options = parseArguments(argv);
+ const { storeFactory, oauth } = await loadPiModules(options.piRoot);
+ const records = await refreshSlots({
+ storeFactory,
+ oauth,
+ poolPath: options.poolPath,
+ slots: options.slots,
+ timeoutMs: options.timeoutMs,
+ });
+ for (const record of records) process.stdout.write(JSON.stringify(record) + "\n");
+ return records.every((record) => record.outcome === "refreshed") ? 0 : 1;
+}
+
+// `import.meta.main` is not available on every Node this repo runs on, so the
+// entrypoint check compares the resolved argv path instead.
+if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
+ try {
+ process.exitCode = await main(process.argv.slice(2));
+ } catch (error) {
+ process.stderr.write(`PI REFRESH ADAPTER REFUSED: ${safeErrorText(error)}\n`);
+ process.exitCode = 2;
+ }
+}
diff --git a/bin/fm-pi-refresh.py b/bin/fm-pi-refresh.py
new file mode 100755
index 00000000000..7a17d361607
--- /dev/null
+++ b/bin/fm-pi-refresh.py
@@ -0,0 +1,853 @@
+#!/usr/bin/env python3
+"""Renew Pi provider credentials before they expire, and republish them.
+
+This module is the single owner of firstmate's provider-credential *renewal*.
+`bin/fm-credential-expiry.py` owns the question "can this credential still
+authenticate", and `bin/fm-pi-account-home.py` owns "how does one pooled Pi
+profile become the single-profile account home its consumers read". This owns
+the third question those two deliberately leave open: what turns a credential
+that is about to die back into a live one, with no human at the keyboard.
+
+Why this exists: every Pi profile in the fleet expires on the same day, and
+nothing on the host renewed one. A reviewer compartment cannot renew its own,
+because its egress allowlist carries the provider's API host and deliberately
+not the provider's auth host, so renewal has to happen here and be staged
+outward. Until it did, the whole fleet stopped on a date rather than on a
+decision.
+
+What it does, in order:
+
+ select read the pool, name the slots whose access token dies inside the
+ horizon, and refuse the ones whose shape cannot be renewed at all
+ back up copy the pool first. Pi rewrites the credential file in place with
+ a plain truncating write, so an interrupted write loses every slot
+ at once, not just the one being renewed
+ rotate hand the due slots to `bin/fm-pi-refresh.mjs`, which drives Pi's
+ own OAuth refresh inside Pi's own credential lock
+ republish re-project each renewed slot into the account home its consumers
+ read, because a renewal that stays in the pool leaves every
+ reviewer holding the credential that is about to expire
+ verify re-read each republished home through the expiry owner and require
+ it to be usable, so the run's exit code means the fleet is live
+ rather than that an HTTP call returned 200
+
+What it never does: log in. A profile whose refresh material is gone needs a
+human and a browser, and this says so by name instead of pretending otherwise.
+
+Token material is never printed, returned, or logged. Accounts and tokens
+appear only as truncated digests, and a provider error is redacted before it is
+reported, because Pi's own refresh error text interpolates the provider's JSON
+response and one failure shape of that response carries a token.
+
+Usage:
+ fm-pi-refresh.py report [--source PATH] [--horizon-seconds N] [--json]
+ fm-pi-refresh.py run-once [--source PATH] [--destination-root DIR]
+ [--horizon-seconds N] [--slot NAME]... [--all]
+ [--backup-root DIR] [--timeout-ms N] [--json]
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime
+import importlib.util
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import stat
+import subprocess
+import sys
+import time
+from typing import Any
+
+BIN_DIR = Path(__file__).resolve().parent
+ACCOUNT_HOME_TOOL = BIN_DIR / "fm-pi-account-home.py"
+CREDENTIAL_EXPIRY_TOOL = BIN_DIR / "fm-credential-expiry.py"
+REFRESH_ADAPTER = BIN_DIR / "fm-pi-refresh.mjs"
+
+DEFAULT_SOURCE = "~/.pi/agent/auth.json"
+
+# The observed credential life is ten days. Renewing at half life means the
+# machine has to be off for five consecutive days before a token is lost, while
+# still asking the provider for a rotation only about twice a fortnight.
+DEFAULT_HORIZON_SECONDS = 5 * 24 * 60 * 60
+
+# One rotation is one HTTPS round trip per slot. The adapter enforces this per
+# slot; the whole invocation gets the same number times the slot count plus a
+# process-start allowance, so one wedged slot cannot hold a scheduled run open.
+DEFAULT_TIMEOUT_MS = 20_000
+ADAPTER_START_ALLOWANCE_SECONDS = 30
+
+# Pi gives up acquiring its credential lock after 30 seconds, so a slot can
+# spend that long waiting before its own refresh timeout even starts. Budgeting
+# only the round trip made the whole invocation killable mid-write, which is
+# exactly the interrupted write the pre-rotation copy exists to survive, self
+# inflicted.
+ADAPTER_LOCK_WAIT_SECONDS = 30
+
+# The adapter emits one small JSON object per slot. Anything larger is not
+# output we know how to read, and is refused rather than parsed.
+MAX_ADAPTER_OUTPUT_BYTES = 256 * 1024
+
+# Used only when a Pi install declares no readable Node floor of its own. Both
+# installs on this machine declare >= 22.19.0.
+FALLBACK_NODE_MAJOR = 22
+NODE_PROBE_TIMEOUT_SECONDS = 10
+
+PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent"
+
+# Backups exist for one failure: an interrupted in-place write of the pool.
+# Once a run has proved the pool still parses and still carries every slot, the
+# older copies protect nothing and are only credential material at rest.
+BACKUP_KEEP = 3
+DEFAULT_BACKUP_ROOT = "~/.local/state/firstmate/pi-credential-backups"
+
+# A second-resolution stamp collides when two runs land inside one second. More
+# than this many in the same second is a loop, not a schedule.
+MAX_BACKUPS_PER_SECOND = 100
+
+# The account homes the reviewer roster names live beside the Agent Fleet
+# account pool, under the `pi` vendor directory.
+DEFAULT_DESTINATION_ROOT = "~/.local/share/agent-fleet/accounts/pi"
+
+# Republished homes are verified with headroom, not merely "not yet expired": a
+# credential that dies an hour from now is not a successful renewal.
+VERIFY_MARGIN_SECONDS = 24 * 60 * 60
+
+
+# Any run of this length in the base64url alphabet is treated as token material.
+# The adapter redacts its own writes, but stderr is a channel this side does not
+# control: Node warnings, import-time output and internal traces land there too,
+# and slicing is not redaction.
+TOKEN_LIKE = re.compile(r"[A-Za-z0-9_-]{40,}")
+
+
+def redact(text: str, limit: int = 300) -> str:
+ """Strip token-shaped runs, then truncate. Never the other way round."""
+
+ return TOKEN_LIKE.sub("[redacted]", text)[:limit]
+
+
+class RefreshError(RuntimeError):
+ """One renewal run cannot proceed, or did not produce a live fleet."""
+
+
+def fail(message: str) -> None:
+ raise RefreshError(message)
+
+
+def load_tool(path: Path, name: str) -> Any:
+ """Load a sibling bin script as a module, the way its other callers do."""
+
+ spec = importlib.util.spec_from_file_location(name, path)
+ if spec is None or spec.loader is None:
+ fail(f"cannot load {path}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def account_home_tool() -> Any:
+ return load_tool(ACCOUNT_HOME_TOOL, "fm_pi_account_home")
+
+
+def credential_expiry_tool() -> Any:
+ return load_tool(CREDENTIAL_EXPIRY_TOOL, "fm_credential_expiry")
+
+
+def pi_executable() -> Path:
+ """Locate the Pi entrypoint WITHOUT resolving it through its symlink.
+
+ The unresolved path is the one that matters: this machine carries two Pi
+ installs, and the Node beside each one is the Node that install works
+ under. `bin/fm-crosscheck.py: pi_reviewer_command` picks its reviewer Node
+ by the same sibling rule for the same reason.
+ """
+
+ override = os.environ.get("FM_PI_BIN")
+ name = override or "pi"
+ located = shutil.which(name)
+ if not located:
+ # `which` refuses a file without the execute bit exactly as it refuses
+ # an absent one, and telling an operator who DID set FM_PI_BIN that it
+ # is "not on PATH" sends them to the wrong problem.
+ if override and Path(override).expanduser().exists():
+ fail(f"FM_PI_BIN names {override}, which exists but is not executable")
+ fail(
+ f"Pi executable {name!r} is not on PATH; set FM_PI_BIN to its path. "
+ "A scheduled run has almost no PATH, so it must set it."
+ )
+ return Path(located)
+
+
+def pi_package_root(entrypoint: Path | None = None) -> Path:
+ """Resolve the installed Pi package directory from its entrypoint.
+
+ The package root is what this needs, rather than the launch command
+ `fm-crosscheck.py` builds, because the two modules it drives are internal
+ and Pi's `exports` map publishes neither, so they are reached by path.
+ """
+
+ resolved = (entrypoint or pi_executable()).resolve()
+ root = resolved.parent.parent
+ manifest_path = root / "package.json"
+ if not manifest_path.is_file():
+ fail(
+ f"Pi entrypoint {resolved} does not sit inside a package directory "
+ f"({root} carries no package.json)"
+ )
+ # Named, not merely shaped. Any directory holding a package.json satisfied
+ # the old check, which pushed the real refusal one layer down into the
+ # module import where it reads as a missing file rather than a wrong Pi.
+ try:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ except (OSError, ValueError) as exc:
+ fail(f"Pi package manifest at {manifest_path} is unreadable: {type(exc).__name__}")
+ if manifest.get("name") != PI_PACKAGE_NAME:
+ fail(
+ f"{root} is not a {PI_PACKAGE_NAME} install (its package.json names "
+ f"{manifest.get('name')!r}); check FM_PI_BIN"
+ )
+ return root
+
+
+def pi_node_floor(root: Path) -> int:
+ """Read the major Node version this Pi install declares it needs."""
+
+ try:
+ manifest = json.loads((root / "package.json").read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return FALLBACK_NODE_MAJOR
+ declared = ""
+ engines = manifest.get("engines")
+ if isinstance(engines, dict) and isinstance(engines.get("node"), str):
+ declared = engines["node"]
+ match = re.search(r">=\s*(\d+)", declared)
+ return int(match.group(1)) if match else FALLBACK_NODE_MAJOR
+
+
+def node_major(binary: str) -> int | None:
+ try:
+ completed = subprocess.run(
+ [binary, "--version"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ timeout=NODE_PROBE_TIMEOUT_SECONDS,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return None
+ match = re.match(r"v(\d+)\.", completed.stdout.decode("utf-8", errors="replace").strip())
+ return int(match.group(1)) if match else None
+
+
+def node_binary(entrypoint: Path | None = None) -> str:
+ """Pin the Node that runs the adapter to the one this Pi works under.
+
+ A Node older than the install's own floor is refused rather than tried.
+ Both Pi installs on this machine declare `>=22.19.0`, and the one whose
+ sibling Node is older does not merely warn: it dies inside undici with
+ `webidl.util.markAsUncloneable is not a function`. Reporting that as a
+ renewal failure would send an operator hunting the provider.
+ """
+
+ entrypoint = entrypoint or pi_executable()
+ floor = pi_node_floor(pi_package_root(entrypoint))
+ override = os.environ.get("FM_PI_NODE_BIN")
+ if override:
+ located = shutil.which(override) or override
+ if not Path(located).is_file():
+ fail(f"FM_PI_NODE_BIN does not name a file: {override}")
+ candidates = [str(Path(located).resolve())]
+ else:
+ candidates = []
+ sibling = entrypoint.parent / "node"
+ if sibling.is_file():
+ candidates.append(str(sibling.resolve()))
+ located = shutil.which("node")
+ if located:
+ candidates.append(str(Path(located).resolve()))
+ if not candidates:
+ fail("node is not on PATH; set FM_PI_NODE_BIN to the Node that runs Pi")
+ # Probed once each. A hanging Node costs NODE_PROBE_TIMEOUT_SECONDS, and
+ # probing again to build the refusal message doubled that before the
+ # adapter's own timeout could apply.
+ probed = [(candidate, node_major(candidate)) for candidate in candidates]
+ for candidate, major in probed:
+ if major is not None and major >= floor:
+ return candidate
+ reported = ", ".join(
+ f"{candidate} (v{major if major is not None else '?'})"
+ for candidate, major in probed
+ )
+ fail(
+ f"no Node meets the floor this Pi install declares (>= {floor}); tried: "
+ f"{reported}. Set FM_PI_NODE_BIN to a newer Node."
+ )
+ raise AssertionError("unreachable")
+
+
+def utc(seconds: float) -> str:
+ moment = datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc)
+ return moment.replace(microsecond=0).isoformat().replace("+00:00", "Z")
+
+
+def expiry_seconds(entry: dict[str, Any]) -> float | None:
+ """Read one pool entry's expiry, which Pi records in milliseconds."""
+
+ value = entry.get("expires")
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return None
+ seconds = float(value) / 1000.0
+ return seconds if seconds > 0 else None
+
+
+def select_due(
+ pool: dict[str, Any],
+ *,
+ horizon_seconds: float,
+ now: float,
+ requested: list[str] | None = None,
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ """Split the pool into slots to renew now and slots to leave alone.
+
+ Shape is judged by the account-home owner's own `entry_faults`, so "which
+ Pi credentials are well formed" has one definition rather than two. A
+ faulted slot is never renewed: a blank refresh token cannot be rotated, and
+ a run that tried would report a provider error where the real answer is
+ that a human has to log in.
+ """
+
+ home_tool = account_home_tool()
+ names = sorted(pool) if requested is None else list(dict.fromkeys(requested))
+ missing = [name for name in names if name not in pool]
+ if missing:
+ fail("Pi credential pool has no profile named: " + ", ".join(sorted(missing)))
+
+ due: list[dict[str, Any]] = []
+ held: list[dict[str, Any]] = []
+ for name in names:
+ entry = pool[name]
+ record: dict[str, Any] = {
+ "slot": name,
+ "account": (
+ home_tool.account_digest(entry) if isinstance(entry, dict) else "none"
+ ),
+ "expires_at": None,
+ "expires_in_seconds": None,
+ "reason": "",
+ }
+ faults = home_tool.entry_faults(entry)
+ if faults:
+ record["reason"] = "unrenewable: " + "; ".join(faults)
+ held.append(record)
+ continue
+ expires = expiry_seconds(entry)
+ if expires is None:
+ record["reason"] = "unrenewable: has no readable expiry"
+ held.append(record)
+ continue
+ record["expires_at"] = utc(expires)
+ record["expires_in_seconds"] = int(expires - now)
+ if expires - now <= horizon_seconds:
+ record["reason"] = "due"
+ due.append(record)
+ else:
+ record["reason"] = "outside the renewal horizon"
+ held.append(record)
+ return due, held
+
+
+def private_directory(path: Path) -> None:
+ """Create one owner-only directory, refusing to write through a link.
+
+ `mkdir(parents=True)` applies its mode to the leaf only, so intermediates
+ land at the caller's umask, and `exist_ok=True` follows a symlinked
+ directory because `isdir` does. Both matter here: what lands underneath is
+ a copy of every credential in the fleet.
+ """
+
+ missing: list[Path] = []
+ walk = path
+ while True:
+ try:
+ existing = walk.lstat()
+ except FileNotFoundError:
+ missing.append(walk)
+ if walk.parent == walk:
+ break
+ walk = walk.parent
+ continue
+ except OSError as exc:
+ fail(f"backup path is unreadable at {walk}: {exc.strerror}")
+ if not stat.S_ISDIR(existing.st_mode) or walk.is_symlink():
+ fail(f"refusing to write a credential backup through {walk}")
+ break
+ for component in reversed(missing):
+ os.mkdir(component, 0o700)
+ os.chmod(component, 0o700)
+ os.chmod(path, 0o700)
+
+
+def backup_pool(
+ source: Path, backup_root: Path, *, now: float, expected: set[str]
+) -> Path:
+ """Copy the pool before anything rotates.
+
+ Pi rewrites the credential file with a plain truncating `writeFileSync`
+ rather than a temp file and a rename, so an interrupted write does not lose
+ the slot being renewed - it loses every slot in the file. This copy is the
+ only thing between that and eight accounts needing a browser.
+ """
+
+ private_directory(backup_root)
+ stamp = datetime.datetime.fromtimestamp(now, datetime.timezone.utc).strftime(
+ "%Y%m%dT%H%M%SZ"
+ )
+ # The stamp resolves to a second, so two runs inside one second collide.
+ # The suffix is zero padded because pruning orders these by name, and an
+ # unpadded 10 would sort before 2.
+ handle = None
+ for attempt in range(MAX_BACKUPS_PER_SECOND):
+ destination = backup_root / f"auth.json.{stamp}-{attempt:02d}"
+ try:
+ handle = os.open(
+ str(destination), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600
+ )
+ break
+ except FileExistsError:
+ continue
+ except OSError as exc:
+ fail(f"the pre-rotation copy cannot be written at {destination}: {exc.strerror}")
+ if handle is None:
+ fail(
+ f"{MAX_BACKUPS_PER_SECOND} pre-rotation copies already exist for {stamp}; "
+ "a renewal is looping rather than running"
+ )
+ try:
+ with os.fdopen(handle, "wb") as stream:
+ stream.write(source.read_bytes())
+ stream.flush()
+ os.fsync(stream.fileno())
+ except BaseException:
+ try:
+ os.unlink(destination)
+ except OSError:
+ pass
+ raise
+ # The read above is unlocked, which is the same non-atomic read this module
+ # faults Pi for. A torn copy is worse than no copy, because it is only ever
+ # reached for by an operator who already has a damaged pool, so the copy is
+ # proved before it is offered as one.
+ damage = pool_is_intact(destination, expected)
+ if damage:
+ try:
+ os.unlink(destination)
+ except OSError:
+ pass
+ fail(
+ f"the pre-renewal copy of {source} did not come out intact "
+ f"({damage}); nothing was rotated"
+ )
+ return destination
+
+
+def prune_backups(backup_root: Path, keep: int) -> list[Path]:
+ """Drop backups older than the newest `keep`, once the pool is proved good."""
+
+ copies = sorted(
+ (path for path in backup_root.glob("auth.json.*") if path.is_file()),
+ key=lambda path: path.name,
+ )
+ removed: list[Path] = []
+ for path in copies[: max(0, len(copies) - keep)]:
+ try:
+ path.unlink()
+ removed.append(path)
+ except OSError:
+ # A backup that will not delete is not a reason to fail a renewal
+ # that already succeeded; it is reported and left alone.
+ pass
+ return removed
+
+
+def run_adapter(
+ *, source: Path, slots: list[str], timeout_ms: int
+) -> list[dict[str, Any]]:
+ """Drive `fm-pi-refresh.mjs` and read back one record per slot."""
+
+ if not REFRESH_ADAPTER.is_file():
+ fail(f"the renewal adapter is missing at {REFRESH_ADAPTER}")
+ entrypoint = pi_executable()
+ command = [
+ node_binary(entrypoint),
+ str(REFRESH_ADAPTER),
+ "--pi-root",
+ str(pi_package_root(entrypoint)),
+ "--pool",
+ str(source),
+ "--timeout-ms",
+ str(timeout_ms),
+ ]
+ for slot in slots:
+ command += ["--slot", slot]
+ budget = (
+ timeout_ms / 1000.0 + ADAPTER_LOCK_WAIT_SECONDS
+ ) * len(slots) + ADAPTER_START_ALLOWANCE_SECONDS
+ try:
+ completed = subprocess.run(
+ command,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=budget,
+ check=False,
+ )
+ except subprocess.TimeoutExpired:
+ fail(
+ f"the renewal adapter did not finish within {int(budget)}s for "
+ f"{len(slots)} slots; the pool may hold a partially renewed set"
+ )
+ except OSError as exc:
+ fail(f"the renewal adapter could not be launched: {exc.strerror or exc}")
+ if len(completed.stdout) > MAX_ADAPTER_OUTPUT_BYTES:
+ fail(
+ f"the renewal adapter emitted more than {MAX_ADAPTER_OUTPUT_BYTES} "
+ "bytes, which is not output this reads"
+ )
+ records: list[dict[str, Any]] = []
+ for line in completed.stdout.decode("utf-8", errors="replace").splitlines():
+ if not line.strip():
+ continue
+ try:
+ value = json.loads(line)
+ except ValueError:
+ fail("the renewal adapter emitted a line that is not a JSON record")
+ if not isinstance(value, dict) or "slot" not in value:
+ fail("the renewal adapter emitted a record naming no slot")
+ records.append(value)
+ if not records:
+ detail = completed.stderr.decode("utf-8", errors="replace").strip()
+ fail(
+ "the renewal adapter reported nothing for "
+ f"{len(slots)} slots (exit {completed.returncode})"
+ + (f": {redact(detail)}" if detail else "")
+ )
+ seen = {record["slot"] for record in records}
+ unreported = [slot for slot in slots if slot not in seen]
+ if unreported:
+ fail(
+ "the renewal adapter reported no outcome for: " + ", ".join(unreported)
+ )
+ return records
+
+
+def reproject(
+ *, source: Path, destination_root: Path, slots: list[str]
+) -> tuple[list[str], list[str]]:
+ """Republish renewed slots into the account homes that already exist.
+
+ Only existing homes are refreshed. Creating one is how an operator adds a
+ reviewer to the fleet, and a renewal run is the wrong place to do it
+ silently: the roster names account homes by path, so a home that appears on
+ its own is a reviewer nobody added.
+ """
+
+ present = [slot for slot in slots if (destination_root / slot).is_dir()]
+ absent = [slot for slot in slots if slot not in present]
+ if not present:
+ return [], absent
+ command = [
+ sys.executable,
+ str(ACCOUNT_HOME_TOOL),
+ "project",
+ "--source",
+ str(source),
+ "--destination-root",
+ str(destination_root),
+ ]
+ for slot in present:
+ command += ["--profile", slot]
+ completed = subprocess.run(
+ command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.decode("utf-8", errors="replace").strip()
+ fail(
+ "renewed credentials could not be republished into "
+ f"{destination_root}: {redact(detail, 400)}"
+ )
+ return present, absent
+
+
+def verify_homes(destination_root: Path, slots: list[str]) -> list[dict[str, Any]]:
+ """Require every republished home to be usable, through the expiry owner."""
+
+ expiry = credential_expiry_tool()
+ records = []
+ for slot in slots:
+ record = expiry.inspect_profile(
+ destination_root / slot,
+ harness="pi",
+ margin_seconds=VERIFY_MARGIN_SECONDS,
+ )
+ records.append({"slot": slot, "state": record["state"], "detail": record["detail"]})
+ try:
+ expiry.require_state(record, "usable", f"republished Pi account home {slot}")
+ except expiry.CredentialExpiryError as exc:
+ fail(str(exc))
+ return records
+
+
+def pool_is_intact(source: Path, expected: set[str]) -> str:
+ """Name what is wrong with the pool after a run, or an empty string."""
+
+ try:
+ parsed = json.loads(source.read_text(encoding="utf-8"))
+ except (OSError, ValueError) as exc:
+ return f"the pool no longer reads as JSON ({type(exc).__name__})"
+ if not isinstance(parsed, dict):
+ return "the pool is no longer a profile object"
+ lost = sorted(expected - set(parsed))
+ if lost:
+ return "the pool lost profiles: " + ", ".join(lost)
+ return ""
+
+
+def render(rows: list[tuple[str, ...]], header: tuple[str, ...]) -> str:
+ widths = [
+ max(len(header[index]), *(len(row[index]) for row in rows))
+ if rows
+ else len(header[index])
+ for index in range(len(header))
+ ]
+ lines = [" ".join(header[i].ljust(widths[i]) for i in range(len(header))).rstrip()]
+ for row in rows:
+ lines.append(" ".join(row[i].ljust(widths[i]) for i in range(len(row))).rstrip())
+ return "\n".join(lines)
+
+
+def command_report(args: argparse.Namespace) -> int:
+ source = Path(args.source).expanduser()
+ pool = account_home_tool().read_pool(source)
+ due, held = select_due(
+ pool, horizon_seconds=args.horizon_seconds, now=time.time()
+ )
+ if args.json:
+ print(
+ json.dumps(
+ {"due": due, "held": held, "horizon_seconds": args.horizon_seconds},
+ indent=2,
+ sort_keys=True,
+ )
+ )
+ return 0
+ rows = [
+ (
+ record["slot"],
+ record["expires_at"] or "-",
+ record["account"],
+ record["reason"],
+ )
+ for record in sorted(due + held, key=lambda item: item["slot"])
+ ]
+ print(render(rows, ("PROFILE", "EXPIRES", "ACCOUNT", "STATE")))
+ print(f"due={len(due)} held={len(held)} horizon={args.horizon_seconds}s")
+ return 0
+
+
+def command_run_once(args: argparse.Namespace) -> int:
+ if args.all and args.slot:
+ fail("--all and --slot name different selections; pass one")
+ if not args.all and not args.slot:
+ fail("name at least one --slot, or pass --all")
+ source = Path(args.source).expanduser()
+ destination_root = Path(args.destination_root).expanduser().resolve()
+ backup_root = Path(args.backup_root).expanduser()
+ home_tool = account_home_tool()
+ pool = home_tool.read_pool(source)
+ expected = set(pool)
+ now = time.time()
+ due, held = select_due(
+ pool,
+ horizon_seconds=args.horizon_seconds,
+ now=now,
+ requested=None if args.all else list(args.slot),
+ )
+ unrenewable = [record for record in held if record["reason"].startswith("unrenewable")]
+
+ summary: dict[str, Any] = {
+ "due": [record["slot"] for record in due],
+ "held": [record["slot"] for record in held],
+ "unrenewable": [record["slot"] for record in unrenewable],
+ "renewed": [],
+ "republished": [],
+ "unprojected": [],
+ "verified": [],
+ "backup": "",
+ "pruned": [],
+ }
+
+ if not due:
+ if args.json:
+ print(json.dumps(summary, indent=2, sort_keys=True))
+ else:
+ print(f"nothing due within {args.horizon_seconds}s; {len(held)} profiles held")
+ for record in unrenewable:
+ print(f" {record['slot']}: {record['reason']}", file=sys.stderr)
+ # An unrenewable profile is a real problem, but it is a problem a
+ # renewal run cannot fix: it needs a browser. Say so and exit non-zero
+ # so a scheduled run does not report success over a dying account.
+ return 1 if unrenewable else 0
+
+ backup = backup_pool(source, backup_root, now=now, expected=expected)
+ summary["backup"] = str(backup)
+ slots = [record["slot"] for record in due]
+ records = run_adapter(source=source, slots=slots, timeout_ms=args.timeout_ms)
+
+ damage = pool_is_intact(source, expected)
+ if damage:
+ fail(
+ f"{damage}; the pre-renewal copy is at {backup} and can be restored "
+ "over it once the cause is understood"
+ )
+ # Pruned here, not at the end. Every step below can refuse, and a recurring
+ # refusal used to leave one more full copy of every credential in the fleet
+ # at rest per scheduled run. Once the pool is proved intact the older copies
+ # protect nothing, which is what BACKUP_KEEP's own comment says.
+ summary["pruned"] = [str(path) for path in prune_backups(backup_root, BACKUP_KEEP)]
+
+ renewed = [record["slot"] for record in records if record.get("outcome") == "refreshed"]
+ problems = [record for record in records if record.get("outcome") != "refreshed"]
+ summary["renewed"] = renewed
+ unstable = [
+ record["slot"]
+ for record in records
+ if record.get("outcome") == "refreshed" and record.get("account_stable") is False
+ ]
+
+ if renewed:
+ republished, unprojected = reproject(
+ source=source, destination_root=destination_root, slots=renewed
+ )
+ summary["republished"] = republished
+ summary["unprojected"] = unprojected
+ summary["verified"] = verify_homes(destination_root, republished)
+
+ if args.json:
+ print(json.dumps(summary, indent=2, sort_keys=True))
+ else:
+ rows = [
+ (
+ record["slot"],
+ str(record.get("outcome", "?")),
+ str(record.get("account", "-")),
+ str(record.get("expires_after") or record.get("detail", "")),
+ )
+ for record in sorted(records, key=lambda item: item["slot"])
+ ]
+ print(render(rows, ("PROFILE", "OUTCOME", "ACCOUNT", "EXPIRES / DETAIL")))
+ print(
+ f"renewed={len(renewed)} republished={len(summary['republished'])} "
+ f"held={len(held)} backup={backup}"
+ )
+ for slot in summary["unprojected"]:
+ print(
+ f" {slot}: renewed in the pool but has no account home under "
+ f"{destination_root}",
+ file=sys.stderr,
+ )
+
+ if unstable:
+ fail(
+ "renewal changed the executing account for: "
+ + ", ".join(sorted(unstable))
+ + "; the reviewer roster identifies accounts by that value"
+ )
+ if problems:
+ for record in problems:
+ print(
+ f"REFUSED {record['slot']}: {record.get('detail', record.get('outcome'))}",
+ file=sys.stderr,
+ )
+ stranded = [
+ record["slot"]
+ for record in problems
+ if record.get("outcome") == "rotated-unpersisted"
+ ]
+ if stranded:
+ # Named apart from an ordinary failure because the recovery differs:
+ # the provider has already retired what the host holds, so there is
+ # nothing to retry and no copy that helps.
+ fail(
+ "the provider rotated these profiles and the rotation could not be "
+ "stored, so each now needs an interactive login: "
+ + ", ".join(sorted(stranded))
+ )
+ fail(f"{len(problems)} of {len(records)} due profiles were not renewed")
+ if unrenewable:
+ fail(
+ "these profiles cannot be renewed and need an interactive login: "
+ + ", ".join(record["slot"] for record in unrenewable)
+ )
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="fm-pi-refresh.py", description=__doc__.splitlines()[0]
+ )
+ commands = parser.add_subparsers(dest="command", required=True)
+
+ report = commands.add_parser(
+ "report", help="name the profiles due for renewal without renewing one"
+ )
+ report.add_argument("--source", default=DEFAULT_SOURCE)
+ report.add_argument(
+ "--horizon-seconds", type=float, default=DEFAULT_HORIZON_SECONDS
+ )
+ report.add_argument("--json", action="store_true")
+ report.set_defaults(handler=command_report)
+
+ run = commands.add_parser(
+ "run-once", help="renew due profiles, republish them, and verify the result"
+ )
+ run.add_argument("--source", default=DEFAULT_SOURCE)
+ run.add_argument("--destination-root", default=DEFAULT_DESTINATION_ROOT)
+ run.add_argument("--backup-root", default=DEFAULT_BACKUP_ROOT)
+ run.add_argument(
+ "--horizon-seconds", type=float, default=DEFAULT_HORIZON_SECONDS
+ )
+ run.add_argument("--timeout-ms", type=int, default=DEFAULT_TIMEOUT_MS)
+ run.add_argument("--slot", action="append", default=[])
+ run.add_argument("--all", action="store_true")
+ run.add_argument("--json", action="store_true")
+ run.set_defaults(handler=command_run_once)
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ try:
+ return int(args.handler(args))
+ except RefreshError as exc:
+ print(f"PI REFRESH REFUSED: {exc}", file=sys.stderr)
+ return 1
+ except OSError as exc:
+ # A traceback is not a refusal contract. Report the path and the errno
+ # text, never the value being written.
+ location = getattr(exc, "filename", None) or "a credential path"
+ print(
+ f"PI REFRESH REFUSED: {location}: {exc.strerror or exc}", file=sys.stderr
+ )
+ return 1
+ except Exception as exc: # noqa: BLE001 - the account-home owner raises its own
+ if type(exc).__name__ == "ProjectionError":
+ print(f"PI REFRESH REFUSED: {exc}", file=sys.stderr)
+ return 1
+ raise
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/docs/scripts.md b/docs/scripts.md
index 9b589e67c26..ef0f8c85aba 100644
--- a/docs/scripts.md
+++ b/docs/scripts.md
@@ -74,6 +74,9 @@ The shared no-mistakes gate refusal used by every directly invocable mutating co
| `fm-credential-expiry.py` | Classify one account profile's provider credential by expiry without emitting token material |
| `fm-azure-validation-shard-bridge.py` | Exchange exact behavior/lint requests and independent Azure runner receipts inside one cell |
| `fm-pi-account-home.py` | Project one Pi profile from the pooled `auth.json` into the single-profile account home its consumers read |
+| `fm-pi-refresh.py` | Renew Pi credentials before they expire, republish them into their account homes, and verify the result |
+| `fm-pi-refresh.mjs` | Rotate one Pi credential through Pi's own OAuth refresh and its own credential lock (the actuator `fm-pi-refresh.py` drives) |
+| `fm-lint-node.sh` | Parse every JavaScript tool in `bin/`, the lane ShellCheck's shell-only file set cannot cover |
| `fm-nm-step-liveness.sh` | Read a no-mistakes step's processes as alive, dead, or graded unknown |
| `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification |
| `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate |
diff --git a/tests/behavior-test-durations.tsv b/tests/behavior-test-durations.tsv
index b061083522c..87e2eeb8a1e 100644
--- a/tests/behavior-test-durations.tsv
+++ b/tests/behavior-test-durations.tsv
@@ -121,3 +121,4 @@
92 tests/operating-fundamentals.test.sh
100 tests/runner-entry-probe.test.sh
1000 tests/test-suite-seal.test.sh
+4000 tests/fm-pi-refresh.test.sh
diff --git a/tests/fm-pi-refresh.test.sh b/tests/fm-pi-refresh.test.sh
new file mode 100755
index 00000000000..c3fe10b6991
--- /dev/null
+++ b/tests/fm-pi-refresh.test.sh
@@ -0,0 +1,512 @@
+#!/usr/bin/env bash
+# shellcheck source=tests/test-entry.sh
+. "$(dirname "$0")/test-entry.sh"
+# Behavior: renewing a Pi credential before it expires, republishing it into the
+# account home its consumers read, and refusing every way that can go wrong,
+# without writing a token into the transcript or reaching the network on any
+# path that must not.
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
+# shellcheck source=tests/lib.sh
+. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+
+TOOL="$ROOT/bin/fm-pi-refresh.py"
+ADAPTER="$ROOT/bin/fm-pi-refresh.mjs"
+# A marker standing in for token material, so a leak is caught by grep rather
+# than by reading the output and hoping.
+MARKER=fmtestpirefreshmarker
+
+file_mode() {
+ python3 -c 'import os,sys; print("%o" % (os.stat(sys.argv[1]).st_mode & 0o777))' "$1"
+}
+
+# Build a pool whose slots differ in exactly the axis under test: how long the
+# access token has left, and whether it can be renewed at all.
+make_pool() {
+ python3 - "$1" "$MARKER" <<'PY'
+import json
+import sys
+import time
+
+path, marker = sys.argv[1], sys.argv[2]
+now = time.time() * 1000
+day = 86400 * 1000
+
+
+def entry(account, days, *, refresh=True, kind="oauth"):
+ value = {
+ "type": kind,
+ "access": f"{marker}.access.{account}",
+ "refresh": f"{marker}.refresh.{account}" if refresh else "",
+ "accountId": account,
+ "expires": now + days * day,
+ }
+ return value
+
+
+json.dump(
+ {
+ "openai-codex": entry("acct-one", 2),
+ "openai-codex-2": entry("acct-two", 9),
+ "openai-codex-3": entry("acct-three", 1, refresh=False),
+ },
+ open(path, "w"),
+ indent=2,
+)
+PY
+}
+
+# A Pi install that is only a shape: enough for the resolver to accept it, with
+# no runtime behind it. The CLI-level contracts must hold without a real Pi.
+make_fake_pi() {
+ local root=$1
+ mkdir -p "$root/pkg/dist" "$root/bin"
+ # The real package name: the resolver checks it, because any directory with a
+ # package.json used to pass and pushed the refusal one layer down into the
+ # module import, where a wrong Pi reads as a missing file.
+ printf '{"name":"@earendil-works/pi-coding-agent","version":"0.0.0","engines":{"node":">=22.19.0"}}\n' \
+ >"$root/pkg/package.json"
+ printf '#!/usr/bin/env node\n' >"$root/pkg/dist/cli.js"
+ # Executable on purpose: a Pi entrypoint without the bit is not resolvable,
+ # and the refusal under test would be that one rather than the intended one.
+ chmod +x "$root/pkg/dist/cli.js"
+ ln -sf "$root/pkg/dist/cli.js" "$root/bin/pi"
+}
+
+# A Node that answers the floor probe honestly and records that it was asked to
+# run anything else. `--version` has to work, or the refusal under test would be
+# the version refusal rather than the one being exercised.
+make_fake_node() {
+ local path=$1 version=$2 marker=$3 code=$4
+ cat >"$path" <'$marker'
+exit $code
+SH
+ chmod +x "$path"
+}
+
+selection_contract() {
+ local work pool out code
+ work=$(fm_test_tmproot fm-pi-refresh-select)
+ pool=$work/auth.json
+ make_pool "$pool"
+
+ out=$(python3 "$TOOL" report --source "$pool" --json 2>&1) \
+ || fail "report refused a readable pool"
+ assert_not_contains "$out" "$MARKER" "report leaked token material"
+
+ python3 - "$out" <<'PY' || fail "report classified the pool wrongly"
+import json
+import sys
+
+value = json.loads(sys.argv[1])
+due = {record["slot"] for record in value["due"]}
+held = {record["slot"]: record["reason"] for record in value["held"]}
+assert due == {"openai-codex"}, due
+assert "openai-codex-2" in held and "horizon" in held["openai-codex-2"], held
+assert held.get("openai-codex-3", "").startswith("unrenewable"), held
+# The account is reported, and only as a digest.
+assert all(record["account"] != "acct-one" for record in value["due"]), value
+PY
+
+ # A slot outside the horizon becomes due when the horizon widens. Without
+ # this the horizon could be ignored entirely and the split above would still
+ # look right.
+ out=$(python3 "$TOOL" report --source "$pool" --horizon-seconds 864000 --json 2>&1) \
+ || fail "report refused a widened horizon"
+ python3 - "$out" <<'PY' || fail "the renewal horizon does not select"
+import json
+import sys
+
+value = json.loads(sys.argv[1])
+assert {record["slot"] for record in value["due"]} == {"openai-codex", "openai-codex-2"}, value
+PY
+
+ # Reporting is a local read. It must not be able to reach a token endpoint,
+ # so a Node that records being run proves the adapter was never invoked.
+ local fakebin marker
+ fakebin=$(fm_fakebin "$work")
+ marker=$work/node-was-run
+ make_fake_node "$fakebin/node" v99.0.0 "$marker" 1
+ PATH="$fakebin:$PATH" FM_PI_NODE_BIN="$fakebin/node" \
+ python3 "$TOOL" report --source "$pool" >/dev/null 2>&1 \
+ || fail "report refused with a stubbed Node on PATH"
+ assert_absent "$marker" "report invoked the renewal adapter"
+
+ code=0
+ out=$(python3 "$TOOL" run-once --source "$pool" 2>&1) || code=$?
+ # 1, not 2: the sibling credential tools refuse through their own error class
+ # rather than through argparse, so an operator gets one refusal contract.
+ expect_code 1 "$code" "run-once accepted no slot selection"
+ assert_contains "$out" "--slot" "the refusal did not name how to select a profile"
+
+ pass "report names the due, the held, and the unrenewable without a token or a network call"
+}
+
+refusal_contract() {
+ local work pool out code fakebin marker piroot
+ work=$(fm_test_tmproot fm-pi-refresh-refuse)
+ pool=$work/auth.json
+ make_pool "$pool"
+ piroot=$work/pi
+ make_fake_pi "$piroot"
+ fakebin=$(fm_fakebin "$work")
+ marker=$work/adapter-was-run
+
+ # An adapter that fails must not be reported as a renewal, and the pre-rotation
+ # copy must already exist when it fails: the copy is the only thing standing
+ # between an interrupted in-place write and every slot in the file.
+ make_fake_node "$fakebin/node" v99.0.0 "$marker" 3
+ code=0
+ out=$(FM_PI_BIN="$piroot/bin/pi" FM_PI_NODE_BIN="$fakebin/node" \
+ python3 "$TOOL" run-once --source "$pool" --slot openai-codex \
+ --backup-root "$work/backups" --destination-root "$work/homes" 2>&1) || code=$?
+ expect_code 1 "$code" "a failed adapter was reported as a renewal"
+ assert_present "$marker" "run-once never invoked the adapter"
+ assert_not_contains "$out" "$MARKER" "the refusal leaked token material"
+ [ -n "$(find "$work/backups" -name 'auth.json.*' -type f 2>/dev/null)" ] \
+ || fail "run-once rotated without first copying the pool"
+ expect_code 600 \
+ "$(file_mode "$(find "$work/backups" -name 'auth.json.*' -type f | head -1)")" \
+ "the pre-rotation copy is not owner-only"
+
+ # A Node below the floor the install declares does not merely warn: it dies
+ # inside undici with an unrelated message. Refusing by version keeps that from
+ # being reported as a provider failure.
+ make_fake_node "$fakebin/oldnode" v20.20.2 "$work/oldnode-ran" 0
+ code=0
+ out=$(FM_PI_BIN="$piroot/bin/pi" FM_PI_NODE_BIN="$fakebin/oldnode" \
+ python3 "$TOOL" run-once --source "$pool" --slot openai-codex \
+ --backup-root "$work/backups" --destination-root "$work/homes" 2>&1) || code=$?
+ expect_code 1 "$code" "a Node below the declared floor was accepted"
+ assert_contains "$out" "22" "the refusal did not name the floor it applied"
+ assert_absent "$work/oldnode-ran" "the refused Node was run anyway"
+
+ # A run whose only selected profile cannot be renewed at all exits non-zero:
+ # a scheduled run must not report success over an account that needs a login.
+ code=0
+ out=$(FM_PI_BIN="$piroot/bin/pi" FM_PI_NODE_BIN="$fakebin/node" \
+ python3 "$TOOL" run-once --source "$pool" --slot openai-codex-3 \
+ --backup-root "$work/backups" --destination-root "$work/homes" 2>&1) || code=$?
+ expect_code 1 "$code" "an unrenewable profile was reported as a clean run"
+ assert_contains "$out" "unrenewable" "the report did not name why it cannot be renewed"
+ assert_absent "$work/adapter-was-run-unrenewable" "an unrenewable profile reached the adapter"
+
+ code=0
+ out=$(python3 "$TOOL" run-once --source "$pool" --slot no-such-slot \
+ --backup-root "$work/backups" --destination-root "$work/homes" 2>&1) || code=$?
+ expect_code 1 "$code" "a slot absent from the pool was accepted"
+ assert_contains "$out" "no-such-slot" "the refusal did not name the missing slot"
+
+ pass "run-once copies before it rotates, and refuses a failed adapter, an underpowered Node, an unrenewable profile, and an absent slot"
+}
+
+republish_contract() {
+ local work pool out
+ work=$(fm_test_tmproot fm-pi-refresh-republish)
+ pool=$work/auth.json
+ make_pool "$pool"
+ mkdir -p "$work/homes/openai-codex"
+
+ # The republish and verify halves drive the real projection tool and the real
+ # expiry owner, not a restatement of either. Only a home that already exists
+ # is republished, because the reviewer roster names homes by path and one
+ # appearing on its own is a reviewer nobody added.
+ python3 - "$TOOL" "$pool" "$work/homes" <<'PY' \
+ || fail "the real projection and expiry owners rejected a republished home"
+import importlib.util
+import pathlib
+import sys
+
+spec = importlib.util.spec_from_file_location("refresh", sys.argv[1])
+refresh = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(refresh)
+
+pool = pathlib.Path(sys.argv[2])
+homes = pathlib.Path(sys.argv[3])
+present, absent = refresh.reproject(
+ source=pool, destination_root=homes, slots=["openai-codex", "openai-codex-2"]
+)
+assert present == ["openai-codex"], present
+assert absent == ["openai-codex-2"], absent
+assert (homes / "openai-codex" / "auth.json").is_file()
+assert not (homes / "openai-codex-2").exists(), "an unrequested account home was created"
+
+verified = refresh.verify_homes(homes, present)
+assert verified[0]["state"] == "usable", verified
+PY
+
+ # Verification is a gate, not a report: a republished home whose credential
+ # dies inside the margin must refuse rather than be counted as renewed.
+ python3 - "$TOOL" "$work/homes" <<'PY' \
+ || fail "verification admitted a home that expires inside its margin"
+import importlib.util
+import json
+import pathlib
+import sys
+
+spec = importlib.util.spec_from_file_location("refresh", sys.argv[1])
+refresh = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(refresh)
+
+home = pathlib.Path(sys.argv[2]) / "openai-codex"
+credential = home / "auth.json"
+value = json.loads(credential.read_text())
+value["openai-codex"]["expires"] = 1000.0
+credential.write_text(json.dumps(value))
+try:
+ refresh.verify_homes(pathlib.Path(sys.argv[2]), ["openai-codex"])
+except refresh.RefreshError as error:
+ assert "openai-codex" in str(error), error
+else:
+ raise AssertionError("an expired republished home was accepted")
+PY
+
+ out=$(cat "$work/homes/openai-codex/auth.json")
+ assert_contains "$out" "openai-codex" "the republished home lost its consumer key"
+
+ pass "renewed profiles republish only into homes that exist, and a home that expires inside its margin refuses"
+}
+
+pool_integrity_contract() {
+ local work pool
+ work=$(fm_test_tmproot fm-pi-refresh-integrity)
+ pool=$work/auth.json
+ make_pool "$pool"
+
+ python3 - "$TOOL" "$pool" <<'PY' || fail "pool damage was not detected"
+import importlib.util
+import pathlib
+import sys
+
+spec = importlib.util.spec_from_file_location("refresh", sys.argv[1])
+refresh = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(refresh)
+
+pool = pathlib.Path(sys.argv[2])
+expected = {"openai-codex", "openai-codex-2", "openai-codex-3"}
+assert refresh.pool_is_intact(pool, expected) == "", "an intact pool was called damaged"
+
+pool.write_text('{"openai-codex": {}}')
+assert "lost profiles" in refresh.pool_is_intact(pool, expected)
+
+pool.write_text("not json at all")
+assert "no longer reads as JSON" in refresh.pool_is_intact(pool, expected)
+PY
+
+ # Backups protect exactly one failure and are credential material at rest
+ # otherwise, so the run prunes down to the retained count.
+ python3 - "$TOOL" "$work" <<'PY' || fail "backups were not bounded"
+import importlib.util
+import pathlib
+import stat
+import sys
+
+spec = importlib.util.spec_from_file_location("refresh", sys.argv[1])
+refresh = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(refresh)
+
+work = pathlib.Path(sys.argv[2])
+source = work / "seed.json"
+source.write_text('{"openai-codex": {}}')
+root = work / "backups"
+expected = {"openai-codex"}
+made = [
+ refresh.backup_pool(source, root, now=1_700_000_000 + index * 60, expected=expected)
+ for index in range(refresh.BACKUP_KEEP + 2)
+]
+
+# The copy is proved to be a copy before it is offered as one: an unlocked read
+# of a pool being written can tear, and a torn copy is worse than none because
+# it is only ever reached for by an operator whose pool is already damaged.
+torn = work / "torn.json"
+torn.write_text('{"openai-codex": {}, "openai-codex-2": {}}')
+try:
+ refresh.backup_pool(torn, root, now=1_700_000_999, expected={"openai-codex", "gone"})
+except refresh.RefreshError as error:
+ assert "did not come out intact" in str(error), error
+else:
+ raise AssertionError("a copy missing a profile was accepted as a backup")
+assert all(path.is_file() for path in made), made
+assert stat.S_IMODE(root.stat().st_mode) == 0o700, oct(root.stat().st_mode)
+refresh.prune_backups(root, refresh.BACKUP_KEEP)
+kept = sorted(path.name for path in root.glob("auth.json.*"))
+assert len(kept) == refresh.BACKUP_KEEP, kept
+assert kept == sorted(path.name for path in made)[-refresh.BACKUP_KEEP :], kept
+PY
+
+ pass "a damaged pool is named rather than reported as renewed, and the pre-rotation copies stay bounded and owner-only"
+}
+
+# Every outcome the adapter can report, driven through the REAL refreshSlots
+# with a store that keeps `modify`'s contract. This unit needs Node and nothing
+# else, because the classification it pins is the adapter's own logic; the
+# integration with Pi's credential store is the next unit's job. Splitting them
+# is the point: the previous shape put the redaction and the rotation decision
+# behind a `pi is installed` check, so both were compiled out on the gate.
+adapter_outcome_contract() {
+ local work
+ work=$(fm_test_tmproot fm-pi-refresh-outcomes)
+ command -v node >/dev/null 2>&1 || fail "node is required to run the renewal adapter"
+
+ node --input-type=module -e "
+const adapter = await import('file://$ADAPTER');
+
+// Faithful to the one behavior the classification depends on: a callback that
+// returns undefined leaves the stored credential alone and modify hands that
+// same stored credential back, which is otherwise indistinguishable from a
+// successful rotation that returned an identical credential.
+const makeStore = (data) => ({
+ data,
+ async read(slot) { return this.data[slot]; },
+ async modify(slot, fn) {
+ const next = await fn(this.data[slot]);
+ if (next !== undefined) this.data[slot] = next;
+ return this.data[slot];
+ },
+});
+
+const codex = (id) => ({ type: 'oauth', access: 'a.' + id, refresh: 'r.' + id, expires: 1, accountId: id });
+const run = (data, oauth, slots) => adapter.refreshSlots({
+ storeFactory: () => makeStore(data), oauth, poolPath: '$work/unused.json', slots, timeoutMs: 1000,
+});
+const only = async (data, oauth, slot) => (await run(data, oauth, [slot]))[0];
+
+const rotating = { refresh: async (c) => ({ ...c, access: 'NEW.' + c.accountId, refresh: 'NEWR.' + c.accountId }) };
+const identical = { refresh: async (c) => ({ ...c }) };
+const drifting = { refresh: async (c) => ({ ...c, access: 'z', accountId: 'someone-else' }) };
+const never = { refresh: async () => { throw new Error('boom'); } };
+
+let r = await only({ s: codex('one') }, rotating, 's');
+if (r.outcome !== 'refreshed') throw new Error('a rotation was not reported: ' + r.outcome);
+if (r.access_rotated !== true || r.account_stable !== true) throw new Error('rotation flags wrong: ' + JSON.stringify(r));
+
+// The case a truthy return cannot distinguish on its own.
+r = await only({ s: codex('one') }, identical, 's');
+if (r.outcome !== 'unchanged') throw new Error('an unrotated credential was counted as renewed: ' + r.outcome);
+
+r = await only({}, rotating, 's');
+if (r.outcome !== 'absent') throw new Error('an absent slot was not reported absent: ' + r.outcome);
+
+r = await only({ s: { type: 'api_key', key: 'k' } }, rotating, 's');
+if (r.outcome !== 'not-oauth') throw new Error('a non-oauth credential was not reported: ' + r.outcome);
+
+// An Anthropic Pi credential carries no accountId. Handing it to the Codex
+// flow would POST its refresh token to the wrong provider's token endpoint.
+r = await only({ s: { type: 'oauth', access: 'a', refresh: 'r', expires: 1 } }, rotating, 's');
+if (r.outcome !== 'unsupported-provider') throw new Error('a non-Codex credential reached the Codex flow: ' + r.outcome);
+
+r = await only({ s: codex('one') }, never, 's');
+if (r.outcome !== 'failed') throw new Error('a throwing refresh was not reported failed: ' + r.outcome);
+
+r = await only({ s: codex('one') }, drifting, 's');
+if (r.account_stable !== false) throw new Error('an account change was not reported');
+
+// A provider error carrying token-shaped text must come back redacted, and the
+// redaction must be applied before the truncation or a short run survives.
+const leaky = { refresh: async () => { throw new Error('missing fields: {\"access_token\":\"$MARKER-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}'); } };
+r = await only({ s: codex('one') }, leaky, 's');
+if (r.detail.includes('$MARKER')) throw new Error('a provider error leaked token material');
+if (!r.detail.includes('[redacted]')) throw new Error('a token-shaped run was not redacted');
+
+// The provider rotated and the store could not keep it. That is not an
+// ordinary failure: the host now holds a token the provider has retired, and
+// no pre-renewal copy helps, so it must be reported as its own outcome.
+const refusingStore = {
+ async read(slot) { return codex('one'); },
+ async modify(slot, fn) { await fn(codex('one')); throw new Error('EACCES: permission denied'); },
+};
+r = (await adapter.refreshSlots({
+ storeFactory: () => refusingStore, oauth: rotating, poolPath: 'x', slots: ['s'], timeoutMs: 1000,
+}))[0];
+if (r.outcome !== 'rotated-unpersisted') throw new Error('a lost rotation was reported as an ordinary failure: ' + r.outcome);
+
+// A store that refuses BEFORE the provider is reached is an ordinary failure.
+const deadStore = { async read() { return codex('one'); }, async modify() { throw new Error('ELOCKED'); } };
+r = (await adapter.refreshSlots({
+ storeFactory: () => deadStore, oauth: rotating, poolPath: 'x', slots: ['s'], timeoutMs: 1000,
+}))[0];
+if (r.outcome !== 'failed') throw new Error('a failure before the provider was miscalled a lost rotation: ' + r.outcome);
+
+const all = JSON.stringify(await run({ s: codex('one') }, rotating, ['s']));
+if (all.includes('a.one') || all.includes('r.one') || all.includes('\"one\"')) {
+ throw new Error('the adapter emitted raw credential or account material');
+}
+" || fail "the adapter reported the wrong outcome for a case it must distinguish"
+
+ pass "the adapter distinguishes refreshed, unchanged, absent, not-oauth, unsupported-provider, failed and a rotation it could not store, and redacts before it truncates"
+}
+
+# The integration the unit above deliberately does not cover: Pi's own
+# credential store, its lock, and what actually lands on disk.
+adapter_store_contract() {
+ local work pool package
+ work=$(fm_test_tmproot fm-pi-refresh-adapter)
+ pool=$work/auth.json
+ make_pool "$pool"
+
+ package=${FM_PI_PACKAGE_DIR:-}
+ if [ -z "$package" ]; then
+ local located
+ located=$(command -v pi 2>/dev/null || true)
+ if [ -n "$located" ]; then
+ package=$(cd "$(dirname "$(readlink -f "$located")")/.." && pwd -P)
+ fi
+ fi
+ if [ -z "$package" ] || [ ! -f "$package/dist/core/auth-storage.js" ]; then
+ # FM_PI_REQUIRED is set wherever Pi is supposed to be installed, so an
+ # install that silently failed goes red instead of skipping. A skip that
+ # can never fail is how this contract came to be absent from CI.
+ [ "${FM_PI_REQUIRED:-0}" != 1 ] \
+ || fail "FM_PI_REQUIRED is set but no Pi credential store was found to contract against"
+ echo "skip: pi is not installed for the adapter store contract"
+ return 0
+ fi
+ command -v node >/dev/null 2>&1 || fail "node is required to run the renewal adapter"
+
+ node --input-type=module -e "
+import { readFileSync } from 'node:fs';
+const adapter = await import('file://$ADAPTER');
+const { AuthStorage } = await import('file://$package/dist/core/auth-storage.js');
+
+const rotating = {
+ refresh: async (current) => ({
+ type: 'oauth', access: 'rotated.' + current.accountId,
+ refresh: 'rotated-refresh.' + current.accountId,
+ expires: Date.now() + 864e5, accountId: current.accountId,
+ }),
+};
+const records = await adapter.refreshSlots({
+ storeFactory: (path) => AuthStorage.create(path),
+ oauth: rotating, poolPath: '$pool', slots: ['openai-codex', 'no-such-slot'], timeoutMs: 5000,
+});
+const rotated = records.find((record) => record.slot === 'openai-codex');
+if (rotated.outcome !== 'refreshed') throw new Error('Pi store did not persist a rotation: ' + rotated.outcome);
+if (records.find((record) => record.slot === 'no-such-slot').outcome !== 'absent') {
+ throw new Error('an absent slot was not reported absent through the real store');
+}
+if (JSON.stringify(records).includes('$MARKER')) throw new Error('the adapter emitted token material');
+
+const onDisk = JSON.parse(readFileSync('$pool', 'utf8'));
+if (onDisk['openai-codex'].access !== 'rotated.acct-one') throw new Error('the rotation did not land in the pool');
+if (Object.keys(onDisk).length !== 3) throw new Error('the pool lost a slot');
+if (onDisk['openai-codex-3'].accountId !== 'acct-three') throw new Error('an untouched slot changed');
+
+// Also prove the module paths the production entrypoint resolves are the ones
+// this Pi actually ships, so a Pi upgrade that moves them fails here.
+const loaded = await adapter.loadPiModules('$package');
+if (typeof loaded.oauth.refresh !== 'function') throw new Error('the resolved Pi OAuth flow has no refresh');
+" || fail "the adapter contract against Pi's real credential store failed"
+
+ pass "the adapter rotates through Pi's real credential store and lands exactly one slot on disk"
+}
+
+selection_contract
+refusal_contract
+republish_contract
+pool_integrity_contract
+adapter_outcome_contract
+adapter_store_contract
diff --git a/tests/test-capabilities.tsv b/tests/test-capabilities.tsv
index cbc86323aae..8be3874893e 100644
--- a/tests/test-capabilities.tsv
+++ b/tests/test-capabilities.tsv
@@ -67,6 +67,7 @@ fm-no-mistakes-reattach.test.sh hermetic
fm-pi-account-home.test.sh hermetic
fm-pi-primary-live-e2e.test.sh hermetic
fm-pi-primary-types.test.sh hermetic
+fm-pi-refresh.test.sh hermetic
fm-pi-watch-extension.test.sh hermetic
fm-pr-merge.test.sh hermetic
fm-prompt-exec.test.sh hermetic