Files
stack/docs/plans/reviews/2026-09-10_pi0851-refresh-investigation.md

14 KiB

PI-REFRESH-ROCKO-1 — pi 0.85.1 OAuth refresh investigation (read-only)

Request: docs/plans/reviews/2026-09-10_pi0851-refresh-investigation-request.md. Scope: bounded read-only investigation of @earendil-works/[email protected] for registry gate 7 (does 0.85.1 support noninteractive host-side OAuth token refresh?). All work was done against a package fetched into a private temp directory (npm pack/extraction, not this repository); no credentials were read, no live OAuth flow or model call was made, and no files beyond this one were written to the repository. Currently pinned version in this repo is 0.84.4 (package.json:8); 0.85.1 is the candidate under evaluation.

1. Package identity and integrity evidence

  • Registry metadata (npm view @earendil-works/[email protected] dist --json):
    • shasum: 4cd00f653c3dabeb193b46f511044e7fbfe0f947
    • integrity: sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==
    • fileCount: 1056, unpackedSize: 21935887
    • tarball: https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.85.1.tgz
    • Registry-attached npm provenance attestation present (predicateType: https://slsa.dev/provenance/v1), plus two detached signatures under dist.signatures (keyid SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U).
    • dist-tags.latest: 0.85.1.
  • Local fetch (npm pack @earendil-works/[email protected] --json) reported shasum: 4cd00f653c3dabeb193b46f511044e7fbfe0f947, integrity: sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ== — matches the registry's dist record exactly.
  • Local re-hash of the downloaded tarball: sha1sum = 4cd00f653c3dabeb193b46f511044e7fbfe0f947 (matches npm's shasum field bit-for-bit), sha256sum = 1f498729649bdce647d1160993b4d92bf3c614cc819213bee2f91dd34f2a7af4 (recorded as an independent local fingerprint; npm does not publish a sha256 for the tarball itself, only the sha512 integrity digest above, which the local fetch reproduced).
  • Extracted package.json: name: "@earendil-works/pi-coding-agent", version: "0.85.1", bin.pi: "dist/bundle/cli.js". Package identity confirmed.
  • Conclusion: the tarball inspected is exactly the registry-published 0.85.1 artifact; no substitution or corruption evidence.

2. Auth model: where credentials live, schema, and refresh entry points

2.1 auth.json location and schema

  • Default path: ~/.pi/agent/auth.json (per docs/providers.md:26,111, and the code default join(getAgentDir(), "auth.json") in dist/core/auth-storage.js:19 (FileAuthStorageBackend constructor) and dist/core/auth-storage.js:281 (AuthStorage.create)).
  • File permissions: written with mode 0o600 (dist/core/auth-storage.js:15, AUTH_FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 }), parent directory created with 0o700 (dist/core/auth-storage.js:23-26).
  • Credential schema, validated on every load (dist/core/auth-storage.js:184-200, inside FileAuthStorageBackend's parse/validate path — the same shape is re-validated by the writable AuthStorage class):
    • API key: { type: "api_key", key?: string, env?: Record<string,string> }.
    • OAuth: { type: "oauth", access: string, refresh: string, expires: number } (dist/core/auth-storage.js:195-199), where expires is a Unix millisecond timestamp. Any entry not matching one of these two shapes throws Invalid auth.json credential for provider "<id>".
  • Two storage implementations exist:
    • FileAuthStorageBackend/ReadOnlyAuthStorage (dist/core/auth-storage.js:17, :157) — read-focused; ReadOnlyAuthStorage.modify() (dist/core/auth-storage.js:227) unconditionally throws "Read-only credential storage cannot modify auth.json".
    • AuthStorage (dist/core/auth-storage.js:263) — the writable, file-lock-backed store actually used at runtime. Its modify(provider, fn, options) (dist/core/auth-storage.js:378-390) takes an exclusive file lock (proper-lockfile, dist/core/auth-storage.js:33-114), re-reads current disk content, calls fn(currentCredential), and if fn returns a new credential object, merges it into the parsed JSON and rewrites the whole file in place (JSON.stringify(merged, null, 2), same 0o600 write options). This is the in-place rewrite path referenced in §2.3.

2.2 Noninteractive refresh entry points (CLI)

Three CLI subcommands are defined in dist/cli/auth-command.js and answer the brief's question (a)/(b) directly — all are plain argv-parsed commands with no TTY prompt in their code path:

  • pi auth check --provider <provider> [--model <model>] [--json] [--credentials] [--no-refresh] (dist/cli/auth-command.js:5,23). Per its own help text (dist/cli/auth-command.js:25): "Checks refresh expired OAuth credentials by default; --no-refresh prevents this." Implementation: checkProviderAuth(args, modelRuntime, options = { refresh: false }) (dist/cli/auth-check.js:5) — note the function default is refresh: false, but the CLI wiring passes refresh: !noRefresh (i.e. refresh is the default CLI behavior; --no-refresh is what sets it to false, matching the printed help text at auth-command.js:25). When options.refresh is true, line dist/cli/auth-check.js:27 calls modelRuntime.getAuth(provider), which is the code path that performs the actual token refresh (see §2.3).
  • pi auth print-api-key --provider <provider> [--model <model>] (dist/cli/auth-command.js:6).
  • pi auth print-bearer-token --provider <provider> [--model <model>] [--min-expiry <duration>] (dist/cli/auth-command.js:7), default minimum validity DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS = 30 * 60_000 (30 minutes; bundled at dist/bundle/chunks/chunk-JVUZSMYM.js, resolveCredentialForPrint). --min-expiry accepts durations like 30m/1h (dist/cli/auth-command.js:47-53).

These two print-* commands are documented in CHANGELOG.md:487,497 (added in 0.83.0, PR #7168): "Credential export for external clients — pi auth print-api-key and pi auth print-bearer-token export configured credentials with automatic OAuth refresh and minimum-validity enforcement." pi auth check was added earlier still, in 0.84.1 (CHANGELOG.md:261,268, issue #7152). Both predate 0.84.4, the version currently pinned in this repo, so this capability is not new to 0.85.1 — see §4.

2.3 What actually performs the refresh, and how it writes back

The refresh call is lazy/on-demand, triggered by any credential resolution (getAuth) once a stored OAuth credential is within its validity window of expiring — this is the mechanism both pi auth check --provider <p> (no --no-refresh) and pi auth print-api-key/print-bearer-token invoke, and it is also what an ordinary pi model call does transparently before making a request. Two equivalent implementations of this "refresh-if-expiring" guard are bundled (dist/bundle/chunks/chunk-IDDQWTHI.js, function resolveStoredOAuth, and a second call site in the same chunk used by ModelRuntime.getAuth, dist/core/model-runtime.js:339):

DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 300 * 1000        // 5 minutes
DEFAULT_OAUTH_REFRESH_TIMEOUT_MS  = 15 * 1000         // 15 seconds

expiresSoon(credential) = Date.now() + minimumValidityMs >= credential.expires

if (expiresSoon(stored)) {
  post = await credentials.modify(providerId, async (current) => {
    if (current?.type === "oauth" && expiresSoon(current)) {
      const refreshSignal = AbortSignal.any([signal, AbortSignal.timeout(15000)]);
      return await oauth.refresh(current, refreshSignal);   // network call, no browser
    }
  });
}

(dist/bundle/chunks/chunk-IDDQWTHI.js, matched around the resolveStoredOAuth function body; the file is a minified single-line bundle so no meaningful line number applies beyond line 1 — the function and constant names above are verbatim from the source and are unique, greppable anchors.)

credentials.modify(...) here resolves to AuthStorage.modify (dist/core/auth-storage.js:378-390): it re-reads auth.json under an exclusive lock, calls the refresh callback, and — if it returns a value — rewrites auth.json in place, replacing only that provider's entry with the new { type: "oauth", access, refresh, expires } object, preserving all other providers' entries. It does not append a separate file or emit material anywhere else; the only output artifact is the updated auth.json.

oauth.refresh is a per-provider object with a refresh(credential, signal) method. For Anthropic specifically (dist/bundle/chunks/anthropic.js, function refreshAnthropicToken, exposed as anthropicOAuth.refresh = (credential, signal) => refreshAnthropicToken(credential.refresh, signal)):

TOKEN_URL = "https://platform.claude.com/v1/oauth/token"

async function refreshAnthropicToken(refreshToken, signal) {
  const body = await postJson(TOKEN_URL, {
    grant_type: "refresh_token",
    client_id: CLIENT_ID,          // fixed public OAuth client id, base64-decoded in source
    refresh_token: refreshToken,
  }, signal);
  const data = JSON.parse(body);
  return {
    type: "oauth",
    refresh: data.refresh_token,
    access: data.access_token,
    expires: Date.now() + data.expires_in * 1000 - 300 * 1000,  // 5-minute safety margin baked in
  };
}

This is a single unauthenticated-by-browser HTTPS POST using only the already-stored refresh token — no interactive browser/PKCE flow, no loopback callback server, no manual code paste. The browser/PKCE dance (loginAnthropic, same file) is a separate function used only for the initial /login; refresh does not invoke it.

3. Direct answers to the brief's questions

  • (a) Does 0.85.1 document or expose a noninteractive token refresh usable host-side without a browser flow? Yes. pi auth check --provider <provider> (default behavior, no --no-refresh) and pi auth print-api-key/pi auth print-bearer-token --provider <provider> all trigger the same lazy refresh path described in §2.3 when the stored OAuth credential is within 5 minutes (or the caller's --min-expiry, for print-bearer-token) of expiry. The refresh itself is one HTTPS POST to the provider's token endpoint with the stored refresh token; no browser or TTY interaction occurs in this path.
  • (b) What exact entry point drives it? CLI: pi auth check --provider <provider> (refresh is the default; --no-refresh opts out) or pi auth print-api-key --provider <provider> / pi auth print-bearer-token --provider <provider> [--min-expiry <duration>] (dist/cli/auth-command.js, dist/cli/auth-check.js). Underlying module entry point: ModelRuntime.getAuth() (dist/core/model-runtime.js:339) → bundled resolveStoredOAuth/credential resolution in dist/bundle/chunks/chunk-IDDQWTHI.js → provider-specific oauth.refresh() (e.g. anthropicOAuth.refresh in dist/bundle/chunks/anthropic.js). There is no separate pi auth refresh subcommand; refresh is a side effect of the read/check/print commands above, or of ordinary model use.
  • (c) Does it rewrite auth.json in place or emit new material? Rewrites in place. AuthStorage.modify (dist/core/auth-storage.js:378-390) merges the refreshed {access, refresh, expires} object into the existing parsed auth.json and writes the whole file back to the same path with 0o600 permissions under an exclusive lock. No new file or side channel is used.
  • (d) Any expiry/staleness metadata it tracks? Yes: the stored credential itself carries expires (Unix ms). Two related constants govern refresh timing: DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 300_000 (refresh triggers once a credential is within 5 minutes of its stored expiry) and DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 15_000 (network timeout for the refresh call itself). Additionally, both the initial token mint (exchangeAuthorizationCode) and every refresh (refreshAnthropicToken) compute expires as Date.now() + expires_in*1000 - 300_000, i.e. the provider's own expires_in is shortened by a 5-minute safety margin before being stored, so the two 5-minute buffers are independent layers (one baked into the stored value, one applied again at read time).

4. 0.84.4 → 0.85.x auth-relevant changes

Reviewed CHANGELOG.md sections for [0.85.1], [0.85.0], and [0.84.4] (lines 3-116 of the extracted CHANGELOG.md) for anything touching auth/oauth/refresh/token/credential: none of the entries in that range mention authentication, OAuth, or credential handling — the changes in that window are model-catalog/provider-setting and reasoning-token-cap fixes unrelated to auth. The pi auth check (0.84.1, CHANGELOG.md:268, #7152) and pi auth print-api-key/print-bearer-token (0.83.0, CHANGELOG.md:497, #7168) commands both predate 0.84.4 and are unchanged in 0.85.1 as far as the changelog records. The most recent auth-adjacent history visible anywhere in the full changelog is older: auth.json file-permission/ACL hardening (CHANGELOG.md:176), a requirement that extension OAuth refreshToken callbacks honor an abort signal (CHANGELOG.md:304), a fix for stalled OAuth refreshes leaking the credential-store lock (CHANGELOG.md:409, #7508), and a fix for concurrent-process auth.json staleness (CHANGELOG.md:431, #7319) — all several versions prior to 0.84.4 and included for context only, not as 0.84.4→0.85.1 deltas.

Conclusion for gate 7: 0.85.1 supports a documented, noninteractive, host-side OAuth refresh (pi auth check / pi auth print-api-key / pi auth print-bearer-token, all requiring only network access and the existing refresh token in auth.json, no browser). This is pre-existing behavior carried unchanged from 0.83.0/0.84.1 through 0.85.1, not a new capability of 0.85.1 itself, and is a code-level finding suitable for reconciliation with prior "no headless refresh" assumptions if any are recorded elsewhere in gate 7 material — that reconciliation is Darkwing's/the coordinator's call.

5. What this investigation did not do

No credential file was read (real or synthetic). No live OAuth request or model call was made — all refresh/access/expires behavior above is static-analysis of bundled source, not observed execution. No package was installed system-wide; extraction was confined to a private temp directory under this session's scratchpad and is not part of the repository. No files other than this one were added to the repository.