diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index a0914343..aab4192a 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -2,8 +2,12 @@ import path from "path" import { Global } from "../global" import fs from "fs/promises" import { z } from "zod" +import { Log } from "../util/log" +import { App } from "../app/app" export namespace Auth { + const log = Log.create({ service: "auth" }) + export const Oauth = z .object({ type: z.literal("oauth"), @@ -28,11 +32,22 @@ export namespace Auth { }) .openapi({ ref: "WellKnownAuth" }) - export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).openapi({ ref: "Auth" }) + export const Helper = z.object({ + type: z.literal("helper"), + command: z.array(z.string()), + refreshInterval: z.number().default(3600), // 1 hour default + timeout: z.number().default(5000), // 5 seconds default + lastFetched: z.number().optional(), + cachedKey: z.string().optional(), + }) + + export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown, Helper]).openapi({ ref: "Auth" }) export type Info = z.infer const filepath = path.join(Global.Path.data, "auth.json") + const helperCache = new Map() + export async function get(providerID: string) { const file = Bun.file(filepath) return file @@ -60,4 +75,74 @@ export namespace Auth { await Bun.write(file, JSON.stringify(data, null, 2)) await fs.chmod(file.name!, 0o600) } + + export async function executeHelper(providerID: string, helper: z.infer): Promise { + const now = Date.now() + const cacheKey = `${providerID}-${JSON.stringify(helper.command)}` + + const cached = helperCache.get(cacheKey) + if (cached && cached.expires > now) { + log.debug("using cached helper result", { providerID }) + return cached.key + } + + if (helper.cachedKey && helper.lastFetched && now - helper.lastFetched < helper.refreshInterval * 1000) { + log.debug("using stored helper result", { providerID }) + helperCache.set(cacheKey, { + key: helper.cachedKey, + expires: helper.lastFetched + helper.refreshInterval * 1000, + }) + return helper.cachedKey + } + + try { + log.info("executing helper command", { providerID, command: helper.command }) + + const process = Bun.spawn({ + cmd: helper.command, + cwd: App.info().path.cwd, + timeout: helper.timeout, + stdout: "pipe", + stderr: "pipe", + }) + + await process.exited + + if (process.exitCode !== 0) { + const stderr = await new Response(process.stderr).text() + log.error("helper command failed", { providerID, exitCode: process.exitCode, stderr }) + return undefined + } + + const stdout = await new Response(process.stdout).text() + const apiKey = stdout.trim() + + if (!apiKey) { + log.error("helper command returned empty result", { providerID }) + return undefined + } + + const updatedHelper: z.infer = { + ...helper, + cachedKey: apiKey, + lastFetched: now, + } + + await set(providerID, updatedHelper) + + helperCache.set(cacheKey, { + key: apiKey, + expires: now + helper.refreshInterval * 1000, + }) + + log.info("helper command executed successfully", { providerID }) + return apiKey + } catch (error) { + log.error("helper command execution failed", { + providerID, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } + } } diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index ab06d5bf..399a2c71 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -19,6 +19,42 @@ export const AuthCommand = cmd({ async handler() {}, }) +/** + * Handle command parsing for helper auth allowing for quoted arguments, and spaces in arguments, example: + * `op read "op:///OpenAI API Key/credential"` will be parsed as `["op", "read", "op:///OpenAI API Key/credential"]` + */ +function parseCommand(commandStr: string): string[] { + const args: string[] = [] + let current = "" + let inQuotes = false + let quoteChar = "" + + for (let i = 0; i < commandStr.length; i++) { + const char = commandStr[i] + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true + quoteChar = char + } else if (inQuotes && char === quoteChar) { + inQuotes = false + quoteChar = "" + } else if (!inQuotes && /\s/.test(char)) { + if (current) { + args.push(current) + current = "" + } + } else { + current += char + } + } + + if (current) { + args.push(current) + } + + return args +} + export const AuthListCommand = cmd({ command: "list", aliases: ["ls"], @@ -236,6 +272,66 @@ export const AuthLoginCommand = cmd({ prompts.log.info("You can create an api key in the dashboard") } + const authMethod = await prompts.select({ + message: "How would you like to authenticate?", + options: [ + { + value: "api", + label: "API Key", + hint: "Enter a static API key", + }, + { + value: "helper", + label: "Dynamic API Key (Helper Script)", + hint: "Use a script to generate API keys dynamically", + }, + ], + }) + if (prompts.isCancel(authMethod)) throw new UI.CancelledError() + + if (authMethod === "helper") { + prompts.log.info("Configure a script that outputs an API key to stdout") + + const command = await prompts.text({ + message: "Enter the command to execute (space-separated)", + placeholder: "./get-token.sh or python get-token.py", + validate: (x) => (x && x.trim().length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(command)) throw new UI.CancelledError() + + const refreshInterval = await prompts.text({ + message: "Refresh interval in seconds", + initialValue: "3600", + validate: (x) => { + if (!x) return "Required" + const num = parseInt(x) + return num > 0 ? undefined : "Must be a positive number" + }, + }) + if (prompts.isCancel(refreshInterval)) throw new UI.CancelledError() + + const timeout = await prompts.text({ + message: "Command timeout in milliseconds", + initialValue: "5000", + validate: (x) => { + if (!x) return "Required" + const num = parseInt(x) + return num >= 100 && num <= 30000 ? undefined : "Must be between 100 and 30000" + }, + }) + if (prompts.isCancel(timeout)) throw new UI.CancelledError() + + await Auth.set(provider, { + type: "helper", + command: parseCommand(command as string), + refreshInterval: parseInt(refreshInterval as string), + timeout: parseInt(timeout as string), + }) + + prompts.outro("Helper authentication configured") + return + } + const key = await prompts.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a41b445d..2665717a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -298,6 +298,28 @@ export namespace Config { }) export type Layout = z.infer + export const ApiKeyHelper = z + .object({ + command: z + .array(z.string()) + .describe("Command to execute to retrieve the API key. Should output the key to stdout."), + refreshInterval: z + .number() + .min(1) + .default(3600) + .describe("How often to refresh the API key in seconds (default: 3600 = 1 hour)"), + timeout: z + .number() + .min(100) + .max(30000) + .default(5000) + .describe("Timeout for the helper command in milliseconds (default: 5000ms)"), + }) + .describe( + "Configuration for dynamic API key generation via external script. The command should output the API key to stdout.", + ) + export type ApiKeyHelper = z.infer + export const Info = z .object({ $schema: z.string().optional().describe("JSON schema reference for configuration validation"), @@ -351,7 +373,10 @@ export namespace Config { models: z.record(ModelsDev.Model.partial()).optional(), options: z .object({ - apiKey: z.string().optional(), + apiKey: z + .union([z.string().describe("Static API key"), ApiKeyHelper]) + .optional() + .describe("API key configuration - either a static string or helper script configuration"), baseURL: z.string().optional(), }) .catchall(z.any()) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 42bb1804..319e6a7a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -22,7 +22,7 @@ export namespace Provider { options?: Record }> - type Source = "env" | "config" | "custom" | "api" + type Source = "env" | "config" | "custom" | "api" | "helper" const CUSTOM_LOADERS: Record = { async anthropic() { @@ -248,6 +248,14 @@ export namespace Provider { if (provider.type === "api") { mergeProvider(providerID, { apiKey: provider.key }, "api") } + if (provider.type === "helper") { + const apiKey = await Auth.executeHelper(providerID, provider) + if (apiKey) { + mergeProvider(providerID, { apiKey }, "helper") + } else { + log.warn("helper auth failed, skipping provider", { providerID }) + } + } } // load custom @@ -272,7 +280,38 @@ export namespace Provider { // load config for (const [providerID, provider] of configProviders) { - mergeProvider(providerID, provider.options ?? {}, "config") + const options = { ...provider.options } + + if (isApiKeyHelper(options.apiKey)) { + const helperConfig = options.apiKey + try { + const helperAuth: z.infer = { + type: "helper", + command: helperConfig.command, + refreshInterval: helperConfig.refreshInterval ?? 3600, + timeout: helperConfig.timeout ?? 5000, + } + + await Auth.set(providerID, helperAuth) + + const apiKey = await Auth.executeHelper(providerID, helperAuth) + if (apiKey) { + options.apiKey = apiKey + } else { + log.warn("apiKey helper failed for provider, no API key available", { providerID }) + delete options.apiKey + } + } catch (error) { + log.error("failed to process apiKey helper configuration", { + providerID, + error: error instanceof Error ? error.message : String(error), + command: helperConfig.command.join(" "), + }) + delete options.apiKey + } + } + + mergeProvider(providerID, options, "config") } for (const [providerID, provider] of Object.entries(providers)) { @@ -290,6 +329,10 @@ export namespace Provider { } }) + function isApiKeyHelper(apiKey: any): apiKey is Config.ApiKeyHelper { + return apiKey !== null && typeof apiKey === "object" && Array.isArray(apiKey.command) && apiKey.command.length > 0 + } + export async function list() { return state().then((state) => state.providers) } diff --git a/packages/opencode/test/auth/helper.test.ts b/packages/opencode/test/auth/helper.test.ts new file mode 100644 index 00000000..28e4dafb --- /dev/null +++ b/packages/opencode/test/auth/helper.test.ts @@ -0,0 +1,118 @@ +import "zod-openapi/extend" +import { test, expect, beforeEach, afterEach } from "bun:test" +import { Auth } from "../../src/auth" +import { App } from "../../src/app/app" +import fs from "fs/promises" +import path from "path" +import { Global } from "../../src/global" +import { z } from "zod" + +const testDir = path.join(__dirname, "temp") + +let originalDataPath: string + +beforeEach(async () => { + await fs.mkdir(testDir, { recursive: true }) + + // Mock Global.Path.data to isolate auth storage for testing + originalDataPath = Global.Path.data + ;(Global.Path as any).data = testDir +}) + +afterEach(async () => { + // Restore original global path + ;(Global.Path as any).data = originalDataPath + + await fs.rm(testDir, { recursive: true, force: true }) +}) + +test("helper auth executes command and caches result", async () => { + await App.provide({ cwd: testDir }, async () => { + const scriptPath = path.join(testDir, "get-key.sh") + await fs.writeFile(scriptPath, '#!/bin/bash\necho "test-api-key-123"', { mode: 0o755 }) + + const helperConfig: z.infer = { + type: "helper", + command: ["bash", scriptPath], + refreshInterval: 3600, + timeout: 5000, + } + + const apiKey = await Auth.executeHelper("test-provider", helperConfig) + + expect(apiKey).toBe("test-api-key-123") + + const stored = await Auth.get("test-provider") + expect(stored?.type).toBe("helper") + if (stored?.type === "helper") { + expect(stored.cachedKey).toBe("test-api-key-123") + expect(stored.lastFetched).toBeGreaterThan(Date.now() - 1000) + } + }) +}) + +test("helper auth uses cached result within refresh interval", async () => { + await App.provide({ cwd: testDir }, async () => { + const helperConfig: z.infer = { + type: "helper", + command: ["echo", "should-not-be-called"], + refreshInterval: 3600, + timeout: 5000, + cachedKey: "cached-api-key", + lastFetched: Date.now() - 1000, // 1 second ago, well within refresh interval + } + + const apiKey = await Auth.executeHelper("cached-test-provider", helperConfig) + expect(apiKey).toBe("cached-api-key") + }) +}) + +test("helper auth re-executes command when cache expires", async () => { + await App.provide({ cwd: testDir }, async () => { + const scriptPath = path.join(testDir, "fresh-key.sh") + await fs.writeFile(scriptPath, '#!/bin/bash\necho "fresh-api-key"', { mode: 0o755 }) + + const helperConfig: z.infer = { + type: "helper", + command: ["bash", scriptPath], + refreshInterval: 1, + timeout: 5000, + cachedKey: "old-api-key", + lastFetched: Date.now() - 2000, // 2 seconds ago, past refresh interval + } + + const apiKey = await Auth.executeHelper("expired-test-provider", helperConfig) + expect(apiKey).toBe("fresh-api-key") + }) +}) + +test("helper auth handles command failure gracefully", async () => { + await App.provide({ cwd: testDir }, async () => { + const helperConfig: z.infer = { + type: "helper", + command: ["false"], + refreshInterval: 3600, + timeout: 5000, + } + + const apiKey = await Auth.executeHelper("test-provider", helperConfig) + expect(apiKey).toBeUndefined() + }) +}) + +test("helper auth handles command timeout", async () => { + await App.provide({ cwd: testDir }, async () => { + const scriptPath = path.join(testDir, "slow-script.sh") + await fs.writeFile(scriptPath, '#!/bin/bash\nsleep 2\necho "too-slow"', { mode: 0o755 }) + + const helperConfig: z.infer = { + type: "helper", + command: ["bash", scriptPath], + refreshInterval: 3600, + timeout: 500, + } + + const apiKey = await Auth.executeHelper("timeout-test-provider", helperConfig) + expect(apiKey).toBeUndefined() + }) +}, 5000) diff --git a/packages/web/src/content/docs/docs/config.mdx b/packages/web/src/content/docs/docs/config.mdx index 06eb6ee7..ac1bc4c0 100644 --- a/packages/web/src/content/docs/docs/config.mdx +++ b/packages/web/src/content/docs/docs/config.mdx @@ -339,3 +339,95 @@ These are useful for: - Keeping sensitive data like API keys in separate files. - Including large instruction files without cluttering your config. - Sharing common configuration snippets across multiple config files. + +## Providers + +You can configure providers and their models in the `provider` section: + +```json +{ + "provider": { + "anthropic": { + "options": { + "apiKey": "your-api-key", + "baseURL": "https://api.anthropic.com" + }, + "models": { + "claude-3-5-sonnet-20241022": { + "name": "Claude 3.5 Sonnet (Custom)" + } + } + } + } +} +``` + +### Dynamic API Keys with Helper Scripts + +You can configure dynamic API keys that are generated by external scripts by using an object configuration for the `apiKey` field. This is useful for integrating with systems that use rotating tokens or require authentication workflows: + +```json +{ + "provider": { + "litellm": { + "options": { + "baseURL": "https://your-litellm-proxy.com/v1", + "apiKey": { + "command": ["./scripts/get-token.sh"], + "refreshInterval": 3600, + "timeout": 5000 + } + }, + "models": { + "gpt-4": {} + } + } + } +} +``` + +The `apiKey` field supports two formats: + +**Static API Key:** + +```json +{ + "apiKey": "your-static-api-key-here" +} +``` + +**Dynamic API Key (Helper Script):** + +```json +{ + "apiKey": { + "command": ["./scripts/get-token.sh"], + "refreshInterval": 3600, + "timeout": 5000 + } +} +``` + +The helper configuration supports: + +- **`command`** (required): Array of command and arguments to execute. The script should output the API key to stdout. +- **`refreshInterval`** (optional): How often to refresh the API key in seconds. Default: 3600 (1 hour). +- **`timeout`** (optional): Command timeout in milliseconds. Default: 5000ms. + +Example helper script (`get-token.sh`): + +```bash +#!/bin/bash +# Example script that fetches a token from an authentication service +curl -s -X POST https://auth.example.com/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"'$CLIENT_ID'","client_secret":"'$CLIENT_SECRET'"}' \ + | jq -r '.access_token' +``` + +You can also configure helper authentication via the CLI: + +```bash +opencode auth login +# Select your provider, then choose "Dynamic API Key (Helper Script)" +```