diff --git a/README.md b/README.md index d6d86547..69d7fbf1 100644 --- a/README.md +++ b/README.md @@ -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 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 Base images use exact tags and immutable multi-platform manifest digests. Dependency update and diff --git a/backend/src/pi/provider-credentials.ts b/backend/src/pi/provider-credentials.ts index 66cbde7b..a2791c86 100644 --- a/backend/src/pi/provider-credentials.ts +++ b/backend/src/pi/provider-credentials.ts @@ -23,13 +23,9 @@ export const PI_0803_CREDENTIAL_ENV_NAMES = Object.freeze([ ]); const PROVIDER_KEY_ENV: Readonly> = { - "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> = { "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"); diff --git a/backend/test/list-models.test.ts b/backend/test/list-models.test.ts index f8cef6bd..a86efab7 100644 --- a/backend/test/list-models.test.ts +++ b/backend/test/list-models.test.ts @@ -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 }); + } + }, +); diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts index 6ec5b6c5..43cb38d0 100644 --- a/backend/test/pi-process-manager.test.ts +++ b/backend/test/pi-process-manager.test.ts @@ -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}`); diff --git a/backend/test/provider-credentials.test.ts b/backend/test/provider-credentials.test.ts index cd484a49..68f08dc6 100644 --- a/backend/test/provider-credentials.test.ts +++ b/backend/test/provider-credentials.test.ts @@ -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"); +}); diff --git a/docs/general/pi-configuration.md b/docs/general/pi-configuration.md index 9c4beeca..ecc2606b 100644 --- a/docs/general/pi-configuration.md +++ b/docs/general/pi-configuration.md @@ -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 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 ### 1. Built-in (compilato dentro Pi)