fix(backend): reject compound provider credentials
This commit is contained in:
@@ -167,6 +167,17 @@ recognized child variable (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `G
|
|||||||
`ZAI_API_KEY`). Neither the generic file path nor deprecated `PI_PROVIDER_API_KEY` is inherited by
|
`ZAI_API_KEY`). Neither the generic file path nor deprecated `PI_PROVIDER_API_KEY` is inherited by
|
||||||
Pi. Local providers such as Ollama require no model key.
|
Pi. Local providers such as Ollama require no model key.
|
||||||
|
|
||||||
|
`THT_MODEL_API_KEY_FILE` supports Pi providers whose authentication is exactly one key:
|
||||||
|
`ant-ling`, `anthropic`, `cerebras`, `deepseek`, `fireworks`, `github-copilot`, `google`
|
||||||
|
(including the `gemini` alias), `google-vertex` when using its API-key mode, `groq`,
|
||||||
|
`huggingface`, `kimi-coding`, `minimax`, `minimax-cn`, `mistral`, `moonshotai`,
|
||||||
|
`moonshotai-cn`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`, `together`,
|
||||||
|
`vercel-ai-gateway`, `xai`, the four `xiaomi*` providers, `zai`, and `zai-coding-cn`.
|
||||||
|
Compound providers are deliberately unsupported: `amazon-bedrock`, `azure-openai-responses`,
|
||||||
|
`cloudflare-workers-ai`, and `cloudflare-ai-gateway` require multiple credential/configuration
|
||||||
|
values. Selecting one fails before Pi starts; ambient AWS, Azure, and Cloudflare credentials are
|
||||||
|
still scrubbed. Supporting them requires a future dedicated provider-specific configuration.
|
||||||
|
|
||||||
## Reproducible image verification
|
## Reproducible image verification
|
||||||
|
|
||||||
Base images use exact tags and immutable multi-platform manifest digests. Dependency update and
|
Base images use exact tags and immutable multi-platform manifest digests. Dependency update and
|
||||||
|
|||||||
@@ -23,13 +23,9 @@ export const PI_0803_CREDENTIAL_ENV_NAMES = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const PROVIDER_KEY_ENV: Readonly<Record<string, string>> = {
|
const PROVIDER_KEY_ENV: Readonly<Record<string, string>> = {
|
||||||
"amazon-bedrock": "AWS_BEARER_TOKEN_BEDROCK",
|
|
||||||
"ant-ling": "ANT_LING_API_KEY",
|
"ant-ling": "ANT_LING_API_KEY",
|
||||||
anthropic: "ANTHROPIC_API_KEY",
|
anthropic: "ANTHROPIC_API_KEY",
|
||||||
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
|
|
||||||
cerebras: "CEREBRAS_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",
|
deepseek: "DEEPSEEK_API_KEY", fireworks: "FIREWORKS_API_KEY",
|
||||||
"github-copilot": "COPILOT_GITHUB_TOKEN", google: "GEMINI_API_KEY",
|
"github-copilot": "COPILOT_GITHUB_TOKEN", google: "GEMINI_API_KEY",
|
||||||
"google-vertex": "GOOGLE_CLOUD_API_KEY", groq: "GROQ_API_KEY", huggingface: "HF_TOKEN",
|
"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",
|
"xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY", zai: "ZAI_API_KEY",
|
||||||
"zai-coding-cn": "ZAI_CODING_CN_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"]);
|
const LOCAL_PROVIDERS = new Set(["ollama", "lmstudio", "local", "aritmolab", "faux"]);
|
||||||
|
|
||||||
export function canonicalPiProvider(provider: string | undefined): string | undefined {
|
export function canonicalPiProvider(provider: string | undefined): string | undefined {
|
||||||
@@ -109,6 +108,12 @@ export function buildPiChildEnv(opts: {
|
|||||||
delete env.THT_MODEL_API_KEY_FILE;
|
delete env.THT_MODEL_API_KEY_FILE;
|
||||||
for (const name of PI_0803_CREDENTIAL_ENV_NAMES) delete env[name];
|
for (const name of PI_0803_CREDENTIAL_ENV_NAMES) delete env[name];
|
||||||
const provider = canonicalPiProvider(opts.provider);
|
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)) {
|
if (provider && !LOCAL_PROVIDERS.has(provider)) {
|
||||||
const envName = PROVIDER_KEY_ENV[provider];
|
const envName = PROVIDER_KEY_ENV[provider];
|
||||||
if (!envName || !opts.credentialFile) throw new Error("model provider credential is unavailable");
|
if (!envName || !opts.credentialFile) throw new Error("model provider credential is unavailable");
|
||||||
|
|||||||
@@ -141,3 +141,25 @@ test("model-list spawn loads only the selected canonical provider credential", a
|
|||||||
rmSync(path.dirname(script), { recursive: true, force: true });
|
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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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"])(
|
test.each(["missing", "permissive", "unreadable", "directory", "symlink", "unsupported"])(
|
||||||
"hosted provider credential failure is sanitized: %s", async (kind) => {
|
"hosted provider credential failure is sanitized: %s", async (kind) => {
|
||||||
const target = path.resolve(__dirname, `.bad-model-key-${process.pid}-${kind}`);
|
const target = path.resolve(__dirname, `.bad-model-key-${process.pid}-${kind}`);
|
||||||
|
|||||||
@@ -74,3 +74,44 @@ test("Docker secrets require a secure root-owned /run/secrets parent and 0444 fi
|
|||||||
},
|
},
|
||||||
})).toThrow("model provider credential is unavailable");
|
})).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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ le chiavi di provider non selezionati e il vecchio `PI_PROVIDER_API_KEY` vengono
|
|||||||
del child. Provider locali come `ollama`, `lmstudio` e `aritmolab` continuano senza chiave; un provider
|
del child. Provider locali come `ollama`, `lmstudio` e `aritmolab` continuano senza chiave; un provider
|
||||||
hosted non mappato o un secret mancante/non sicuro fallisce prima dello spawn con errore sanitizzato.
|
hosted non mappato o un secret mancante/non sicuro fallisce prima dello spawn con errore sanitizzato.
|
||||||
|
|
||||||
|
La sorgente generica supporta soltanto provider con una singola chiave: `ant-ling`, `anthropic`,
|
||||||
|
`cerebras`, `deepseek`, `fireworks`, `github-copilot`, `google` (anche tramite alias `gemini`),
|
||||||
|
`google-vertex` in modalità API key, `groq`, `huggingface`, `kimi-coding`, `minimax`, `minimax-cn`,
|
||||||
|
`mistral`, `moonshotai`, `moonshotai-cn`, `nvidia`, `openai`, `opencode`, `opencode-go`,
|
||||||
|
`openrouter`, `together`, `vercel-ai-gateway`, `xai`, i quattro provider `xiaomi*`, `zai` e
|
||||||
|
`zai-coding-cn`.
|
||||||
|
|
||||||
|
I provider composti `amazon-bedrock`, `azure-openai-responses`, `cloudflare-workers-ai` e
|
||||||
|
`cloudflare-ai-gateway` non sono rappresentabili da un solo file. La selezione fallisce prima
|
||||||
|
dello spawn (anche durante l'elenco modelli); tutte le credenziali ambientali AWS, Azure e
|
||||||
|
Cloudflare restano comunque rimosse. Servirà una futura configurazione dedicata per provider per
|
||||||
|
supportare questi bundle senza ambiguità.
|
||||||
|
|
||||||
## I tre livelli di provenienza di un modello
|
## I tre livelli di provenienza di un modello
|
||||||
|
|
||||||
### 1. Built-in (compilato dentro Pi)
|
### 1. Built-in (compilato dentro Pi)
|
||||||
|
|||||||
Reference in New Issue
Block a user