Merge branch 'feat/per-provider-model-credentials'

This commit is contained in:
2026-07-17 19:31:06 +02:00
5 changed files with 120 additions and 2 deletions
+32
View File
@@ -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<string> {
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<string, unknown>)
.map((key) => key.trim().toLowerCase())
.filter((key) => key.length > 0),
);
} catch {
return new Set();
}
}
+8 -1
View File
@@ -4,6 +4,7 @@ import { RpcClient } from "../rpc/rpc-client.js";
import { SessionBridge } from "../bridge/session-bridge.js"; import { SessionBridge } from "../bridge/session-bridge.js";
import type { ThtRunner } from "../tht/tht-runner.js"; import type { ThtRunner } from "../tht/tht-runner.js";
import { buildPiChildEnv, canonicalPiProvider } from "./provider-credentials.js"; import { buildPiChildEnv, canonicalPiProvider } from "./provider-credentials.js";
import { loadPiAuthProviders } from "./auth-providers.js";
import { secretValue } from "../config/secret-bundle.js"; import { secretValue } from "../config/secret-bundle.js";
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js"; import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
@@ -35,8 +36,13 @@ export class PiProcessManager {
private spawnFn: ( private spawnFn: (
sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext, sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext,
) => ChildProcessWithoutNullStreams; ) => ChildProcessWithoutNullStreams;
private loadAuthProviders: () => ReadonlySet<string>;
constructor(private cfg: AppConfig, opts?: { spawnFn?: SpawnFn }) { constructor(
private cfg: AppConfig,
opts?: { spawnFn?: SpawnFn; authProviders?: () => ReadonlySet<string> },
) {
this.loadAuthProviders = opts?.authProviders ?? (() => loadPiAuthProviders());
if (opts?.spawnFn) { if (opts?.spawnFn) {
this.spawnFn = (sessionId, author, provider, principal) => this.spawnFn = (sessionId, author, provider, principal) =>
this.spawnPi(opts.spawnFn!, sessionId, author, provider, principal); this.spawnPi(opts.spawnFn!, sessionId, author, provider, principal);
@@ -51,6 +57,7 @@ export class PiProcessManager {
): ChildProcessWithoutNullStreams { ): ChildProcessWithoutNullStreams {
const env = buildPiChildEnv({ const env = buildPiChildEnv({
provider, provider,
authProviders: this.loadAuthProviders(),
credentialValue: secretValue(this.cfg, "THT_MODEL_API_KEY"), credentialValue: secretValue(this.cfg, "THT_MODEL_API_KEY"),
credentialFile: this.cfg.modelApiKeyFile, credentialFile: this.cfg.modelApiKeyFile,
additions: { THT_SESSION: sessionId, THT_AUTHOR: author }, additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
+10
View File
@@ -105,6 +105,13 @@ export function buildPiChildEnv(opts: {
additions?: NodeJS.ProcessEnv; additions?: NodeJS.ProcessEnv;
credentialValue?: string; credentialValue?: string;
fsOps?: CredentialFsOps; 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<string>;
}): NodeJS.ProcessEnv { }): NodeJS.ProcessEnv {
const env = { ...(opts.ambient ?? process.env), ...opts.additions }; const env = { ...(opts.ambient ?? process.env), ...opts.additions };
delete env.PI_PROVIDER_API_KEY; delete env.PI_PROVIDER_API_KEY;
@@ -145,6 +152,9 @@ export function buildPiChildEnv(opts: {
); );
} }
if (provider && !LOCAL_PROVIDERS.has(provider)) { 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]; const envName = PROVIDER_KEY_ENV[provider];
if (!envName || (!opts.credentialFile && opts.credentialValue === undefined)) { if (!envName || (!opts.credentialFile && opts.credentialValue === undefined)) {
throw new Error("model provider credential is unavailable"); throw new Error("model provider credential is unavailable");
+41
View File
@@ -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");
});
+29 -1
View File
@@ -306,7 +306,12 @@ test.each([
child.stderr.resume = () => {}; child.stderr.resume = () => {};
const mgr = new PiProcessManager(loadConfig({ const mgr = new PiProcessManager(loadConfig({
PI_BIN: "/usr/local/bin/pi", THT_MODEL_API_KEY_FILE: secret, 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 { try {
await mgr.spawnFor("credential-session", { provider }); await mgr.spawnFor("credential-session", { provider });
const env = calls[0][2].env; 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 () => { test("session Pi spawn reads the single secret bundle and scrubs its path", async () => {
const secret = path.resolve(__dirname, `.bundle-${process.pid}`); const secret = path.resolve(__dirname, `.bundle-${process.pid}`);
writeFileSync(secret, "THT_MODEL_API_KEY=bundle-secret\n", { mode: 0o600 }); writeFileSync(secret, "THT_MODEL_API_KEY=bundle-secret\n", { mode: 0o600 });