feat(backend): let pi self-authenticate providers from its own auth store
The backend injects a single managed model key (THT_MODEL_API_KEY[_FILE]) as the selected provider's env var, but that key belongs to one provider — so selecting a second cloud provider (e.g. DeepSeek while the managed key is zai's) forced the wrong key onto it and failed auth. This is why the model could not be switched to DeepSeek. When the selected provider is present in pi's own auth store (~/.pi/agent/auth.json), skip injection and let pi resolve that provider's key itself. Deployments without an auth store (containers) yield an empty set, so the managed-key injection stays authoritative and fail-fast there. authProviders is injectable into PiProcessManager for deterministic tests. Verified live: GLM 5.2, DeepSeek V4 Flash, and aritmolab Qwen3.6 all operate through the ThothII model selector. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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