Skip to content

Commit ac9a9c1

Browse files
author
Jam Bezooyen
committed
feat(apikey): Allow apikeys loaded from commands
1 parent 4e24e04 commit ac9a9c1

File tree

3 files changed

+113
-7
lines changed

3 files changed

+113
-7
lines changed

packages/opencode/src/auth/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export namespace Auth {
2020
})
2121
.openapi({ ref: "ApiAuth" })
2222

23+
export const Cmd = z
24+
.object({
25+
type: z.literal("cmd"),
26+
command: z.array(z.string()),
27+
})
28+
.openapi({ ref: "CmdAuth" })
29+
2330
export const WellKnown = z
2431
.object({
2532
type: z.literal("wellknown"),
@@ -28,7 +35,7 @@ export namespace Auth {
2835
})
2936
.openapi({ ref: "WellKnownAuth" })
3037

31-
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).openapi({ ref: "Auth" })
38+
export const Info = z.discriminatedUnion("type", [Oauth, Api, Cmd, WellKnown]).openapi({ ref: "Auth" })
3239
export type Info = z.infer<typeof Info>
3340

3441
const filepath = path.join(Global.Path.data, "auth.json")

packages/opencode/src/cli/cmd/auth.ts

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,39 @@ import { Global } from "../../global"
1010
import { Plugin } from "../../plugin"
1111
import { Instance } from "../../project/instance"
1212

13+
// Simple command parser that handles quoted strings
14+
function parseCommand(input: string): string[] {
15+
const args: string[] = []
16+
let current = ""
17+
let inQuotes = false
18+
let quoteChar = ""
19+
20+
for (let i = 0; i < input.length; i++) {
21+
const char = input[i]
22+
23+
if (!inQuotes && (char === '"' || char === "'")) {
24+
inQuotes = true
25+
quoteChar = char
26+
} else if (inQuotes && char === quoteChar) {
27+
inQuotes = false
28+
quoteChar = ""
29+
} else if (!inQuotes && char === " ") {
30+
if (current) {
31+
args.push(current)
32+
current = ""
33+
}
34+
} else {
35+
current += char
36+
}
37+
}
38+
39+
if (current) {
40+
args.push(current)
41+
}
42+
43+
return args.filter((arg) => arg.length > 0)
44+
}
45+
1346
export const AuthCommand = cmd({
1447
command: "auth",
1548
describe: "manage credentials",
@@ -253,16 +286,46 @@ export const AuthLoginCommand = cmd({
253286
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
254287
}
255288

256-
const key = await prompts.password({
257-
message: "Enter your API key",
289+
const authMethod = await prompts.select({
290+
message: "How would you like to provide the API key?",
291+
options: [
292+
{ label: "Enter API key directly", value: "direct" },
293+
{ label: "Run a command to get the API key", value: "command" },
294+
],
295+
})
296+
if (prompts.isCancel(authMethod)) throw new UI.CancelledError()
297+
298+
if (authMethod === "direct") {
299+
const key = await prompts.password({
300+
message: "Enter your API key",
301+
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
302+
})
303+
if (prompts.isCancel(key)) throw new UI.CancelledError()
304+
await Auth.set(provider, {
305+
type: "api",
306+
key,
307+
})
308+
prompts.outro("Done")
309+
return
310+
}
311+
312+
const commandInput = await prompts.text({
313+
message: "Enter the command to run (e.g., 'op read op://vault/item')",
258314
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
259315
})
260-
if (prompts.isCancel(key)) throw new UI.CancelledError()
316+
if (prompts.isCancel(commandInput)) throw new UI.CancelledError()
317+
318+
// Parse command string into array with proper quote handling
319+
const command = parseCommand(commandInput)
320+
if (command.length === 0) {
321+
prompts.log.error("Invalid command")
322+
return
323+
}
324+
261325
await Auth.set(provider, {
262-
type: "api",
263-
key,
326+
type: "cmd",
327+
command,
264328
})
265-
266329
prompts.outro("Done")
267330
})
268331
},

packages/opencode/src/provider/provider.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,43 @@ export namespace Provider {
256256
if (disabled.has(providerID)) continue
257257
if (provider.type === "api") {
258258
mergeProvider(providerID, { apiKey: provider.key }, "api")
259+
continue
260+
}
261+
if (provider.type !== "cmd") continue
262+
263+
// Basic security validation
264+
const command = provider.command[0]
265+
if (!command || typeof command !== "string") {
266+
log.error("invalid command", { providerID, command })
267+
continue
268+
}
269+
270+
// Prevent potentially dangerous commands
271+
const dangerousCommands = ["rm", "del", "format", "fdisk", "mkfs", "dd", "shred"]
272+
if (dangerousCommands.some((cmd) => command.includes(cmd))) {
273+
log.error("command contains potentially dangerous operation", { providerID, command })
274+
continue
275+
}
276+
277+
log.info("executing command for", { providerID, command: provider.command.join(" ") })
278+
const proc = Bun.spawn({
279+
cmd: provider.command,
280+
stdout: "pipe",
281+
stderr: "pipe",
282+
})
283+
const exit = await proc.exited
284+
if (exit !== 0) {
285+
const stderr = await new Response(proc.stderr).text()
286+
log.error("command failed", { providerID, exit, stderr })
287+
continue
288+
}
289+
const apiKey = (await new Response(proc.stdout).text()).trim()
290+
if (!apiKey) {
291+
log.error("command produced empty output", { providerID })
292+
continue
259293
}
294+
mergeProvider(providerID, { apiKey }, "api")
295+
log.info("command executed successfully", { providerID })
260296
}
261297

262298
// load custom

0 commit comments

Comments
 (0)