Merge branch 'feat/per-provider-model-credentials'
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<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) {
|
||||
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 },
|
||||
|
||||
@@ -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<string>;
|
||||
}): 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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user