diff --git a/backend/src/pi/auth-providers.ts b/backend/src/pi/auth-providers.ts new file mode 100644 index 00000000..56520db0 --- /dev/null +++ b/backend/src/pi/auth-providers.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Providers pi can authenticate on its own from `~/.pi/agent/auth.json`. + * + * The backend injects a single managed model key (`THT_MODEL_API_KEY[_FILE]`), + * which belongs to exactly one provider. Forcing that one key onto a different + * provider's credential variable breaks its auth. So when the selected provider + * is present in pi's own auth store, the backend skips injection and lets pi + * resolve that provider's key itself. An absent/malformed store (e.g. a + * containerized deployment that ships no auth.json) yields an empty set, which + * keeps the managed-key path authoritative there. + */ +export function loadPiAuthProviders( + opts: { agentDir?: string; read?: (path: string) => string } = {}, +): Set { + const agentDir = opts.agentDir ?? join(homedir(), ".pi", "agent"); + const read = opts.read ?? ((path: string) => readFileSync(path, "utf8")); + try { + const raw: unknown = JSON.parse(read(join(agentDir, "auth.json"))); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return new Set(); + return new Set( + Object.keys(raw as Record) + .map((key) => key.trim().toLowerCase()) + .filter((key) => key.length > 0), + ); + } catch { + return new Set(); + } +} diff --git a/backend/src/pi/pi-process-manager.ts b/backend/src/pi/pi-process-manager.ts index c9aa47f3..d7be6c22 100644 --- a/backend/src/pi/pi-process-manager.ts +++ b/backend/src/pi/pi-process-manager.ts @@ -4,6 +4,7 @@ import { RpcClient } from "../rpc/rpc-client.js"; import { SessionBridge } from "../bridge/session-bridge.js"; import type { ThtRunner } from "../tht/tht-runner.js"; import { buildPiChildEnv, canonicalPiProvider } from "./provider-credentials.js"; +import { loadPiAuthProviders } from "./auth-providers.js"; import { secretValue } from "../config/secret-bundle.js"; import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js"; @@ -35,8 +36,13 @@ export class PiProcessManager { private spawnFn: ( sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext, ) => ChildProcessWithoutNullStreams; + private loadAuthProviders: () => ReadonlySet; - constructor(private cfg: AppConfig, opts?: { spawnFn?: SpawnFn }) { + constructor( + private cfg: AppConfig, + opts?: { spawnFn?: SpawnFn; authProviders?: () => ReadonlySet }, + ) { + this.loadAuthProviders = opts?.authProviders ?? (() => loadPiAuthProviders()); if (opts?.spawnFn) { this.spawnFn = (sessionId, author, provider, principal) => this.spawnPi(opts.spawnFn!, sessionId, author, provider, principal); @@ -51,6 +57,7 @@ export class PiProcessManager { ): ChildProcessWithoutNullStreams { const env = buildPiChildEnv({ provider, + authProviders: this.loadAuthProviders(), credentialValue: secretValue(this.cfg, "THT_MODEL_API_KEY"), credentialFile: this.cfg.modelApiKeyFile, additions: { THT_SESSION: sessionId, THT_AUTHOR: author }, diff --git a/backend/src/pi/provider-credentials.ts b/backend/src/pi/provider-credentials.ts index ad57883f..dfac758e 100644 --- a/backend/src/pi/provider-credentials.ts +++ b/backend/src/pi/provider-credentials.ts @@ -105,6 +105,13 @@ export function buildPiChildEnv(opts: { additions?: NodeJS.ProcessEnv; credentialValue?: string; fsOps?: CredentialFsOps; + /** + * Providers pi can authenticate from its own auth store. For these, the single + * managed key is NOT injected (it belongs to one provider and would misauth the + * others); pi resolves the key itself. Empty/absent in deployments without a pi + * auth store, keeping the managed-key path authoritative there. + */ + authProviders?: ReadonlySet; }): NodeJS.ProcessEnv { const env = { ...(opts.ambient ?? process.env), ...opts.additions }; delete env.PI_PROVIDER_API_KEY; @@ -145,6 +152,9 @@ export function buildPiChildEnv(opts: { ); } if (provider && !LOCAL_PROVIDERS.has(provider)) { + // pi self-authenticates this provider from its own auth store; injecting the + // single managed key here would force one provider's key onto another. + if (opts.authProviders?.has(provider)) return env; const envName = PROVIDER_KEY_ENV[provider]; if (!envName || (!opts.credentialFile && opts.credentialValue === undefined)) { throw new Error("model provider credential is unavailable"); diff --git a/backend/test/auth-providers.test.ts b/backend/test/auth-providers.test.ts new file mode 100644 index 00000000..82ef63db --- /dev/null +++ b/backend/test/auth-providers.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "vitest"; +import { loadPiAuthProviders } from "../src/pi/auth-providers.js"; +import { buildPiChildEnv } from "../src/pi/provider-credentials.js"; + +test("loadPiAuthProviders returns the lowercased provider keys from the pi auth store", () => { + const read = () => JSON.stringify({ zai: { type: "api-key", key: "z" }, DeepSeek: { key: "d" } }); + expect(loadPiAuthProviders({ agentDir: "/agent", read })).toEqual(new Set(["zai", "deepseek"])); +}); + +test("loadPiAuthProviders is empty when the auth store is absent or malformed", () => { + const enoent = () => { throw Object.assign(new Error("nope"), { code: "ENOENT" }); }; + expect(loadPiAuthProviders({ read: enoent }).size).toBe(0); + expect(loadPiAuthProviders({ read: () => "not json" }).size).toBe(0); + expect(loadPiAuthProviders({ read: () => "[]" }).size).toBe(0); +}); + +test("a provider in the pi auth store gets no injected key and never opens the managed file", () => { + // The single managed key belongs to one provider; forcing it onto another + // provider's credential variable breaks auth. When pi can self-authenticate, + // the backend must skip injection entirely — and must not read the managed file. + const env = buildPiChildEnv({ + ambient: { DEEPSEEK_API_KEY: "stale" }, + provider: "deepseek", + authProviders: new Set(["deepseek"]), + credentialFile: "/managed/zai-key", + fsOps: { + lstat: () => { throw new Error("must not stat the managed key file"); }, + open: () => { throw new Error("must not open the managed key file"); }, + fstat: () => { throw new Error("unreachable"); }, + read: () => "zai-key", + close: () => undefined, + }, + }); + expect(env.DEEPSEEK_API_KEY).toBeUndefined(); +}); + +test("a non-local provider absent from the auth store still requires a managed credential", () => { + expect(() => buildPiChildEnv({ + ambient: {}, provider: "deepseek", authProviders: new Set(["zai"]), + })).toThrow("model provider credential is unavailable"); +}); diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts index f5aa1c9e..ba296b19 100644 --- a/backend/test/pi-process-manager.test.ts +++ b/backend/test/pi-process-manager.test.ts @@ -306,7 +306,12 @@ test.each([ child.stderr.resume = () => {}; const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi", THT_MODEL_API_KEY_FILE: secret, - }), { spawnFn: (...args: any[]) => { calls.push(args); return child as any; } }); + }), { + spawnFn: (...args: any[]) => { calls.push(args); return child as any; }, + // Empty auth store: exercise the managed-key injection path deterministically, + // independent of whatever providers the developer's ~/.pi/agent/auth.json holds. + authProviders: () => new Set(), + }); try { await mgr.spawnFor("credential-session", { provider }); const env = calls[0][2].env; @@ -320,6 +325,29 @@ test.each([ } }); +test("skips managed-key injection for a provider present in pi's auth store", async () => { + const secret = path.resolve(__dirname, `.model-key-${process.pid}-authskip`); + writeFileSync(secret, "provider-secret", { mode: 0o600 }); + const calls: any[][] = []; + const child = recordingChild(); + child.stderr.resume = () => {}; + const mgr = new PiProcessManager(loadConfig({ + PI_BIN: "/usr/local/bin/pi", THT_MODEL_API_KEY_FILE: secret, + }), { + spawnFn: (...args: any[]) => { calls.push(args); return child as any; }, + authProviders: () => new Set(["deepseek"]), + }); + try { + await mgr.spawnFor("authskip-session", { provider: "deepseek" }); + const env = calls[0][2].env; + // pi resolves deepseek from its own auth store, so no key is forced onto it. + expect(env.DEEPSEEK_API_KEY).toBeUndefined(); + } finally { + mgr.teardown("authskip-session"); + await import("node:fs/promises").then((fs) => fs.unlink(secret)); + } +}); + test("session Pi spawn reads the single secret bundle and scrubs its path", async () => { const secret = path.resolve(__dirname, `.bundle-${process.pid}`); writeFileSync(secret, "THT_MODEL_API_KEY=bundle-secret\n", { mode: 0o600 });