|
|
|
@@ -0,0 +1,250 @@
|
|
|
|
|
# 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/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<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.
|