From f6a0d8d2f1e821c5d0463f3a5b299db46ab1ed9d Mon Sep 17 00:00:00 2001 From: Jam Bezooyen Date: Wed, 3 Sep 2025 10:53:00 -0700 Subject: [PATCH] feat(apikey): Allow apikeys loaded from commands --- packages/opencode/src/auth/index.ts | 9 ++- packages/opencode/src/cli/cmd/auth.ts | 75 ++++++++++++++++++++-- packages/opencode/src/provider/provider.ts | 36 +++++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index a091434381..fa14a2785d 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -20,6 +20,13 @@ export namespace Auth { }) .openapi({ ref: "ApiAuth" }) + export const Cmd = z + .object({ + type: z.literal("cmd"), + command: z.array(z.string()), + }) + .openapi({ ref: "CmdAuth" }) + export const WellKnown = z .object({ type: z.literal("wellknown"), @@ -28,7 +35,7 @@ export namespace Auth { }) .openapi({ ref: "WellKnownAuth" }) - export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).openapi({ ref: "Auth" }) + export const Info = z.discriminatedUnion("type", [Oauth, Api, Cmd, WellKnown]).openapi({ ref: "Auth" }) export type Info = z.infer const filepath = path.join(Global.Path.data, "auth.json") diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 382232f5ac..7d9af43218 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -10,6 +10,39 @@ import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" +// Simple command parser that handles quoted strings +function parseCommand(input: string): string[] { + const args: string[] = [] + let current = "" + let inQuotes = false + let quoteChar = "" + + for (let i = 0; i < input.length; i++) { + const char = input[i] + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true + quoteChar = char + } else if (inQuotes && char === quoteChar) { + inQuotes = false + quoteChar = "" + } else if (!inQuotes && char === " ") { + if (current) { + args.push(current) + current = "" + } + } else { + current += char + } + } + + if (current) { + args.push(current) + } + + return args.filter((arg) => arg.length > 0) +} + export const AuthCommand = cmd({ command: "auth", describe: "manage credentials", @@ -253,16 +286,46 @@ export const AuthLoginCommand = cmd({ prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") } - const key = await prompts.password({ - message: "Enter your API key", + const authMethod = await prompts.select({ + message: "How would you like to provide the API key?", + options: [ + { label: "Enter API key directly", value: "direct" }, + { label: "Run a command to get the API key", value: "command" }, + ], + }) + if (prompts.isCancel(authMethod)) throw new UI.CancelledError() + + if (authMethod === "direct") { + const key = await prompts.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(key)) throw new UI.CancelledError() + await Auth.set(provider, { + type: "api", + key, + }) + prompts.outro("Done") + return + } + + const commandInput = await prompts.text({ + message: "Enter the command to run (e.g., 'op read op://vault/item')", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(key)) throw new UI.CancelledError() + if (prompts.isCancel(commandInput)) throw new UI.CancelledError() + + // Parse command string into array with proper quote handling + const command = parseCommand(commandInput) + if (command.length === 0) { + prompts.log.error("Invalid command") + return + } + await Auth.set(provider, { - type: "api", - key, + type: "cmd", + command, }) - prompts.outro("Done") }) }, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 184c2c950c..dd38bf56c1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -256,7 +256,43 @@ export namespace Provider { if (disabled.has(providerID)) continue if (provider.type === "api") { mergeProvider(providerID, { apiKey: provider.key }, "api") + continue + } + if (provider.type !== "cmd") continue + + // Basic security validation + const command = provider.command[0] + if (!command || typeof command !== "string") { + log.error("invalid command", { providerID, command }) + continue + } + + // Prevent potentially dangerous commands + const dangerousCommands = ["rm", "del", "format", "fdisk", "mkfs", "dd", "shred"] + if (dangerousCommands.some((cmd) => command.includes(cmd))) { + log.error("command contains potentially dangerous operation", { providerID, command }) + continue + } + + log.info("executing command for", { providerID, command: provider.command.join(" ") }) + const proc = Bun.spawn({ + cmd: provider.command, + stdout: "pipe", + stderr: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + const stderr = await new Response(proc.stderr).text() + log.error("command failed", { providerID, exit, stderr }) + continue + } + const apiKey = (await new Response(proc.stdout).text()).trim() + if (!apiKey) { + log.error("command produced empty output", { providerID }) + continue } + mergeProvider(providerID, { apiKey }, "api") + log.info("command executed successfully", { providerID }) } // load custom