From 1954c1255e71ab86283e6a99ac82598268fc308d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 12 Jan 2026 15:23:12 -0500 Subject: [PATCH] core: add password authentication and improve server security - Add OPENCODE_PASSWORD flag for basic auth protection - Show security warnings when password is not set - Remove deprecated spawn command - Improve error handling with HTTPException responses --- packages/opencode/src/cli/cmd/run.ts | 10 ++--- packages/opencode/src/cli/cmd/serve.ts | 4 ++ packages/opencode/src/cli/cmd/tui/spawn.ts | 48 ---------------------- packages/opencode/src/cli/cmd/web.ts | 4 ++ packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/index.ts | 2 - packages/opencode/src/server/server.ts | 9 ++++ 7 files changed, 23 insertions(+), 55 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/tui/spawn.ts diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index a86b435ec..54248f96f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -339,13 +339,15 @@ export const RunCommand = cmd({ } await bootstrap(process.cwd(), async () => { - const server = Server.listen({ port: args.port ?? 0, hostname: "127.0.0.1" }) - const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` }) + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + return Server.App().fetch(request) + }) as typeof globalThis.fetch + const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) if (args.command) { const exists = await Command.get(args.command) if (!exists) { - server.stop() UI.error(`Command "${args.command}" not found`) process.exit(1) } @@ -370,7 +372,6 @@ export const RunCommand = cmd({ })() if (!sessionID) { - server.stop() UI.error("Session not found") process.exit(1) } @@ -389,7 +390,6 @@ export const RunCommand = cmd({ } await execute(sdk, sessionID) - server.stop() }) }, }) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 657f9196c..441240609 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,12 +1,16 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" +import { Flag } from "../../flag/flag" export const ServeCommand = cmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { + if (!Flag.OPENCODE_PASSWORD) { + console.log("Warning: OPENCODE_PASSWORD is not set; server is unsecured.") + } const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) diff --git a/packages/opencode/src/cli/cmd/tui/spawn.ts b/packages/opencode/src/cli/cmd/tui/spawn.ts deleted file mode 100644 index ef359e6f4..000000000 --- a/packages/opencode/src/cli/cmd/tui/spawn.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { cmd } from "@/cli/cmd/cmd" -import { Instance } from "@/project/instance" -import path from "path" -import { Server } from "@/server/server" -import { upgrade } from "@/cli/upgrade" -import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" - -export const TuiSpawnCommand = cmd({ - command: "spawn [project]", - builder: (yargs) => - withNetworkOptions(yargs).positional("project", { - type: "string", - describe: "path to start opencode in", - }), - handler: async (args) => { - upgrade() - const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) - const bin = process.execPath - const cmd = [] - let cwd = process.cwd() - if (bin.endsWith("bun")) { - cmd.push( - process.execPath, - "run", - "--conditions", - "browser", - new URL("../../../index.ts", import.meta.url).pathname, - ) - cwd = new URL("../../../../", import.meta.url).pathname - } else cmd.push(process.execPath) - cmd.push("attach", server.url.toString(), "--dir", args.project ? path.resolve(args.project) : process.cwd()) - const proc = Bun.spawn({ - cmd, - cwd, - stdout: "inherit", - stderr: "inherit", - stdin: "inherit", - env: { - ...process.env, - BUN_OPTIONS: "", - }, - }) - await proc.exited - await Instance.disposeAll() - await server.stop(true) - }, -}) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index e2ecc1187..abb347798 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -2,6 +2,7 @@ import { Server } from "../../server/server" import { UI } from "../ui" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" +import { Flag } from "../../flag/flag" import open from "open" import { networkInterfaces } from "os" @@ -32,6 +33,9 @@ export const WebCommand = cmd({ builder: (yargs) => withNetworkOptions(yargs), describe: "start opencode server and open web interface", handler: async (args) => { + if (!Flag.OPENCODE_PASSWORD) { + UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_PASSWORD is not set; server is unsecured.") + } const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) UI.empty() diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 50e3cd79e..77260a84c 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -20,6 +20,7 @@ export namespace Flag { OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS") export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli" + export const OPENCODE_PASSWORD = process.env["OPENCODE_PASSWORD"] // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6099443e7..3de7735bd 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -21,7 +21,6 @@ import { ExportCommand } from "./cli/cmd/export" import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/tui/attach" import { TuiThreadCommand } from "./cli/cmd/tui/thread" -import { TuiSpawnCommand } from "./cli/cmd/tui/spawn" import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" @@ -81,7 +80,6 @@ const cli = yargs(hideBin(process.argv)) .command(AcpCommand) .command(McpCommand) .command(TuiThreadCommand) - .command(TuiSpawnCommand) .command(AttachCommand) .command(RunCommand) .command(GenerateCommand) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 32d7a1795..05024acde 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -7,6 +7,7 @@ import { Hono } from "hono" import { cors } from "hono/cors" import { stream, streamSSE } from "hono/streaming" import { proxy } from "hono/proxy" +import { basicAuth } from "hono/basic-auth" import { Session } from "../session" import z from "zod" import { Provider } from "../provider/provider" @@ -25,6 +26,7 @@ import { Project } from "../project/project" import { Vcs } from "../project/vcs" import { Agent } from "../agent/agent" import { Auth } from "../auth" +import { Flag } from "../flag/flag" import { Command } from "../command" import { ProviderAuth } from "../provider/auth" import { Global } from "../global" @@ -45,6 +47,7 @@ import { Snapshot } from "@/snapshot" import { SessionSummary } from "@/session/summary" import { SessionStatus } from "@/session/status" import { upgradeWebSocket, websocket } from "hono/bun" +import { HTTPException } from "hono/http-exception" import { errors } from "./error" import { Pty } from "@/pty" import { PermissionNext } from "@/permission/next" @@ -80,6 +83,7 @@ export namespace Server { log.error("failed", { error: err, }) + if (err instanceof HTTPException) return err.getResponse() if (err instanceof NamedError) { let status: ContentfulStatusCode if (err instanceof Storage.NotFoundError) status = 404 @@ -93,6 +97,11 @@ export namespace Server { status: 500, }) }) + .use((c, next) => { + const password = Flag.OPENCODE_PASSWORD + if (!password) return next() + return basicAuth({ username: "opencode", password })(c, next) + }) .use(async (c, next) => { const skipLogging = c.req.path === "/log" if (!skipLogging) {