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