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 { 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 },
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 = () => {};
|
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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user