fix(backend): reject compound provider credentials

This commit is contained in:
2026-07-12 08:10:47 +02:00
parent f064daef09
commit 2302286ea1
6 changed files with 115 additions and 4 deletions
+9 -4
View File
@@ -23,13 +23,9 @@ export const PI_0803_CREDENTIAL_ENV_NAMES = Object.freeze([
]);
const PROVIDER_KEY_ENV: Readonly<Record<string, string>> = {
"amazon-bedrock": "AWS_BEARER_TOKEN_BEDROCK",
"ant-ling": "ANT_LING_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
cerebras: "CEREBRAS_API_KEY",
"cloudflare-ai-gateway": "CLOUDFLARE_API_KEY",
"cloudflare-workers-ai": "CLOUDFLARE_API_KEY",
deepseek: "DEEPSEEK_API_KEY", fireworks: "FIREWORKS_API_KEY",
"github-copilot": "COPILOT_GITHUB_TOKEN", google: "GEMINI_API_KEY",
"google-vertex": "GOOGLE_CLOUD_API_KEY", groq: "GROQ_API_KEY", huggingface: "HF_TOKEN",
@@ -43,6 +39,9 @@ const PROVIDER_KEY_ENV: Readonly<Record<string, string>> = {
"xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY", zai: "ZAI_API_KEY",
"zai-coding-cn": "ZAI_CODING_CN_API_KEY",
};
const COMPOUND_PROVIDERS = new Set([
"amazon-bedrock", "azure-openai-responses", "cloudflare-ai-gateway", "cloudflare-workers-ai",
]);
const LOCAL_PROVIDERS = new Set(["ollama", "lmstudio", "local", "aritmolab", "faux"]);
export function canonicalPiProvider(provider: string | undefined): string | undefined {
@@ -109,6 +108,12 @@ export function buildPiChildEnv(opts: {
delete env.THT_MODEL_API_KEY_FILE;
for (const name of PI_0803_CREDENTIAL_ENV_NAMES) delete env[name];
const provider = canonicalPiProvider(opts.provider);
if (provider && COMPOUND_PROVIDERS.has(provider)) {
throw new Error(
"compound credential bundles are unsupported by THT_MODEL_API_KEY_FILE; "
+ "dedicated provider configuration is required",
);
}
if (provider && !LOCAL_PROVIDERS.has(provider)) {
const envName = PROVIDER_KEY_ENV[provider];
if (!envName || !opts.credentialFile) throw new Error("model provider credential is unavailable");
+22
View File
@@ -141,3 +141,25 @@ test("model-list spawn loads only the selected canonical provider credential", a
rmSync(path.dirname(script), { recursive: true, force: true });
}
});
test.each(["amazon-bedrock", "azure-openai-responses", "cloudflare-workers-ai", "cloudflare-ai-gateway"])(
"model listing rejects compound provider %s before spawning Pi", async (provider) => {
const script = scriptWith([]);
const secret = join(path.dirname(script), "model-key");
writeFileSync(secret, "selected-secret", { mode: 0o600 });
let spawns = 0;
const lister = createPiModelLister(loadConfig({
PI_PROVIDER: provider, THT_MODEL_API_KEY_FILE: secret,
}), {
spawnFn: () => { spawns += 1; throw new Error("must not spawn"); },
});
try {
await expect(lister()).rejects.toThrow(
"compound credential bundles are unsupported by THT_MODEL_API_KEY_FILE; dedicated provider configuration is required",
);
expect(spawns).toBe(0);
} finally {
rmSync(path.dirname(script), { recursive: true, force: true });
}
},
);
+19
View File
@@ -262,6 +262,25 @@ test("local providers spawn without a model key and scrub ambient generic creden
}
});
test.each(["amazon-bedrock", "azure-openai-responses", "cloudflare-workers-ai", "cloudflare-ai-gateway"])(
"session spawn rejects compound provider %s before spawning Pi", async (provider) => {
const secret = path.resolve(__dirname, `.compound-key-${process.pid}-${provider}`);
writeFileSync(secret, "provider-secret", { mode: 0o600 });
let spawns = 0;
const mgr = new PiProcessManager(loadConfig({ THT_MODEL_API_KEY_FILE: secret }), {
spawnFn: () => { spawns += 1; throw new Error("must not spawn"); },
});
try {
await expect(mgr.spawnFor("compound-provider", { provider })).rejects.toThrow(
"compound credential bundles are unsupported by THT_MODEL_API_KEY_FILE; dedicated provider configuration is required",
);
expect(spawns).toBe(0);
} finally {
await import("node:fs/promises").then((fs) => fs.unlink(secret));
}
},
);
test.each(["missing", "permissive", "unreadable", "directory", "symlink", "unsupported"])(
"hosted provider credential failure is sanitized: %s", async (kind) => {
const target = path.resolve(__dirname, `.bad-model-key-${process.pid}-${kind}`);
+41
View File
@@ -74,3 +74,44 @@ test("Docker secrets require a secure root-owned /run/secrets parent and 0444 fi
},
})).toThrow("model provider credential is unavailable");
});
test.each(["amazon-bedrock", "azure-openai-responses", "cloudflare-workers-ai", "cloudflare-ai-gateway"])(
"compound provider %s fails closed before opening the generic secret", (provider) => {
let opens = 0;
expect(() => buildPiChildEnv({
ambient: {}, provider, credentialFile: "/unused",
fsOps: {
lstat: () => { throw new Error("must not inspect file"); },
open: () => { opens += 1; return 9; },
fstat: () => { throw new Error("must not inspect file"); },
read: () => "secret", close: () => undefined,
},
})).toThrow(
"compound credential bundles are unsupported by THT_MODEL_API_KEY_FILE; dedicated provider configuration is required",
);
expect(opens).toBe(0);
},
);
test("single-key providers scrub ambient compound companions before injecting their key", () => {
const stat = {
dev: 7, ino: 1, uid: process.getuid?.() ?? 0, mode: 0o100600, nlink: 1, size: 6,
isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false,
};
const env = buildPiChildEnv({
ambient: {
AWS_ACCESS_KEY_ID: "ambient", AWS_SECRET_ACCESS_KEY: "ambient",
CLOUDFLARE_ACCOUNT_ID: "ambient", CLOUDFLARE_GATEWAY_ID: "ambient",
},
provider: "openai", credentialFile: "/safe/key",
fsOps: {
lstat: () => stat as any, open: () => 9, fstat: () => stat as any,
read: () => "secret", close: () => undefined,
},
});
expect(env.OPENAI_API_KEY).toBe("secret");
expect(env).not.toHaveProperty("AWS_ACCESS_KEY_ID");
expect(env).not.toHaveProperty("AWS_SECRET_ACCESS_KEY");
expect(env).not.toHaveProperty("CLOUDFLARE_ACCOUNT_ID");
expect(env).not.toHaveProperty("CLOUDFLARE_GATEWAY_ID");
});