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:4cd00f653c3dabeb193b46f511044e7fbfe0f947integrity:sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==fileCount: 1056,unpackedSize: 21935887tarball: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 underdist.signatures(keyidSHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U). dist-tags.latest:0.85.1.
- Local fetch (
npm pack @earendil-works/[email protected] --json) reportedshasum: 4cd00f653c3dabeb193b46f511044e7fbfe0f947,integrity: sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==— matches the registry'sdistrecord exactly. - Local re-hash of the downloaded tarball:
sha1sum=4cd00f653c3dabeb193b46f511044e7fbfe0f947(matches npm'sshasumfield bit-for-bit),sha256sum=1f498729649bdce647d1160993b4d92bf3c614cc819213bee2f91dd34f2a7af4(recorded as an independent local fingerprint; npm does not publish a sha256 for the tarball itself, only the sha512integritydigest 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(perdocs/providers.md:26,111, and the code defaultjoin(getAgentDir(), "auth.json")indist/core/auth-storage.js:19(FileAuthStorageBackendconstructor) anddist/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 with0o700(dist/core/auth-storage.js:23-26). - Credential schema, validated on every load (
dist/core/auth-storage.js:184-200, insideFileAuthStorageBackend's parse/validate path — the same shape is re-validated by the writableAuthStorageclass):- 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), whereexpiresis a Unix millisecond timestamp. Any entry not matching one of these two shapes throwsInvalid auth.json credential for provider "<id>".
- API key:
- 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. Itsmodify(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, callsfn(currentCredential), and iffnreturns a new credential object, merges it into the parsed JSON and rewrites the whole file in place (JSON.stringify(merged, null, 2), same0o600write 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 isrefresh: false, but the CLI wiring passesrefresh: !noRefresh(i.e. refresh is the default CLI behavior;--no-refreshis what sets it tofalse, matching the printed help text atauth-command.js:25). Whenoptions.refreshis true, linedist/cli/auth-check.js:27callsmodelRuntime.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 validityDEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS = 30 * 60_000(30 minutes; bundled atdist/bundle/chunks/chunk-JVUZSMYM.js,resolveCredentialForPrint).--min-expiryaccepts durations like30m/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) andpi 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, forprint-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-refreshopts out) orpi 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) → bundledresolveStoredOAuth/credential resolution indist/bundle/chunks/chunk-IDDQWTHI.js→ provider-specificoauth.refresh()(e.g.anthropicOAuth.refreshindist/bundle/chunks/anthropic.js). There is no separatepi auth refreshsubcommand; refresh is a side effect of the read/check/print commands above, or of ordinary model use. - (c) Does it rewrite
auth.jsonin 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 parsedauth.jsonand writes the whole file back to the same path with0o600permissions 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) andDEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 15_000(network timeout for the refresh call itself). Additionally, both the initial token mint (exchangeAuthorizationCode) and every refresh (refreshAnthropicToken) computeexpiresasDate.now() + expires_in*1000 - 300_000, i.e. the provider's ownexpires_inis 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.