Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/opencode/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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<typeof Info>

const filepath = path.join(Global.Path.data, "auth.json")
Expand Down
75 changes: 69 additions & 6 deletions packages/opencode/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Copy link
Author

@jemjam jemjam Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is admittedly a bit "sloppy", I'm open to other approaches. Initially I just wanted to enable the command format in config. Adding the prompts to auth login required parsing user input though (that would invariably include some type of quote).

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",
Expand Down Expand Up @@ -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")
})
},
Expand Down
36 changes: 36 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down