Browse Source

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
Dax Raad 1 month ago
parent
commit
1954c1255e

+ 5 - 5
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()
     })
   },
 })

+ 4 - 0
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}`)

+ 0 - 48
packages/opencode/src/cli/cmd/tui/spawn.ts

@@ -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)
-  },
-})

+ 4 - 0
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()

+ 1 - 0
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")

+ 0 - 2
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)

+ 9 - 0
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) {