Files
stack/docs/plans/reviews/2026-09-10_m20-refresh-isolation-findings.md
T

30 KiB

M20-I2-ISOLATION-ROCKO-1 — pi auth check/print isolation findings

Bounded read-only investigation (#1500). Brief: docs/plans/reviews/2026-09-10_m20-refresh-isolation-request.md. Package under investigation: @earendil-works/[email protected] (registry), the same version currently pinned at the repo root (package.json:8; verified directly for this correction — see section 0). Method: npm pack/npm view against the public registry, local re-hash, static reading of the extracted public package only. No CLI execution, no credentials, no network calls, no installs.

0. Correction receipt (post-review, 2026-09-10)

Darkwing's review identified three evidence issues in the version of this file at hash 89a9578c1a5982a2ef085e1ebff3990d66e8c237732397e2aba1c3b84d081ce9. Original claims are quoted verbatim below, followed by the correction. Nothing in the original file has been deleted; corrected passages are updated in place in the sections referenced.

(1) Package pin. Original (section 11): "Did not compare 0.85.1's auth-check isolation surface against the currently pinned 0.84.4 (package.json:8) line-by-line." This was stale. Re-checked directly against the current working tree: package.json:8 now reads "@earendil-works/pi-coding-agent": "0.85.1", and package-lock.json:12,533 agree. git log --oneline -- package.json shows the pin was moved from 0.84.4 to 0.85.1 by commit 557aba0f ("Add packages/mosaic registry schemas, validation, read-only CLI; pin pi 0.85.1 (#1499)"), which landed after my original PI-REFRESH-ROCKO-1 read of package.json:8 (when it still said 0.84.4) and before this review. Corrected in section 11 below: there is now no version delta between the pinned package and the package under investigation — both are 0.85.1.

(2) Snapshot path. Original (section 10): a timestamp placeholder, /tmp/m20-pi0851-isolation-<timestamp>/. Corrected to the concrete, still-present path used for every citation in this file: /tmp/m20-pi0851-isolation-20260910T222007Z/ (verified present with ls at the time of this correction). Section 10 updated below.

(3) allowModelNetwork:false scope. Original claims (sections 5 and 9) stated flatly that "no live network call occurs" for any of the three auth subcommands because allowModelNetwork is hard-coded false. This was incorrect as a general claimallowModelNetwork/allowNetwork only gates ModelRuntime.refresh()'s model-catalog refresh path (dist/core/model-runtime.js). It does not reach OAuth credential refresh, which is a separate, unconditional path traced fresh for this correction in section 5a below and was not part of the original findings. Sections 5, 8, and 9 are corrected below to bound the no-network claim to model-catalog traffic only, and to state the OAuth-refresh network exposure explicitly.

No CLI or network credential experiment was run to produce this correction — the finding is from static reading of dist/bundle/chunks/chunk-IDDQWTHI.js only, in the same snapshot already cited.

1. Package identity/integrity (re-verified for this task)

Reused method from docs/plans/reviews/2026-09-10_pi0851-refresh-investigation.md (hash cfab6c006a5aeba1734390c01efa8231e201e6560bf422938385448b24054ac6). The original session-scratchpad extraction from that task was no longer present (cleaned up at that task's close-out); Darkwing acknowledged this before I re-fetched (reaction message, see brief context).

Two independent fetches into the shared snapshot (path below):

Fetch Command Tarball shasum (npm pack manifest)
1 npm pack @earendil-works/[email protected] --json 4cd00f653c3dabeb193b46f511044e7fbfe0f947
2 (addendum: lifecycle scripts disabled) npm pack @earendil-works/[email protected] --ignore-scripts --json 4cd00f653c3dabeb193b46f511044e7fbfe0f947

Identical shasum both times, and identical to the shasum recorded in the prior PI-REFRESH-ROCKO-1 findings — the "0.85.1" identity used here is byte-for-byte the same package. Also cross-checked against registry-dist.json (npm view @earendil-works/[email protected] dist --json), whose shasum/integrity match the local re-hash.

Note on lifecycle scripts: npm pack <spec> against a remote registry package resolves and downloads the tarball directly — it does not run an npm install and therefore never executes install lifecycle scripts, independent of --ignore-scripts. The identical shasum with and without the flag is expected and is not by itself proof the package has no lifecycle scripts (extracted/package/package.json scripts block was not separately audited here, out of scope); it is proof the fetch method used for this snapshot did not execute any.

2. Isolation lever: PI_CODING_AGENT_DIR

extracted/package/dist/config.js:406-427:

export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
export function getAgentDir() {
    const envDir = process.env[ENV_AGENT_DIR];
    if (envDir) {
        return expandTildePath(envDir);
    }
    return join(homedir(), CONFIG_DIR_NAME, "agent");
}

APP_NAME is "pi" (dist/config.js:401, piConfigName || "pi"), so ENV_AGENT_DIR resolves to the literal string PI_CODING_AGENT_DIR. CONFIG_DIR_NAME is .pi by default (dist/config.js:403). With the env var unset, the fallback is homedir() (Node's os.homedir(), itself HOME-driven on POSIX) joined with .pi/agent — so HOME also moves the default location, but PI_CODING_AGENT_DIR is the explicit, single-purpose override and does not disturb any other HOME-dependent behavior.

Every agent-state path is derived from getAgentDir() and therefore moves together under one env var (dist/config.js:433-461):

export function getModelsPath()   { return join(getAgentDir(), "models.json"); }
export function getAuthPath()     { return join(getAgentDir(), "auth.json"); }
export function getSettingsPath() { return join(getAgentDir(), "settings.json"); }
export function getToolsDir()     { return join(getAgentDir(), "tools"); }
export function getBinDir()       { return join(getAgentDir(), "bin"); }
export function getPromptsDir()   { return join(getAgentDir(), "prompts"); }
export function getSessionsDir()  { return join(getAgentDir(), "sessions"); }
export function getDebugLogPath() { return join(getAgentDir(), `${APP_NAME}-debug.log`); }

getPackageDir() (dist/config.js:311-313) is a separate, unrelated env var (PI_PACKAGE_DIR) that locates the installed package itself (themes, docs, README, CHANGELOG bundled with the npm package) — it has no bearing on agent state and is not part of the isolation boundary being evaluated.

No dedicated CLI flag exists for auth path override. auth-command.js (dist/cli/auth-command.js) defines only --provider, --model, --json, --credentials, --no-refresh (check) and --min-expiry (print-bearer-token) — see AUTH_COMMAND_USAGE at dist/cli/auth-command.js:4-8 and the flag parser at dist/cli/auth-command.js:27-75. There is no --auth-path, --config-dir, or similar. PI_CODING_AGENT_DIR (optionally combined with a scratch HOME) is the only supported isolation mechanism.

3. CLI dispatch order — auth commands run before cwd/settings/extension/session/trust setup

Confirmed directly from the bundled entry point, not inferred from naming. dist/bundle/cli.js is the npm bin.pi shim; it calls main(process.argv.slice(2)) from the single bundled chunk dist/bundle/chunks/chunk-JVUZSMYM.js. That chunk is a single minified line (4,006,956 bytes); functions were located by string search (indexOf) rather than line numbers — anchors below are function names, which are preserved (not mangled) in this build.

async function main(args, options) (found at byte offset 3990752), head of body, statement order preserved verbatim except formatting:

async function main(args,options){
  resetTimings();
  let extensionFactories=[...builtInExtensions,...options?.extensionFactories??[]],
      offlineMode=args.includes("--offline")||isTruthyEnvFlag2(process.env.PI_OFFLINE);
  if(offlineMode&&(process.env.PI_OFFLINE="1",process.env.PI_SKIP_VERSION_CHECK="1"),
     await runAuthCommand(args))
    return;
  process.platform==="win32"&&cleanupWindowsSelfUpdateQuarantine(getPackageDir()),
  cleanupManagedInstall();
  let cwd=process.cwd(),
      agentDir=getAgentDir(),
      bootstrapSettingsManager=SettingsManager.create(cwd,agentDir,{projectTrusted:!1});
  if(applyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy),
     configureHttpDispatcher(),
     await handlePackageCommand(args,{extensionFactories})){ ... }
  if(await handleConfigCommand(args,{extensionFactories}))return;
  let parsed=parseArgs(args);
  ...

This settles the brief's explicit question with evidence, not an inference from command naming: await runAuthCommand(args) is the first awaited call in main(), and a truthy return exits main() before:

  • process.cwd() is read (cwd is assigned only after the auth branch),
  • SettingsManager.create(cwd, agentDir, {projectTrusted:false}) runs (project trust / project settings discovery),
  • handlePackageCommand/handleConfigCommand run,
  • parseArgs(args) runs (the general CLI arg parser, session/model/tool flags),
  • any session manager, migrations, or interactive-mode setup is reached.

The only thing evaluated before runAuthCommand is the extensionFactories array assembly: builtInExtensions is a static in-memory literal — builtInExtensions=[{name:"llama.cpp",factory:llamaExtension,hidden:!0}] — with no disk I/O at construction, and options?.extensionFactories are factories the host process passes in (not loaded from a project directory or config file). So no project-scoped extension or config discovery occurs ahead of auth dispatch either.

runAuthCommand(args) itself (byte offset 3981368) calls getAgentDir() indirectly (via AuthStorage.create() / ReadOnlyAuthStorage / ModelRuntime.create() default paths — section 4) but never reads cwd, never touches SettingsManager, and never resolves project-level extensions.

4. Auth storage isolation specifics

dist/core/auth-storage.js:

  • AuthStorage.create(authPath = join(getAgentDir(), "auth.json")) (line 281) — used by check unless --no-refresh is given.
  • ReadOnlyAuthStorage constructor: constructor(authPath = join(getAgentDir(), "auth.json")) (line 161) — used by check --no-refresh.
  • FileAuthStorageBackend (line 17) is the backing store for AuthStorage; its lock file is acquired directly on the auth path via proper-lockfile: lockfile.lockSync(path, {realpath:false}) (line 39) and lockfile.lock(this.authPath, ...) (line 85). proper-lockfile places its lock next to the target file, so the lock stays inside getAgentDir() — no isolation-breaking temp path outside the agent dir.
  • Neither constructor, nor any auth-path CLI flag, exists to point check/print-* at a path outside getAgentDir() independent of the env var — confirming PI_CODING_AGENT_DIR (optionally with a scratch HOME) is the complete and only isolation lever for credentials.

print-api-key/print-bearer-token (the command.kind!=="check" branch inside runAuthCommand, same byte-offset function) call ModelRuntime.create({allowModelNetwork:false, signal}) with no explicit credentials/authPath option, so ModelRuntime.create (dist/core/model-runtime.js:74) falls back to its own default: DefaultAuthStorage.create(options.authPath) with options.authPath undefined — which resolves to the same getAgentDir()-derived auth.json path. Credential file location is identical across all three subcommands; the models-catalog behavior differs (section 5), and so does credential-refresh network exposure across check/check --no-refresh/print-* (section 5a).

5. check vs print-*: models.json / models-store.json asymmetry, and model-catalog network scope (corrected)

dist/cli/auth-check.js:42-49:

export async function createAuthCheckModelRuntime(credentials) {
    return ModelRuntime.create({
        credentials,
        modelsStore: new InMemoryCodingAgentModelsStore(),
        allowModelNetwork: false,
        refreshOnCreate: false,
    });
}

check passes an explicit in-memory models store and disables refreshOnCreate — it never reads models.json/models-store.json from disk and never runs runtime.refresh() at construction.

print-api-key/print-bearer-token, by contrast, call ModelRuntime.create({allowModelNetwork:false, signal}) with no modelsStore/modelsPath/refreshOnCreate override. ModelRuntime.create defaults (dist/core/model-runtime.js:74-108):

static async create(options = {}) {
    const credentials = new RuntimeCredentials(options.credentials ?? DefaultAuthStorage.create(options.authPath));
    const modelsPath = options.modelsPath === null ? undefined : (options.modelsPath ?? join(getAgentDir(), "models.json"));
    const config = await ModelConfig.load(modelsPath);
    const modelsStore = options.modelsStore ??
        (modelsPath
            ? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json"))
            : new InMemoryCodingAgentModelsStore());
    ...
    const refreshFromNetwork = runtime.modelNetworkEnabled && options.allowModelNetwork === true;
    ...
    if (options.refreshOnCreate !== false) {
        await runtime.refresh({ allowNetwork: refreshFromNetwork, signal });
    }
    ...
}

So print-* does read models.json and models-store.json from getAgentDir() (both env-overridable together with everything else), and does call runtime.refresh() at construction (since refreshOnCreate is not passed as false here) — this is a real behavioral difference from check. allowModelNetwork is fixed to false for both check's in-memory runtime and print-*'s runtime, so refreshFromNetwork = runtime.modelNetworkEnabled && options.allowModelNetwork === true evaluates to false regardless of modelNetworkEnabled. refresh() (dist/core/model-runtime.js:504-517) forwards this as allowNetwork: options.allowNetwork ?? this.modelNetworkEnabled into the underlying this.models.refresh(refreshOptions) (the pi-ai models store) — this specific call path performs no network-based model-catalog refresh for any of the three auth subcommands. The only disk reads triggered by it are local: ModelConfig.load(modelsPath) (models.json) and whatever FileModelsStore does with models-store.json, both under getAgentDir().

Practical isolation consequence (catalog only): an isolated PI_CODING_AGENT_DIR needs, at minimum, a valid (possibly empty-provider) models.json to exist or be absent-tolerant for print-* to construct cleanly, since it does load that file; check has no such dependency.

This "no network" finding is scoped to model-catalog refresh only and does not extend to OAuth credential refresh — see section 5a, added on review correction. The original text here claimed "no live network call occurs under an isolated, offline-style setup" without that scope qualifier; that unqualified claim was incorrect and is retracted in favor of section 5a.

5a. OAuth credential refresh is a separate, unconditional network path (correction — traced 2026-09-10)

ModelRuntime.getAuth()/checkAuth() (dist/core/model-runtime.js:280,339) delegate to this.models.getAuth/checkAuth — methods on the pi-ai-level models store, implemented in dist/bundle/chunks/chunk-IDDQWTHI.js (same snapshot, extracted/package/dist/bundle/chunks/chunk-IDDQWTHI.js), not in dist/core/model-runtime.js. Neither allowModelNetwork nor allowNetwork is threaded into this call at all — getAuth (offset 15594) calls resolveProviderAuth(provider, this.credentials, this.authContext, {...overrides, signal}) with no network flag among overrides.

resolveProviderAuthresolveStoredOAuth (offset 4923 in that chunk) is where an OAuth credential's freshness is enforced, unconditionally, with no network gate anywhere in the function:

var DEFAULT_OAUTH_MINIMUM_VALIDITY_MS=300*1e3, DEFAULT_OAUTH_REFRESH_TIMEOUT_MS=15e3;
async function resolveStoredOAuth(credentials,providerId,oauth,stored,signal,minOAuthValidityMs){
  let minimumValidityMs=Math.max(DEFAULT_OAUTH_MINIMUM_VALIDITY_MS,minOAuthValidityMs??0),
      expiresSoon=credential2=>Date.now()+minimumValidityMs>=credential2.expires,
      credential=stored;
  if(expiresSoon(credential)){
    let post;
    try{
      post=await credentials.modify(providerId,async current=>{
        if(current?.type==="oauth"&&expiresSoon(current))
          try{
            let refreshSignal=AbortSignal.any([signal,AbortSignal.timeout(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS)]);
            return await oauth.refresh(current,refreshSignal)
          }catch(error){throw new ModelsError("oauth",`OAuth refresh failed for ${providerId}`,{cause:error})}
      },{signal})
    }catch(error){ ... }
    ...
  }
  ...
}

If a stored OAuth credential's expires timestamp is within DEFAULT_OAUTH_MINIMUM_VALIDITY_MS (300,000 ms / 5 minutes) of Date.now() — including already expired — this path calls oauth.refresh(current, refreshSignal) regardless of allowModelNetwork/allowNetwork, which this function never receives. oauth.refresh is the per-provider implementation; for Anthropic it is refreshAnthropicToken posting to TOKEN_URL="https://platform.claude.com/v1/oauth/token" (dist/bundle/chunks/anthropic.js, confirmed present in this snapshot with that exact literal), and equivalent live token endpoints exist for other bundled providers (openai-codex.jsauth.openai.com/oauth/token, openrouter.jsopenrouter.ai/api/v1/auth/keys, xai.jsauth.x.ai/oauth2/token). This is a real outbound HTTPS call, not a local operation.

Which subcommands are exposed, and the one mitigation that exists:

  • check (default, no --no-refresh): uses AuthStorage.create() (mutable) → credentials.modify() succeeds → oauth.refresh() runs if a stored OAuth credential is near/at expiry.
  • check --no-refresh: uses ReadOnlyAuthStorage, whose modify() throws immediately (dist/core/auth-storage.js:227, "Read-only credential storage cannot modify auth.json") before oauth.refresh() can be reached. The thrown error propagates out of resolveStoredOAuth/getAuth and is caught by checkProviderAuth's try/catch (dist/cli/auth-check.js:32-34), which reports {status:"invalid", reason:"invalid_state"} instead. This is the only one of the three subcommands with a supported way to guarantee no OAuth-refresh network attempt.
  • print-api-key / print-bearer-token: always construct ModelRuntime.create({allowModelNetwork:false, signal}) with no credentials override, which resolves to DefaultAuthStorage.create() — the same mutable AuthStorage as default check. There is no --no-refresh equivalent flag for either print command (AUTH_COMMAND_USAGE/flag parser, dist/cli/auth-command.js:4-8,27-75 restrict --no-refresh to kind==="check"). So both print commands will attempt a live OAuth-refresh network call whenever the isolated auth.json holds a stored OAuth credential within 5 minutes of expiry (or already expired), with no way to suppress it via flags.

Bounded conclusion: for an empty isolated auth.json (or one containing only api_key-type credentials, which never enter resolveStoredOAuth at all — readCredential in resolveProviderAuthWithSignal branches on stored.type==="oauth" before this path is reached), none of the three subcommands makes any network call, matching the original (uncorrected) intent. The risk is specific and load-bearing: never place a real, or a synthetic near-expiry, OAuth-type credential in an isolated PI_CODING_AGENT_DIR used for print-api-key/print-bearer-token testing, or in check without --no-refresh — doing so can trigger a genuine outbound request to the provider's live OAuth token endpoint, independent of allowModelNetwork. No such credential or request was created, read, or attempted in this investigation; this is a static-source-derived bound, not an observed network call.

6. models.json custom-provider schema (Darkwing addendum)

docs/models.md (top-level docs/ in the extracted 0.85.1 tarball, included in the shared snapshot) documents the format; the authoritative schema is dist/core/model-config.js:139-187 (TypeBox), reproduced in relevant part:

const ModelDefinitionSchema = Type.Object({
    id: Type.String({ minLength: 1 }),
    name: Type.Optional(...), api: Type.Optional(...), baseUrl: Type.Optional(...),
    reasoning: Type.Optional(...), thinkingLevelMap: Type.Optional(...),
    input: Type.Optional(...), cost: Type.Optional(...),
    contextWindow: Type.Optional(...), maxTokens: Type.Optional(...),
    samplingParams: Type.Optional(...), headers: Type.Optional(...),
    compat: Type.Optional(...),
});
const ProviderConfigSchema = Type.Object({
    name: Type.Optional(...), baseUrl: Type.Optional(Type.String({minLength:1})),
    apiKey: Type.Optional(Type.String({minLength:1})), api: Type.Optional(Type.String({minLength:1})),
    oauth: Type.Optional(Type.Literal("radius")), headers: Type.Optional(...),
    compat: Type.Optional(...), authHeader: Type.Optional(...),
    models: Type.Optional(Type.Array(ModelDefinitionSchema)),
    modelOverrides: Type.Optional(...),
});
const ModelsConfigSchema = Type.Object({
    providers: Type.Record(Type.String(), ProviderConfigSchema),
});

This confirms Darkwing's proposed minimal shape is schema-valid against 0.85.1's actual models.json validator:

{
  "providers": {
    "<id>": {
      "api": "...",
      "baseUrl": "...",
      "models": [{ "id": "..." }],
      "apiKey": "... (optional)"
    }
  }
}

models.id is the only field in the entire schema with no Type.Optional wrapper anywhere in the object graph — every other field at every level is optional. docs/models.md's own "Minimal Example" uses exactly this shape (an Ollama provider with api, baseUrl, apiKey, and a models array of bare {id} objects), matching the schema reading. apiKey is confirmed optional both in the schema and in docs/models.md's notes on keyless local servers.

7. Non-secret output and exit codes

Source: runAuthCommand (byte offset 3981368) and resolveCredentialForPrint (found by name in the same chunk), plus dist/cli/auth-command.js / dist/cli/auth-check.js.

Parse-level errors (any subcommand, before dispatch):

  • Unknown auth subcommand or unparseable args → console.error in red (Error: <message>), process.exitCode = 1.
  • Unknown flag → console.error (Unknown option --<flag> for "<command name>".) + a dim usage hint, process.exitCode = 1.
  • --help/-h/bare authprintAuthCommandHelp() prints the usage block to stdout via console.log, no exit code forced (defaults to 0).

print-api-key / print-bearer-token:

  • Success: the resolved credential/token string is written to stdout via process.stdout.write(${credential}\n). No JSON mode, no structured status — bare secret value only. process.exitCode is left at its default (0) on success.
  • Failure (AuthCommandError or unexpected throw, e.g. unknown provider, no credential configured, model not found): console.error in red (Error: <message>), process.exitCode = 1 (kind is not "check", so the kind-dependent exit branch sets 1).

check:

  • result.status is one of "ready" | "not_ready" | "invalid" (checkProviderAuth, dist/cli/auth-check.js:5-35), with a reason code among invalid_state | provider_not_found | credentials_not_configured | credential_not_available when not ready.
  • Default (non---json) output: credential ?? result.status written to stdout (i.e. the bare status word unless --credentials requested and status is ready, in which case the raw credential is printed instead — operators should not pass --credentials in a context where stdout is logged/shared if avoiding secret material matters).
  • --json output: JSON.stringify({...result, ...(credential ? {credentials: credential} : {})}) — i.e. {"status":"ready","provider":"...","authType":"..."} (plus a credentials field only when --credentials was also passed).
  • Exit codes: result.status==="ready" ? 0 : result.status==="not_ready" ? 1 : 2.
  • On a thrown error during check (e.g. missing --provider/--model): console.error (Error: <message>), process.exitCode = 2 (kind is "check").

No path observed prints raw OAuth refresh tokens or API keys unless the operator explicitly passes --credentials (check) or invokes print-api-key/print-bearer-token directly, which is their documented purpose. This finding itself contains no credential material — all values above are format descriptions only.

8. Direct answers to the brief

  • Exact supported environment/flags for isolation: set PI_CODING_AGENT_DIR to a dedicated empty directory (this alone isolates auth.json, models.json, models-store.json, settings.json, sessions, tools, bin, prompts, and the debug log). Optionally also set a scratch HOME as defense-in-depth, since it is the fallback base if PI_CODING_AGENT_DIR were ever unset by mistake, but it is not required given PI_CODING_AGENT_DIR takes precedence unconditionally. No CLI flag exists for this; it is env-only.
  • Working directory relevance: none, for auth commands specifically — proven in section 3 (cwd is not read until after runAuthCommand returns).
  • Does auth-check load extensions/project packages/settings before handling the command? No — proven in section 3 by direct reading of main()'s statement order, not inferred from naming.
  • Is PI_CODING_AGENT_DIR (or another override) supported? Yes, confirmed at dist/config.js:406-427; it is the sole override and controls the entire agent directory as one unit.
  • Non-secret output and exit codes: enumerated in section 7.
  • Network scope (corrected): allowModelNetwork:false blocks only the model-catalog refresh path (section 5); it does not gate OAuth credential refresh, which is a separate, unconditional path that can make a live call to the provider's OAuth token endpoint if a stored OAuth credential is near/at expiry (section 5a).

9. Blocker determination (corrected)

No blocker identified for the isolation mechanism itself, with one load-bearing constraint on credential content that was missing from the original version of this section. PI_CODING_AGENT_DIR fully isolates the auth/config directory:

  • Credential resolution (auth.json) is isolated identically across all three subcommands (section 4).
  • No project/cwd/extension/settings discovery occurs before or during auth-command handling (section 3).
  • Model-catalog refresh performs no network call under allowModelNetwork:false, which is hard-coded for all three subcommands, not operator-controlled (section 5).
  • The one residual gap for print-* is that it also touches models.json/models-store.json under the same isolated directory (section 5) — this is a same-directory read, not an isolation leak, but callers should ensure the isolated PI_CODING_AGENT_DIR either omits models.json (tolerated — ModelConfig.load handles a missing file) or contains only the intended synthetic provider (section 6 shape) if print-* isolation tests need deterministic provider resolution.
  • Corrected/added: credential content placed in the isolated auth.json is load-bearing for network isolation, independent of PI_CODING_AGENT_DIR. An empty auth.json, or one containing only api_key-type entries, triggers no network call from any of the three subcommands. A stored oauth-type credential within 5 minutes of its expires timestamp (or already expired) will trigger a live refresh request to the provider's real OAuth token endpoint for default check and for both print-* commands (no flag suppresses this for print-*; check --no-refresh is the one subcommand/flag combination that provably cannot reach the network for this reason — section 5a). This is a constraint on test fixture design, not a defect in PI_CODING_AGENT_DIR isolation, but it must be honored for any isolation test that populates auth.json with OAuth-shaped data.

10. Shared snapshot (corrected — concrete path)

/tmp/m20-pi0851-isolation-20260910T222007Z/ (verified present on this host at the time of this correction; coordinator/reviewer-accessible for inspection). Contents: tarball, pack.json/pack-ignore-scripts.json (npm pack manifests), registry-dist.json (npm view dist metadata), tarball.sha256, and extracted/package/ (full public tarball contents: dist/, docs/ including docs/models.md, CHANGELOG.md, README.md, package.json). See SNAPSHOT-INDEX.md inside the snapshot for a file-by-file map to the citations above.

11. What this investigation did not do

  • Did not run pi (any subcommand), install the package, or use real or fake credentials.
  • Did not audit extracted/package/package.json's scripts block for the presence/absence of lifecycle scripts (noted as out of scope in section 1's caveat).
  • Did not trace FileModelsStore/ModelConfig.load's exact disk-miss behavior line-by-line beyond confirming it is called and is local (no network) under allowModelNetwork:false for the model-catalog path specifically (section 5).
  • Did not verify behavior on Windows (getAgentDir's expandTildePath/ path-join behavior assumed POSIX-equivalent per this host).
  • Corrected: the package pin comparison this bullet originally described is moot — package.json:8 now pins 0.85.1, the same version investigated throughout this file (see section 0(1)). The pin was 0.84.4 when read during the prior PI-REFRESH-ROCKO-1 task and was moved to 0.85.1 by commit 557aba0f (#1499) before this task ran. There is no version delta left to compare for the auth/isolation surface; PI-REFRESH-ROCKO-1's changelog analysis (0.84.4→0.85.1, no auth-related changes) remains the relevant prior evidence for anything that predates the pin bump.
  • Did not run any command, real or synthetic, that would populate auth.json with an OAuth-type credential to observe the refresh path in section 5a directly — that section is derived entirely from static reading of chunk-IDDQWTHI.js, per the brief's "no CLI/network credential experiment authorized" constraint from the review.