UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

798 lines • 39.2 kB
// pi.dev (#491) credential DETECTION — "does a usable provider credential exist // on this machine?", answered from disk + env only. // // Why this is its own module: `pi --list-models` is NOT an auth probe (it prints // the built-in catalog with no key at all), so unlike every other CLI backend the // connect-time model probe cannot tell us whether pi can actually authenticate. // Readiness therefore has to reproduce pi's own credential resolution. The rules // below are transcribed from pi's source (earendil-works/pi @ main) rather than // guessed; each is cited at its use site so a future reader can re-verify: // // packages/ai/src/env-api-keys.ts — `envMap` + the anthropic/copilot specials // packages/ai/src/auth/types.ts — ApiKeyCredential / OAuthCredential shapes // packages/coding-agent/src/core/auth-storage.ts — auth.json is plain JSON.parse // packages/coding-agent/src/core/model-config.ts — models.json is JSONC + `{providers:{…}}` // packages/coding-agent/src/utils/json.ts — the exact stripJsonComments behaviour // packages/ai/src/providers/google-vertex.ts — Vertex ADC (creds path + project + location) // // PRECISION BAR (deliberate): "ready" means a plausible, WELL-FORMED, PRESENT // credential source — not a proven-working one. A revoked key, an exhausted quota // or a typo'd secret is invisible from disk and still fails on the first turn; // that residual is accepted and the panel/banner wording must not over-promise. // What we do NOT accept is greening on a source that CANNOT authenticate no // matter what: a record with no key at all, a credentials path pointing at a file // that isn't there, or an empty provider stanza. // // Scope note: this is accidental-wrongness only. There is no adversarial model // here — a user who hand-edits their own auth.json to lie is out of scope (this // is a local single-trust-domain project). import { readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import { TOOL_ONLY_SECRET_ENV_KEYS } from "../services/panel-secrets.js"; /** * Every env var pi reads as a provider credential. * * Transcribed from `envMap` in packages/ai/src/env-api-keys.ts, plus the three * exported anthropic constants (ANTHROPIC_AUTH_TOKEN / ANTHROPIC_OAUTH_TOKEN / * ANTHROPIC_API_KEY) and the github-copilot special case, which live outside the * map. Ordered by provider id to keep it diff-able against pi's file. * * NOT every entry here necessarily reaches pi's subprocess — see * `piEnvKeysReachingPi()`, which subtracts the ComfyUI tool-only secrets that * buildAgentSpawnEnv() strips. Keep this list a faithful copy of pi's map; do the * subtraction there, not by editing this array. */ export const PI_PROVIDER_ENV_KEYS = { // anthropic is special-cased in pi: three accepted vars, any one suffices. anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"], // envMap, in pi's own order. "ant-ling": ["ANT_LING_API_KEY"], "qwen-token-plan": ["QWEN_TOKEN_PLAN_API_KEY"], "qwen-token-plan-cn": ["QWEN_TOKEN_PLAN_CN_API_KEY"], openai: ["OPENAI_API_KEY"], "azure-openai-responses": ["AZURE_OPENAI_API_KEY"], nvidia: ["NVIDIA_API_KEY"], deepseek: ["DEEPSEEK_API_KEY"], google: ["GEMINI_API_KEY"], // stripped before it reaches pi — see below "google-vertex": ["GOOGLE_CLOUD_API_KEY"], groq: ["GROQ_API_KEY"], cerebras: ["CEREBRAS_API_KEY"], xai: ["XAI_API_KEY"], radius: ["RADIUS_API_KEY"], openrouter: ["OPENROUTER_API_KEY"], "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], zai: ["ZAI_API_KEY"], "zai-coding-cn": ["ZAI_CODING_CN_API_KEY"], mistral: ["MISTRAL_API_KEY"], minimax: ["MINIMAX_API_KEY"], "minimax-cn": ["MINIMAX_CN_API_KEY"], moonshotai: ["MOONSHOT_API_KEY"], "moonshotai-cn": ["MOONSHOT_API_KEY"], huggingface: ["HF_TOKEN"], // stripped before it reaches pi — see below fireworks: ["FIREWORKS_API_KEY"], together: ["TOGETHER_API_KEY"], opencode: ["OPENCODE_API_KEY"], "opencode-go": ["OPENCODE_API_KEY"], "kimi-coding": ["KIMI_API_KEY"], "cloudflare-workers-ai": ["CLOUDFLARE_API_KEY"], "cloudflare-ai-gateway": ["CLOUDFLARE_API_KEY"], xiaomi: ["XIAOMI_API_KEY"], "xiaomi-token-plan-cn": ["XIAOMI_TOKEN_PLAN_CN_API_KEY"], "xiaomi-token-plan-ams": ["XIAOMI_TOKEN_PLAN_AMS_API_KEY"], "xiaomi-token-plan-sgp": ["XIAOMI_TOKEN_PLAN_SGP_API_KEY"], "github-copilot": ["COPILOT_GITHUB_TOKEN"], // special-cased in pi // Documented in packages/coding-agent/docs/providers.md but absent from // `envMap`, which only covers the api-key providers. "amazon-bedrock": ["AWS_BEARER_TOKEN_BEDROCK"], }; /** * Extra env vars a provider needs ALONGSIDE its key when authenticating purely * from the environment. pi's cloudflare-auth.ts returns undefined unless both the * key and the account id are present (plus the gateway id for the AI Gateway), * so the key alone is not a credential. Only providers whose companion * requirement is confirmed in pi's source are listed here. */ const PI_PROVIDER_ENV_REQUIRED = { "cloudflare-workers-ai": ["CLOUDFLARE_ACCOUNT_ID"], "cloudflare-ai-gateway": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_GATEWAY_ID"], }; /** Flat view of every credential env var pi reads. */ export const PI_ENV_API_KEYS = Object.values(PI_PROVIDER_ENV_KEYS).flat(); /** * The subset of `PI_ENV_API_KEYS` that actually SURVIVES into pi's spawn env. * * pi is spawned with `buildAgentSpawnEnv()` (no keep-list), which deletes the * ComfyUI tool-only secrets. A key that gets stripped must never green pi: * `GEMINI_API_KEY` and `HF_TOKEN` are ComfyUI tool secrets, so a user whose ONLY * credential is one of those has a pi that cannot authenticate — its first turn * would fail. Those users must put the key in ~/.pi/agent/auth.json instead. * * Derived (not hand-maintained) so that changing the secret allowlists in * panel-secrets.ts automatically keeps pi's readiness honest. */ export function piEnvKeysReachingPi() { const stripped = new Set(TOOL_ONLY_SECRET_ENV_KEYS); return PI_ENV_API_KEYS.filter((k) => !stripped.has(k)); } /** * pi's JSONC pre-pass, byte-for-byte the same behaviour as `stripJsonComments` * in packages/coding-agent/src/utils/json.ts: strips `//` line comments and * trailing commas before `}`/`]`, while leaving anything inside a string literal * alone. Block comments are deliberately NOT handled — pi doesn't handle them * either, and a models.json using slash-star comments fails to load for pi too, * so accepting it here would green a file pi itself rejects. */ export function stripJsonComments(input) { return input .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : "")) .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : "")); } /** Valid env-var name per pi's resolver (`[A-Za-z_][A-Za-z0-9_]*`). */ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*/; /** * Every env var a credential value interpolates, in pi's parse order. * * Hand-scanned rather than regex'd because pi's resolver has escapes: in a * non-command value `$$` is a literal `$` and `$!` a literal `!`. A regex would * read `"$$MY_KEY"` as a reference to MY_KEY when pi reads it as the literal * string `$MY_KEY` — that mis-parse false-REDs a perfectly good key. */ function envRefsIn(value) { const names = []; for (let i = 0; i < value.length; i++) { if (value[i] !== "$") continue; const next = value[i + 1]; if (next === "$" || next === "!") { i++; // escaped literal — consume both, reference nothing continue; } if (next === "{") { const end = value.indexOf("}", i + 2); if (end === -1) continue; const name = value.slice(i + 2, end); if (ENV_NAME.test(name) && ENV_NAME.exec(name)?.[0] === name) { names.push(name); i = end; } continue; } const m = ENV_NAME.exec(value.slice(i + 1)); if (m) { names.push(m[0]); i += m[0].length; } } return names; } /** * Is a stored credential VALUE (auth.json `key`, models.json `apiKey`) plausibly * resolvable? pi accepts three forms (docs/providers.md + resolveConfigValue): * * `!some command` → executed, output cached. We cannot verify it without * running it, and running a user command during a readiness * probe is not acceptable — so we accept it unverified. * `$VAR` / `${VAR}` → interpolated from the entry's own `env` block first, then * the process env. pi resolves the WHOLE template to * undefined if any referenced var is unset, so one missing * var means this is not a credential. * literal → accepted as-is (including `$$`/`$!` escapes). * * Empty / non-string / whitespace-only is never a credential. */ export function credentialValueUsable(raw, entryEnv, procEnv = process.env) { if (typeof raw !== "string") return false; const value = raw.trim(); if (!value) return false; // `!command` — unverifiable without executing it; err toward ready. // pi executes the text AFTER `!`; an empty command resolves to undefined, so // it cannot authenticate. Do not let a lone `!` greet the user green. if (value.startsWith("!")) return value.slice(1).trim() !== ""; // Every referenced var must resolve to something non-empty IN THE ENV PI WILL // ACTUALLY SEE. `resolveEnvConfigValue` uses the entry's own `env` block with // `||` fallback to the inherited process env — but a var our spawn // env STRIPS is never inherited, so `"key": "$GEMINI_API_KEY"` resolves for us // and to nothing for pi. Treating it as present would be exactly the false // green this whole module exists to prevent. const stripped = new Set(TOOL_ONLY_SECRET_ENV_KEYS); for (const name of envRefsIn(value)) { const fromEntry = entryEnv?.[name]; // pi's resolver is exactly `entryEnv?.[name] || process.env[name]`. An // empty entry therefore falls through to process.env; whitespace remains // truthy, wins, and cannot authenticate. const resolved = fromEntry || procEnv[name]; if (stripped.has(name) && !fromEntry) return false; if (!nonEmptyString(resolved)) return false; } return true; } /** * Read a provider-scoped value with pi's `??` merge semantics. A number of * bespoke providers deliberately let an explicitly-empty stored setting block * the ambient value; do not accidentally turn that into `||` fallback. */ function readNullishScoped(entryEnv, procEnv, key) { const fromEntry = entryEnv?.[key]; return fromEntry ?? procEnv[key]; } function isPlainObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function nonEmptyString(v) { return typeof v === "string" && v.trim() !== ""; } /** pi refreshes any OAuth token with less than this much life left * (DEFAULT_OAUTH_MINIMUM_VALIDITY_MS in packages/ai/src/auth/resolve.ts). */ const OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1000; // Providers whose current pi resolver accepts auth.json OAuth credentials. // Radius OAuth is configured through models.json (`oauth: "radius"` + baseUrl), // not an auth.json OAuth record, so it is deliberately not listed here. const PI_OAUTH_AUTH_PROVIDERS = new Set([ "anthropic", "github-copilot", "kimi-coding", "openai-codex", "openrouter", "xai", ]); function expiresUsableWithoutRefresh(expires, nowMs) { if (typeof expires !== "number" || !Number.isFinite(expires)) return false; return expires > nowMs + OAUTH_MINIMUM_VALIDITY_MS; } /** * Does one ~/.pi/agent/auth.json record carry a usable credential? * * Shapes from packages/ai/src/auth/types.ts: * ApiKeyCredential { type: "api_key"; key?: string; env?: ProviderEnv } * OAuthCredential { type: "oauth"; refresh: string; access: string; expires: number } * * Note `key` is OPTIONAL in pi's TYPE. Whether a keyless api-key record is * usable is provider-specific: Vertex and Bedrock deliberately support it, * while ordinary key providers can fall back to their ambient key. * * An UNRECOGNISED `type` is not a credential either: pi's resolveProviderAuth * dispatches only "oauth" and "api_key" and returns undefined for anything else * (packages/ai/src/auth/resolve.ts) — and, because a stored credential owns the * provider, it does not fall back to the env. So such a record is strictly worse * than no record at all. */ export function authRecordUsable(record, procEnv = process.env, nowMs = Date.now(), ctx = {}) { if (!isPlainObject(record)) return false; const provider = ctx.provider; const type = record.type; if (type === "api_key") { const entryEnv = isPlainObject(record.env) ? record.env : undefined; if (provider === "amazon-bedrock") { // Bedrock is deliberately unlike ordinary api-key providers: its resolver // falls through from a keyless stored record to AWS's credential chain. return credentialValueUsable(record.key, entryEnv, procEnv) || piBedrockCredentialUsable(entryEnv, procEnv); } // pi's Vertex `/login` writes `{type:"api_key", env:{GOOGLE_CLOUD_PROJECT, …}}` // with a falsy `key`, and its resolver tests `if (key)` before falling // falls back to ADC. So a keyless vertex record IS a credential when the ADC // trio resolves (from the record's own env first, then the process env) — it // used to read as "not signed in" for a working Vertex login. if (!record.key && provider === "google-vertex") { // google-vertex reads `credential.key ?? ctx.env(...)`: scoped `env` does // not supply GOOGLE_CLOUD_API_KEY, and only the process env is ambient. if (credentialValueUsable(procEnv.GOOGLE_CLOUD_API_KEY, undefined, procEnv)) return true; return piVertexAdcUsable(ctx.home ?? homedir(), procEnv, entryEnv); } if (!credentialValueUsable(record.key, entryEnv, procEnv)) return false; // Companion config (cloudflare's account/gateway ids) may come from the // record's own `env` block or the environment; without it pi's resolver // returns undefined and the stored key is useless. const required = provider ? PI_PROVIDER_ENV_REQUIRED[provider] : undefined; if (required) { return required.every((k) => { const fromEntry = entryEnv?.[k]; return (typeof fromEntry === "string" && fromEntry.trim() !== "") || !!procEnv[k]?.trim(); }); } return true; } if (type === "oauth") { // pi only dispatches OAuth records to providers that implement that flow. // An OAuth-shaped record for an API-key-only provider is not a credential. if (provider !== undefined && !PI_OAUTH_AUTH_PROVIDERS.has(provider)) return false; // A refresh token lets pi re-mint access, so a partial migrated record with // one remains usable even when its stored access token is expired. Without // it, an access token needs to survive pi's five-minute refresh window. if (nonEmptyString(record.refresh)) return true; return nonEmptyString(record.access) && expiresUsableWithoutRefresh(record.expires, nowMs); } return false; } function readFileOrNull(file) { try { return readFileSync(file, "utf8"); } catch { return null; } } /** * Parse ~/.pi/agent/auth.json and report whether ANY provider record is usable. * Plain `JSON.parse` — pi reads this file with JSON.parse (auth-storage.ts), NOT * the JSONC path it uses for models.json, so a commented auth.json is broken for * pi and must not green here. Never throws. */ export function piAuthJsonUsable(file, procEnv = process.env, nowMs = Date.now()) { return Object.entries(piAuthRecords(file)).some(([provider, rec]) => authRecordUsable(rec, procEnv, nowMs, { provider })); } /** The parsed auth.json map, or `{}` when missing/corrupt. Used both for * usability and to know WHICH providers have a stored record at all — pi's * resolver states "a stored credential owns the provider: ambient/env is * consulted only when nothing is stored", so a stored record suppresses that * provider's env keys whether or not it works. Never throws. */ /** True only when auth.json parses to literal `null`. pi stores the parsed value * as-is and then indexes it (`this.data[provider]`), which THROWS for null — so * no provider resolves at all and even a good ambient env key cannot be used. * Deliberately narrow: `[]`, a string or a number all index harmlessly to * undefined for pi, so those must NOT suppress ambient credentials. */ function piAuthStoreBroken(file) { const raw = readFileOrNull(file); if (raw === null) return false; try { return JSON.parse(raw) === null; } catch { return false; } } function piAuthRecords(file) { const raw = readFileOrNull(file); if (raw === null) return {}; try { const parsed = JSON.parse(raw); return isPlainObject(parsed) ? parsed : {}; } catch { return {}; } } /** * Parse ~/.pi/agent/models.json and report whether a CUSTOM provider carries its * own credential. * * Two corrections over "the file is a non-empty object": * - JSONC: pi runs stripJsonComments() before JSON.parse (model-config.ts), so a * models.json with `//` comments or trailing commas is perfectly valid FOR PI. * Rejecting it read as "not signed in" for a working install. * - Shape + emptiness: the schema is `{ providers: { <id>: {…} } }`. The old * check counted top-level keys, so `{"providers":{}}` (one key) and even * `{"note":"todo"}` greened pi. A provider entry only supplies auth when it * has a resolvable `apiKey` or an `oauth` mode; per docs/models.md an entry * without one is loaded but its models stay UNAVAILABLE, so it is not a * credential. * * Never throws. */ export function piModelsJsonUsable(file, procEnv = process.env, provider) { const raw = readFileOrNull(file); if (raw === null) return false; try { const parsed = JSON.parse(stripJsonComments(raw)); if (!isPlainObject(parsed)) return false; const providers = parsed.providers; if (!isPlainObject(providers)) return false; const entries = Object.values(providers); // pi validates the WHOLE file against ModelsConfigSchema and DISCARDS it on // any violation — so one malformed sibling provider means none of the file's // credentials reach pi. Reproduce that all-or-nothing behaviour rather than // greening off a single good-looking entry. if (!entries.every((p) => providerEntryValid(p))) return false; return Object.entries(providers).some(([id, p]) => (!provider || id === provider) && isPlainObject(p) && (credentialValueUsable(p.apiKey, undefined, procEnv) || p.oauth === "radius")); } catch { return false; } } /* * models.json schema validation, transcribed from pi's TypeBox schemas in * packages/coding-agent/src/core/model-config.ts: * * ModelsConfigSchema { providers: Record<string, ProviderConfig> } * ProviderConfigSchema { name?, baseUrl?, apiKey?, api?: String{minLength:1}, * oauth?: Literal"radius", headers?: Record<string,string>, * compat?, authHeader?: Boolean, * models?: ModelDefinition[], modelOverrides?: Record<…> } * ModelDefinitionSchema{ id: String{minLength:1}, name?/api?/baseUrl?: String{minLength:1}, * reasoning?: Boolean, thinkingLevelMap?, input?: ("text"|"image")[], * cost?, contextWindow?/maxTokens?: Number, headers?: Record<string,string>, * compat? } * * This matters because pi validates the file as a UNIT and DISCARDS it whole on * any violation — so one malformed provider means none of the file's credentials * reach pi. Fields whose sub-schemas we have not transcribed (compat, cost, * thinkingLevelMap, modelOverrides) are only shape-checked, and UNDECLARED extra * keys are left alone because the schemas declare no additionalProperties * restriction — being stricter than pi there would false-red a file it loads. */ function optNonEmptyString(v) { return v === undefined || (typeof v === "string" && v.length > 0); } function optBoolean(v) { return v === undefined || typeof v === "boolean"; } /** Type.Record(Type.String(), Type.String()) — every value must be a string. */ function optStringRecord(v) { return v === undefined || (isPlainObject(v) && Object.values(v).every((x) => typeof x === "string")); } /** Type.Number(), plus provider-composer's runtime `<= 0` rejection. */ function optPositiveNumber(v) { return v === undefined || (typeof v === "number" && Number.isFinite(v) && v > 0); } function isFiniteNumber(v) { return typeof v === "number" && Number.isFinite(v); } /** Type.Number() with no positivity constraint — ModelOverride's contextWindow / * maxTokens, which provider-composer's `<= 0` check does NOT apply to (that * check is on ModelDefinition only), so 0 is a config pi accepts. */ function optFiniteNumber(v) { return v === undefined || isFiniteNumber(v); } /** ModelCostSchema: input/output/cacheRead/cacheWrite are REQUIRED Type.Number(); * `tiers` is an optional array. A `cost: {}` (or a string rate) fails the schema * and pi throws the whole models.json away. */ function optModelCost(v, required) { if (v === undefined) return true; if (!isPlainObject(v)) return false; for (const f of ["input", "output", "cacheRead", "cacheWrite"]) { if (v[f] === undefined) { // Required on ModelDefinition's cost, optional on a ModelOverride's. if (required) return false; continue; } if (!isFiniteNumber(v[f])) return false; } if (v.tiers !== undefined && (!Array.isArray(v.tiers) || !v.tiers.every(costTierValid))) return false; return true; } /** ModelCostTierSchema: inputTokensAbove + the four rates, all required numbers. */ function costTierValid(tier) { if (!isPlainObject(tier)) return false; return ["inputTokensAbove", "input", "output", "cacheRead", "cacheWrite"].every((f) => isFiniteNumber(tier[f])); } /** ThinkingLevelMapSchema: each DECLARED level maps to a string or null. Only the * declared keys are checked — pi's object schemas set no additionalProperties * restriction, so an unknown key is fine and rejecting it would false-red. */ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; function optThinkingLevelMap(v) { if (v === undefined) return true; if (!isPlainObject(v)) return false; return THINKING_LEVELS.every((lvl) => { const x = v[lvl]; return x === undefined || x === null || typeof x === "string"; }); } /** ModelOverrideSchema — the same fields as a model definition, all optional and * with no `id`. */ function modelOverrideValid(v) { if (!isPlainObject(v)) return false; if (!optNonEmptyString(v.name)) return false; if (!optBoolean(v.reasoning)) return false; if (!optThinkingLevelMap(v.thinkingLevelMap)) return false; if (!optModelInput(v.input)) return false; if (!optModelCost(v.cost, false)) return false; if (!optFiniteNumber(v.contextWindow) || !optFiniteNumber(v.maxTokens)) return false; if (!optStringRecord(v.headers)) return false; if (v.compat !== undefined && !isPlainObject(v.compat)) return false; return true; } /** Type.Array(Type.Union([Literal"text", Literal"image"])). */ function optModelInput(v) { return v === undefined || (Array.isArray(v) && v.every((x) => x === "text" || x === "image")); } function modelDefinitionValid(model) { if (!isPlainObject(model)) return false; if (typeof model.id !== "string" || model.id.length === 0) return false; if (!optNonEmptyString(model.name) || !optNonEmptyString(model.api) || !optNonEmptyString(model.baseUrl)) return false; if (!optBoolean(model.reasoning)) return false; if (!optPositiveNumber(model.contextWindow) || !optPositiveNumber(model.maxTokens)) return false; if (!optStringRecord(model.headers)) return false; if (!optModelInput(model.input)) return false; if (!optModelCost(model.cost, true)) return false; if (!optThinkingLevelMap(model.thinkingLevelMap)) return false; // ProviderCompatSchema is a union of three all-optional shapes; a shape check // is the faithful limit without transcribing 24 fields whose only effect is // wire-format tweaks. if (model.compat !== undefined && !isPlainObject(model.compat)) return false; return true; } function providerEntryValid(entry) { if (!isPlainObject(entry)) return false; if (!optNonEmptyString(entry.name) || !optNonEmptyString(entry.baseUrl) || !optNonEmptyString(entry.apiKey) || !optNonEmptyString(entry.api)) return false; if (entry.oauth !== undefined) { if (entry.oauth !== "radius") return false; // provider-composer.ts throws `"baseUrl" is required when "oauth" is set`. if (typeof entry.baseUrl !== "string" || entry.baseUrl.length === 0) return false; } if (!optStringRecord(entry.headers)) return false; if (!optBoolean(entry.authHeader)) return false; if (entry.compat !== undefined && !isPlainObject(entry.compat)) return false; if (entry.modelOverrides !== undefined) { if (!isPlainObject(entry.modelOverrides)) return false; if (!Object.values(entry.modelOverrides).every(modelOverrideValid)) return false; } if (entry.models !== undefined && (!Array.isArray(entry.models) || !entry.models.every(modelDefinitionValid))) return false; return true; } /** True only for an existing REGULAR file — a directory at a credentials path is * not a credential (existsSync alone would say yes). Never throws. */ function isRegularFile(file) { try { return statSync(file).isFile(); } catch { return false; } } /** * Google Vertex via Application Default Credentials. * * pi's google-vertex provider (packages/ai/src/providers/google-vertex.ts) needs * ALL THREE of a credentials FILE, a project and a location for the ADC/service- * account path — GOOGLE_APPLICATION_CREDENTIALS alone cannot authenticate. It * also falls back to gcloud's well-known ADC file when the var is unset, which we * now honour (that user IS ready and used to read as not). * * The two real corrections here: the referenced file must EXIST (a stale * GOOGLE_APPLICATION_CREDENTIALS pointing at a deleted key file used to green * pi), and project/location must be set. */ export function piVertexAdcUsable(home, procEnv = process.env, entryEnv) { // Vertex uses nullish (not truthy) per-field fallback. An explicitly-empty // stored project/path therefore blocks the ambient value just as it does in // pi; treating it as `||` would false-green a broken stored record. // google-vertex resolves the project as stored GOOGLE_CLOUD_PROJECT, ambient // GOOGLE_CLOUD_PROJECT, then ambient GCLOUD_PROJECT. GCLOUD_PROJECT is never // read from a stored auth record. In particular, an explicitly-empty stored // primary still blocks both ambient fallbacks under nullish semantics. const project = readNullishScoped(entryEnv, procEnv, "GOOGLE_CLOUD_PROJECT") ?? procEnv.GCLOUD_PROJECT; const location = readNullishScoped(entryEnv, procEnv, "GOOGLE_CLOUD_LOCATION"); if (!nonEmptyString(project) || !nonEmptyString(location)) return false; // NOT trimmed: pi uses the literal value, so a space-padded path that // resolves for us would not resolve for pi. const explicit = readNullishScoped(entryEnv, procEnv, "GOOGLE_APPLICATION_CREDENTIALS"); if (explicit !== undefined) { // Must point at a file that is actually there. A RELATIVE path resolves // against pi's cwd, which readiness cannot know — so it is unverifiable, and // "unverifiable" has to mean not-ready here or a dangling `missing.json` // greens pi. gcloud always writes an absolute path, so this costs nothing // real. return typeof explicit === "string" && isAbsolute(explicit) && isRegularFile(explicit); } // gcloud's well-known ADC file — pi's fallback is this literal POSIX path, so // we probe exactly that and no other location (a %APPDATA%\gcloud file would // green a credential pi does not look for). return isRegularFile(join(home, ".config", "gcloud", "application_default_credentials.json")); } /** * pi's agent config directory — where auth.json and models.json live. * * pi resolves this as `PI_CODING_AGENT_DIR` (with `~` expansion) falling back to * `<home>/.pi/agent` (packages/coding-agent/src/config.ts). The override is not a * secret, so buildAgentSpawnEnv passes it straight through to pi — meaning a user * who relocated their pi config was reading as "not signed in" while pi itself * found the credentials fine. */ export function piAgentDir(home, procEnv = process.env) { // NOT trimmed, for the same reason as GOOGLE_APPLICATION_CREDENTIALS above. const override = procEnv.PI_CODING_AGENT_DIR; if (override) { if (override === "~") return home; // pi expands `~\` only on Windows; on POSIX that string is a RELATIVE path // (a directory literally named "~\cfg"), so expanding it here would green a // config pi reads from somewhere else entirely. const tildeSlash = override.startsWith("~/") || (process.platform === "win32" && override.startsWith("~\\")); if (tildeSlash) return join(home, override.slice(2)); // A RELATIVE override resolves against pi's own cwd, which readiness cannot // know — so we cannot say whether a config lives there. Null (= don't read // file-backed credentials) rather than guessing against OUR cwd, which could // green a config pi will never see. Same stance as a relative // GOOGLE_APPLICATION_CREDENTIALS. return isAbsolute(override) ? override : null; } return join(home, ".pi", "agent"); } /** * The AWS sources accepted by pi's amazon-bedrock provider. Unlike ordinary * provider records, a keyless Bedrock `api_key` record deliberately falls * through to these sources (amazon-bedrock.ts): an explicit stored AWS_PROFILE, * an ambient profile, static access-key pair, ECS task role, or web identity. * Only the PROFILE comes from a stored `env` block. Every other AWS source is * read from ctx.env (the ambient spawn environment). */ function piBedrockCredentialUsable(entryEnv, procEnv) { if (nonEmptyString(procEnv.AWS_BEARER_TOKEN_BEDROCK)) return true; const storedProfile = entryEnv?.AWS_PROFILE; // `credential.env?.AWS_PROFILE ?? ctx.env("AWS_PROFILE")`: an explicitly // empty stored profile suppresses the ambient profile, but no stored property // (or null) falls through to it. const profile = storedProfile ?? procEnv.AWS_PROFILE; if (nonEmptyString(profile)) return true; if (nonEmptyString(procEnv.AWS_ACCESS_KEY_ID) && nonEmptyString(procEnv.AWS_SECRET_ACCESS_KEY)) return true; if (nonEmptyString(procEnv.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI)) return true; if (nonEmptyString(procEnv.AWS_CONTAINER_CREDENTIALS_FULL_URI)) return true; return nonEmptyString(procEnv.AWS_WEB_IDENTITY_TOKEN_FILE); } /** The effective provider selected by the pi spawn flags. `--provider` wins; * otherwise pi treats the prefix of `--model provider/model` as the provider. * A bare model has no reliable static provider mapping, so it retains pi's * normal default-selection behaviour rather than guessing. */ function configuredPiProvider(procEnv) { const explicit = procEnv.COMFYUI_MCP_PI_PROVIDER; if (explicit) return explicit; const model = procEnv.COMFYUI_MCP_PI_MODEL; const slash = model?.indexOf("/") ?? -1; return slash > 0 ? model.slice(0, slash) : undefined; } /** * Is any provider authenticated purely by an env var? * * Per pi's resolver, "a stored credential owns the provider: ambient/env is * consulted only when nothing is stored" — so a provider that HAS a record in * auth.json never falls back to its env key. If we got here the stored record was * not usable, which means that provider is dead and its env key must not green * pi. Every OTHER provider's env keys still count. */ function piEnvCredentialPresent(storedProviders, procEnv, onlyProvider) { const stripped = new Set(TOOL_ONLY_SECRET_ENV_KEYS); for (const [provider, keys] of Object.entries(PI_PROVIDER_ENV_KEYS)) { if (onlyProvider && provider !== onlyProvider) continue; const stored = storedProviders[provider]; // Pi's ordinary api-key helper falls back to ctx.env when its stored key is // absent, falsy, or cannot resolve. A record's mere existence must not hide // that same provider's working ambient key. const storedEnv = isPlainObject(stored) && isPlainObject(stored.env) ? stored.env : undefined; const storedTemplateResolvesTruthy = isPlainObject(stored) && typeof stored.key === "string" && envRefsIn(stored.key).every((name) => !!(storedEnv?.[name] || procEnv[name])); const storedCanSupplyKey = isPlainObject(stored) && stored.type === "api_key" && (credentialValueUsable(stored.key, storedEnv, procEnv) || storedTemplateResolvesTruthy); const storedIsOrdinaryApiKey = isPlainObject(stored) && stored.type === "api_key"; if (providerHasStoredCredential(storedProviders, provider) && (!storedIsOrdinaryApiKey || storedCanSupplyKey)) continue; if (!keys.some((k) => !stripped.has(k) && !!procEnv[k]?.trim())) continue; // Some providers need companion config alongside the key — without it pi's // resolver returns undefined and the key is useless on its own. const required = PI_PROVIDER_ENV_REQUIRED[provider]; if (required && !required.every((k) => !!procEnv[k]?.trim())) continue; return true; } return false; } /** Does auth.json carry a stored credential for `provider`? pi's resolver gates * on `if (stored)`, so a null/false entry is NOT a stored credential and the * ambient env is still consulted — only a truthy record takes ownership. */ function providerHasStoredCredential(storedProviders, provider) { return !!storedProviders[provider]; } /** * True when a pi provider credential is detectable from ANY source pi documents. * This — not the CLI's presence — is the honest "ready" signal for pi, and the * connect handler uses it too so a credential-less pi is degraded up front * instead of greeted green (#491). */ export function piCredentialPresent(home = homedir(), procEnv = process.env, nowMs = Date.now()) { const agentDir = piAgentDir(home, procEnv); // agentDir null = a relative PI_CODING_AGENT_DIR we cannot resolve; the // file-backed sources are unreadable, but env/ADC still apply. const authFile = agentDir ? join(agentDir, "auth.json") : null; // A `null` auth.json breaks pi's auth layer outright — nothing resolves, so no // source counts, not even an otherwise-good env key. if (authFile && piAuthStoreBroken(authFile)) return false; const stored = authFile ? piAuthRecords(authFile) : {}; const selectedProvider = configuredPiProvider(procEnv); if (selectedProvider) { const selected = stored[selectedProvider]; if (authRecordUsable(selected, procEnv, nowMs, { provider: selectedProvider, home })) return true; // Bedrock's own resolver intentionally consults the AWS chain even with a // keyless stored record; generic stored-record ownership does not apply. if (selectedProvider === "amazon-bedrock" && (!providerHasStoredCredential(stored, "amazon-bedrock") || (isPlainObject(selected) && selected.type === "api_key")) && piBedrockCredentialUsable(isPlainObject(selected) && isPlainObject(selected.env) ? selected.env : undefined, procEnv)) return true; if (piEnvCredentialPresent(stored, procEnv, selectedProvider)) return true; if (selectedProvider === "google-vertex" && !providerHasStoredCredential(stored, "google-vertex") && piVertexAdcUsable(home, procEnv)) return true; return !!agentDir && piModelsJsonUsable(join(agentDir, "models.json"), procEnv, selectedProvider); } if (Object.entries(stored).some(([provider, rec]) => authRecordUsable(rec, procEnv, nowMs, { provider, home }))) return true; const bedrockStored = stored["amazon-bedrock"]; // The Bedrock resolver is the one exception to ordinary stored-record // ownership: a valid api_key record with no `key` still falls through to the // AWS chain. An unrecognised/non-api-key stored record remains dead, as pi's // generic credential resolver never hands it to the provider. if ((!providerHasStoredCredential(stored, "amazon-bedrock") || (isPlainObject(bedrockStored) && bedrockStored.type === "api_key")) && piBedrockCredentialUsable(isPlainObject(bedrockStored) && isPlainObject(bedrockStored.env) ? bedrockStored.env : undefined, procEnv)) return true; if (piEnvCredentialPresent(stored, procEnv)) return true; // Vertex ADC is ambient credential too, so a stored google-vertex record owns // that provider and suppresses it (same rule as the env keys above). if (!providerHasStoredCredential(stored, "google-vertex") && piVertexAdcUsable(home, procEnv)) return true; if (agentDir && piModelsJsonUsable(join(agentDir, "models.json"), procEnv)) return true; return false; } //# sourceMappingURL=pi-credentials.js.map