# 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/pi-coding-agent@0.85.1` 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/pi-coding-agent@0.85.1 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/pi-coding-agent@0.85.1 --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 }`. - 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 ""`. - 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 [--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 [--model ]` (`dist/cli/auth-command.js:6`). - `pi auth print-bearer-token --provider [--model ] [--min-expiry ]` (`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

` (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 ` (default behavior, no `--no-refresh`) and `pi auth print-api-key`/`pi auth print-bearer-token --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 ` (refresh is the default; `--no-refresh` opts out) or `pi auth print-api-key --provider ` / `pi auth print-bearer-token --provider [--min-expiry ]` (`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.