From ba031347f24f16108a6be76b9a81ca3fe95eb28d Mon Sep 17 00:00:00 2001 From: opencode-bot Date: Mon, 1 Sep 2025 14:02:36 -0400 Subject: [PATCH 1/7] feat(docker): add Docker server mode, auth sync, Dockerfile updates, local build flags, script/docker-build, and publish-docker workflow --- .github/workflows/publish-docker.yml | 50 +++++++++ Dockerfile | 35 ++++++ README.md | 40 +++++++ packages/opencode/src/cli/cmd/serve.ts | 142 +++++++++++++++++++++++-- packages/opencode/src/cli/cmd/tui.ts | 130 +++++++++++++++++++++- script/docker-build | 26 +++++ 6 files changed, 411 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/publish-docker.yml create mode 100644 Dockerfile create mode 100755 script/docker-build diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml new file mode 100644 index 00000000000..6e5aa3e78ab --- /dev/null +++ b/.github/workflows/publish-docker.yml @@ -0,0 +1,50 @@ +name: Publish Docker Image + +on: + release: + types: [published] + workflow_dispatch: {} + +jobs: + docker: + name: Build and push to Docker Hub + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: ${{ secrets.DOCKERHUB_USERNAME && secrets.DOCKERHUB_TOKEN }} + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Compute tags + id: meta + run: | + tag="${GITHUB_REF_NAME#v}" + sha=$(echo "$GITHUB_SHA" | cut -c1-7) + if [ -z "$tag" ]; then tag="$sha"; fi + echo "version=$tag" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: ${{ secrets.DOCKERHUB_USERNAME && secrets.DOCKERHUB_TOKEN }} + platforms: linux/amd64,linux/arm64 + tags: | + opencodeai/opencode:server + opencodeai/opencode:server-${{ steps.meta.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..5831c97b9ac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM oven/bun:latest AS base + +# Core tools required by server features (downloads, unzip, etc.) and gopls support +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl unzip tar git golang nodejs npm \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN groupadd -g 1001 opencode && \ + useradd -r -u 1001 -g opencode -m opencode + +# Set working directory for the app layer +WORKDIR /app + +# Copy only the opencode package files for a minimal build +COPY packages/opencode/package.json ./ +RUN sed -i 's/"@opencode-ai\/sdk": "workspace:\*"/"@opencode-ai\/sdk": "latest"/g' package.json && \ + sed -i 's/"@opencode-ai\/plugin": "workspace:\*"/"@opencode-ai\/plugin": "latest"/g' package.json + +# Install dependencies (production preferred, fall back to full) +RUN bun install --production || bun install + +# Copy source code +COPY packages/opencode/src ./src +COPY packages/opencode/tsconfig.json ./ + +# Expose port +EXPOSE 8080 + +# Switch to non-root user +USER opencode + +# Start the server +CMD ["bun", "run", "/app/src/index.ts", "serve", "--hostname", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md index b844c497ead..f0a69c7cdd4 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,46 @@ $ bun install $ bun dev ``` +#### Docker Server Mode + +You can optionally run the opencode server in a Docker container with the current directory mounted for isolation. When started with `--docker`, opencode securely syncs only its own provider credentials (from `auth.json`) into the container; no other local credentials or home directories are mounted. + +```bash +# TUI with server in Docker (mounts $PWD to /workspace) +# Uses Docker Hub image by default: opencodeai/opencode:server +opencode --docker + +# Headless server in Docker +opencode serve --docker --port 8080 --docker-image opencode:latest +``` + +This maps a host port to the container’s server and mounts your current directory at `/workspace`. + +Build from a local Dockerfile (handy for dev): + +```bash +# Build with the repo Dockerfile, then run +opencode --docker --docker-build --dockerfile ./Dockerfile + +# Or headless +opencode serve --docker --docker-build --dockerfile ./Dockerfile --port 8080 +``` + +The default Docker image is `opencodeai/opencode:server`. The provided Dockerfile uses the `oven/bun` base image, adds essential tools (`curl`, `unzip`, `tar`, `git`, `nodejs`, `npm`) and Go (for optional `gopls`), installs the opencode server, and exposes port `8080`. + +If you prefer to build the image manually: + +```bash +docker build -t opencode:latest . +``` + +Or use the helper: + +```bash +# Tags both opencodeai/opencode:server and opencode:local +./script/docker-build [Dockerfile] [context] +``` + #### Development Notes **API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients. diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 850dbc83d42..e7b5cc16835 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,10 +1,39 @@ +import { Provider } from "../../provider/provider" import { Server } from "../../server/server" +import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" +import { Auth } from "../../auth" +import path from "path" +import { ModelsDev } from "../../provider/models" export const ServeCommand = cmd({ command: "serve", builder: (yargs) => yargs + .option("docker", { + type: "boolean", + describe: "run server in docker with current dir mounted", + }) + .option("docker-image", { + type: "string", + describe: "docker image for server", + default: "opencodeai/opencode:server", + alias: ["dockerImage"], + }) + .option("dockerfile", { + type: "string", + describe: "path to a local Dockerfile to build before running", + }) + .option("docker-context", { + type: "string", + describe: "docker build context directory (defaults to Dockerfile's dir)", + alias: ["dockerContext"], + }) + .option("docker-build", { + type: "boolean", + describe: "force build the docker image before running", + alias: ["dockerBuild"], + }) .option("port", { alias: ["p"], type: "number", @@ -19,14 +48,111 @@ export const ServeCommand = cmd({ }), describe: "starts a headless opencode server", handler: async (args) => { - const hostname = args.hostname - const port = args.port - const server = Server.listen({ - port, - hostname, + const cwd = process.cwd() + await bootstrap(cwd, async () => { + const providers = await Provider.list() + if (Object.keys(providers).length === 0) { + return "needs_provider" + } + + const srv = await (async () => { + if (!args.docker) return Server.listen({ port: args.port, hostname: args.hostname }) + const docker = Bun.which("docker") + if (!docker) return Server.listen({ port: args.port, hostname: args.hostname }) + const df = (args as { dockerfile?: string }).dockerfile + const needBuild = !!df || (args as { dockerBuild?: boolean }).dockerBuild === true + const img = await (async () => { + const defaultImg = "opencodeai/opencode:server" + if (!needBuild) return (args as { dockerImage?: string }).dockerImage ?? defaultImg + const f = df ?? "Dockerfile" + const ctx = (args as { dockerContext?: string }).dockerContext ?? path.dirname(path.resolve(f)) + const base = (args as { dockerImage?: string }).dockerImage ?? defaultImg + const tag = base === defaultImg ? "opencode:local" : base + const b = Bun.spawn({ cmd: [docker, "build", "-t", tag, "-f", f, ctx], stdout: "inherit", stderr: "inherit" }) + const code = await b.exited + if (code !== 0) return base + return tag + })() + const alloc = () => { + const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("ok") }) + const p = s.port + s.stop() + return p + } + const port = args.port && args.port > 0 ? args.port : alloc() + const host = args.hostname ?? "127.0.0.1" + const cport = 8080 + const vol = process.cwd() + ":/workspace" + const db = await ModelsDev.get() + const envlist: string[] = [] + for (const p of Object.values(db)) { + for (const k of p.env) { + const v = process.env[k] + if (v) envlist.push(`${k}=${v}`) + } + } + const cmd = [ + docker, + "run", + "--rm", + "-d", + "-p", + `${port}:${cport}`, + "-v", + vol, + "-w", + "/workspace", + ...envlist.flatMap((e) => ["-e", e]), + img, + "bun", + "run", + "/app/src/index.ts", + "serve", + "--hostname", + "0.0.0.0", + "--port", + String(cport), + ] + const p = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" }) + const code = await p.exited + const id = await new Response(p.stdout).text().then((x) => x.trim()) + if (code !== 0 || !id) return Server.listen({ port: args.port, hostname: args.hostname }) + const url = new URL("http://" + host + ":" + String(port)) + const until = Date.now() + 20_000 + while (Date.now() < until) { + const ok = await fetch(new URL("/doc", url)).then((r) => r.ok).catch(() => false) + if (ok) break + await Bun.sleep(200) + } + return { + hostname: host, + port, + url, + stop: async () => { + const stop = Bun.spawn({ cmd: [docker, "stop", id], stdout: "ignore", stderr: "inherit" }) + await stop.exited + }, + } + })() + + if (args.docker) { + const auth = await Auth.all() + await Promise.all( + Object.entries(auth).map(([id, info]) => + fetch(new URL("/auth/" + encodeURIComponent(id), srv.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(info), + }).catch(() => {}), + ), + ) + } + + console.log(`opencode server listening on http://${srv.hostname}:${srv.port}`) + + await new Promise(() => {}) + + srv.stop() }) - console.log(`opencode server listening on http://${server.hostname}:${server.port}`) - await new Promise(() => {}) - server.stop() }, }) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 2011c26cbd5..5d9aa977f9c 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -12,6 +12,7 @@ import { Bus } from "../../bus" import { Log } from "../../util/log" import { FileWatcher } from "../../file/watch" import { Ide } from "../../ide" +import { Auth } from "../../auth" import { Flag } from "../../flag/flag" import { Session } from "../../session" @@ -36,6 +37,30 @@ export const TuiCommand = cmd({ type: "string", describe: "path to start opencode in", }) + .option("docker", { + type: "boolean", + describe: "run server in docker with current dir mounted", + }) + .option("docker-image", { + type: "string", + describe: "docker image for server", + default: "opencodeai/opencode:server", + alias: ["dockerImage"], + }) + .option("dockerfile", { + type: "string", + describe: "path to a local Dockerfile to build before running", + }) + .option("docker-context", { + type: "string", + describe: "docker build context directory (defaults to Dockerfile's dir)", + alias: ["dockerContext"], + }) + .option("docker-build", { + type: "boolean", + describe: "force build the docker image before running", + alias: ["dockerBuild"], + }) .option("model", { type: "string", alias: ["m"], @@ -105,11 +130,95 @@ export const TuiCommand = cmd({ if (Object.keys(providers).length === 0) { return "needs_provider" } + const server = await (async () => { + if (!args.docker) { + return Server.listen({ port: args.port, hostname: args.hostname }) + } - const server = Server.listen({ - port: args.port, - hostname: args.hostname, - }) + const docker = Bun.which("docker") + if (!docker) { + UI.error("docker not found, starting server locally") + return Server.listen({ port: args.port, hostname: args.hostname }) + } + + const df = (args as { dockerfile?: string }).dockerfile + const needBuild = !!df || (args as { dockerBuild?: boolean }).dockerBuild === true + const img = await (async () => { + const defaultImg = "opencodeai/opencode:server" + if (!needBuild) return (args as { dockerImage?: string }).dockerImage ?? defaultImg + const f = df ?? "Dockerfile" + const ctx = (args as { dockerContext?: string }).dockerContext ?? path.dirname(path.resolve(f)) + const base = (args as { dockerImage?: string }).dockerImage ?? defaultImg + const tag = base === defaultImg ? "opencode:local" : base + const b = Bun.spawn({ cmd: [docker, "build", "-t", tag, "-f", f, ctx], stdout: "inherit", stderr: "inherit" }) + const code = await b.exited + if (code !== 0) { + UI.error("docker build failed, starting server locally") + return base + } + return tag + })() + + const alloc = () => { + const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("ok") }) + const p = s.port + s.stop() + return p + } + + const port = args.port && args.port > 0 ? args.port : alloc() + const host = "127.0.0.1" + const cport = 8080 + const vol = process.cwd() + ":/workspace" + + const cmd = [ + docker, + "run", + "--rm", + "-d", + "-p", + `${port}:${cport}`, + "-v", + vol, + "-w", + "/workspace", + img, + "bun", + "run", + "/app/src/index.ts", + "serve", + "--hostname", + "0.0.0.0", + "--port", + String(cport), + ] + + const proc = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" }) + const code = await proc.exited + const id = await new Response(proc.stdout).text().then((x) => x.trim()) + if (code !== 0 || !id) { + UI.error("failed to start docker server, starting locally") + return Server.listen({ port: args.port, hostname: args.hostname }) + } + + const url = new URL("http://" + host + ":" + String(port)) + const until = Date.now() + 20_000 + while (Date.now() < until) { + const ok = await fetch(new URL("/doc", url)).then((r) => r.ok).catch(() => false) + if (ok) break + await Bun.sleep(200) + } + + return { + hostname: host, + port, + url, + stop: async () => { + const stop = Bun.spawn({ cmd: [docker, "stop", id], stdout: "ignore", stderr: "inherit" }) + await stop.exited + }, + } + })() let cmd = ["go", "run", "./main.go"] let cwd = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url)) @@ -131,6 +240,19 @@ export const TuiCommand = cmd({ Log.Default.info("tui", { cmd, }) + if (args.docker) { + const auth = await Auth.all() + await Promise.all( + Object.entries(auth).map(([id, info]) => + fetch(new URL("/auth/" + encodeURIComponent(id), server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(info), + }).catch(() => {}), + ), + ) + } + const proc = Bun.spawn({ cmd: [ ...cmd, diff --git a/script/docker-build b/script/docker-build new file mode 100755 index 00000000000..3ed2279211f --- /dev/null +++ b/script/docker-build @@ -0,0 +1,26 @@ +#!/bin/sh +set -euo pipefail + +# Usage: script/docker-build [Dockerfile] [context] +# - Builds the opencode server image and tags it twice: +# - opencodeai/opencode:server (default hub tag) +# - opencode:local (local convenience tag) + +DF=${1:-Dockerfile} +CTX=${2:-$(dirname "${DF}")} +IMG1=${IMG1:-opencodeai/opencode:server} +IMG2=${IMG2:-opencode:local} + +DOCKER=${DOCKER:-} +if [ -z "${DOCKER}" ]; then + if command -v docker >/dev/null 2>&1; then + DOCKER=$(command -v docker) + else + echo "docker not found in PATH" >&2 + exit 1 + fi +fi + +echo "Building ${IMG1} and ${IMG2} from ${DF} (context ${CTX})..." +exec "${DOCKER}" build -t "${IMG1}" -t "${IMG2}" -f "${DF}" "${CTX}" + From b30f6e1700ea59c8f5e14716eefa49489f08739a Mon Sep 17 00:00:00 2001 From: opencode-bot Date: Mon, 1 Sep 2025 16:05:16 -0400 Subject: [PATCH 2/7] fix(docker): resolve catalog: deps from root catalog during build; ensure golang-go/node/jq; verified build --- Dockerfile | 9 ++++++--- package.json | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5831c97b9ac..8473b236bee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM oven/bun:latest AS base # Core tools required by server features (downloads, unzip, etc.) and gopls support RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - ca-certificates curl unzip tar git golang nodejs npm \ + ca-certificates curl unzip tar git golang-go nodejs npm jq \ && rm -rf /var/lib/apt/lists/* # Create non-root user @@ -14,9 +14,12 @@ RUN groupadd -g 1001 opencode && \ WORKDIR /app # Copy only the opencode package files for a minimal build -COPY packages/opencode/package.json ./ +COPY packages/opencode/package.json ./package.json +# Provide workspace catalog mapping for catalog: versions +COPY package.json /tmp/root.package.json RUN sed -i 's/"@opencode-ai\/sdk": "workspace:\*"/"@opencode-ai\/sdk": "latest"/g' package.json && \ - sed -i 's/"@opencode-ai\/plugin": "workspace:\*"/"@opencode-ai\/plugin": "latest"/g' package.json + sed -i 's/"@opencode-ai\/plugin": "workspace:\*"/"@opencode-ai\/plugin": "latest"/g' package.json && \ + node -e 'const fs=require("fs"); const root=JSON.parse(fs.readFileSync("/tmp/root.package.json","utf8")); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); const cat=(root.workspaces&&root.workspaces.catalog)||{}; if(pkg.dependencies){for(const k of Object.keys(pkg.dependencies)) if(pkg.dependencies[k]==="catalog:") pkg.dependencies[k]=cat[k]||pkg.dependencies[k];} if(pkg.devDependencies){for(const k of Object.keys(pkg.devDependencies)) if(pkg.devDependencies[k]==="catalog:") pkg.devDependencies[k]=cat[k]||pkg.devDependencies[k];} fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2));' # Install dependencies (production preferred, fall back to full) RUN bun install --production || bun install diff --git a/package.json b/package.json index a38d1b613cd..e8db7f13e9d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "packageManager": "bun@1.2.19", "scripts": { "dev": "bun run --conditions=development packages/opencode/src/index.ts", + "docker:build": "./script/docker-build", "typecheck": "bun run --filter='*' typecheck", "generate": "(cd packages/sdk && ./js/script/generate.ts) && (cd packages/sdk/stainless && ./generate.ts)", "postinstall": "./script/hooks" From 544ccce793381dafde0e149c78cd4c8e00cdac5f Mon Sep 17 00:00:00 2001 From: opencode-bot Date: Mon, 1 Sep 2025 16:39:34 -0400 Subject: [PATCH 3/7] feat(docker): inject provider env vars into container and push auth to server for seamless creds --- packages/opencode/src/cli/cmd/tui.ts | 32 ++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 5d9aa977f9c..98445844577 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -17,6 +17,7 @@ import { Auth } from "../../auth" import { Flag } from "../../flag/flag" import { Session } from "../../session" import { Instance } from "../../project/instance" +import { ModelsDev } from "../../provider/models" declare global { const OPENCODE_TUI_PATH: string @@ -130,13 +131,16 @@ export const TuiCommand = cmd({ if (Object.keys(providers).length === 0) { return "needs_provider" } + const cfg = await Config.get() + const useDocker = (args.docker ?? (cfg.server?.docker === true)) === true + const server = await (async () => { - if (!args.docker) { + if (!useDocker) { return Server.listen({ port: args.port, hostname: args.hostname }) } - const docker = Bun.which("docker") - if (!docker) { + const dockerBin = Bun.which("docker") + if (!dockerBin) { UI.error("docker not found, starting server locally") return Server.listen({ port: args.port, hostname: args.hostname }) } @@ -145,12 +149,13 @@ export const TuiCommand = cmd({ const needBuild = !!df || (args as { dockerBuild?: boolean }).dockerBuild === true const img = await (async () => { const defaultImg = "opencodeai/opencode:server" - if (!needBuild) return (args as { dockerImage?: string }).dockerImage ?? defaultImg + const configured = cfg.server?.image + if (!needBuild) return (args as { dockerImage?: string }).dockerImage ?? configured ?? defaultImg const f = df ?? "Dockerfile" const ctx = (args as { dockerContext?: string }).dockerContext ?? path.dirname(path.resolve(f)) - const base = (args as { dockerImage?: string }).dockerImage ?? defaultImg + const base = (args as { dockerImage?: string }).dockerImage ?? configured ?? defaultImg const tag = base === defaultImg ? "opencode:local" : base - const b = Bun.spawn({ cmd: [docker, "build", "-t", tag, "-f", f, ctx], stdout: "inherit", stderr: "inherit" }) + const b = Bun.spawn({ cmd: [dockerBin, "build", "-t", tag, "-f", f, ctx], stdout: "inherit", stderr: "inherit" }) const code = await b.exited if (code !== 0) { UI.error("docker build failed, starting server locally") @@ -170,9 +175,17 @@ export const TuiCommand = cmd({ const host = "127.0.0.1" const cport = 8080 const vol = process.cwd() + ":/workspace" + const db = await ModelsDev.get() + const envlist: string[] = [] + for (const p of Object.values(db)) { + for (const k of p.env) { + const v = process.env[k] + if (v) envlist.push(`${k}=${v}`) + } + } const cmd = [ - docker, + dockerBin, "run", "--rm", "-d", @@ -182,6 +195,7 @@ export const TuiCommand = cmd({ vol, "-w", "/workspace", + ...envlist.flatMap((e) => ["-e", e]), img, "bun", "run", @@ -214,7 +228,7 @@ export const TuiCommand = cmd({ port, url, stop: async () => { - const stop = Bun.spawn({ cmd: [docker, "stop", id], stdout: "ignore", stderr: "inherit" }) + const stop = Bun.spawn({ cmd: [dockerBin, "stop", id], stdout: "ignore", stderr: "inherit" }) await stop.exited }, } @@ -240,7 +254,7 @@ export const TuiCommand = cmd({ Log.Default.info("tui", { cmd, }) - if (args.docker) { + if (useDocker) { const auth = await Auth.all() await Promise.all( Object.entries(auth).map(([id, info]) => From 13720b09ffaa56d519a8913233a68532b8e61f21 Mon Sep 17 00:00:00 2001 From: opencode-bot Date: Mon, 1 Sep 2025 17:13:53 -0400 Subject: [PATCH 4/7] feat(config): add server.docker (and image) to auto-use Docker server for TUI when enabled; update TUI to honor config --- .opencode/docker-server-pr.md | 24 ++++++++++++++++++++++++ README.md | 12 ++++++++++++ packages/opencode/src/cli/cmd/tui.ts | 17 +++++++++++++++++ packages/opencode/src/config/config.ts | 7 +++++++ 4 files changed, 60 insertions(+) create mode 100644 .opencode/docker-server-pr.md diff --git a/.opencode/docker-server-pr.md b/.opencode/docker-server-pr.md new file mode 100644 index 00000000000..211ffdc79db --- /dev/null +++ b/.opencode/docker-server-pr.md @@ -0,0 +1,24 @@ +Adds an optional Docker-backed server mode for the TUI and headless server to isolate the runtime environment without sacrificing TUI performance. + +Why +- Improve security/isolation by running the server in a container +- Avoid host tooling/version conflicts while keeping the TUI native on the host +- Keep this fully optional; default behavior is unchanged + +What +- TUI/Serve: `--docker` flag to start the server in Docker, mounting `$PWD` to `/workspace` and mapping a host port to container `8080`. +- Image: default to `opencodeai/opencode:server`; support `--docker-image`. +- Local builds: support `--dockerfile`, `--docker-context`, `--docker-build` for building a local image; added `script/docker-build` and `docker:build` script. +- Auth: sync only opencode-managed provider credentials to the server (`PUT /auth/:id`) and inject only provider-defined env vars (from models.dev) into the container (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`). No $HOME/XDG dirs are mounted. +- Dockerfile: based on `oven/bun`; installs minimal tools (`git`, `curl`, `unzip`, `tar`, `nodejs`, `npm`, `golang`) and runs `bun run /app/src/index.ts serve --hostname 0.0.0.0 --port 8080`. +- CI: GitHub Action to publish `opencodeai/opencode:server` on release (multi-arch). +- Docs: README snippet for Docker usage. + +Usage +- TUI: `opencode --docker` (uses Hub image) or `opencode --docker --docker-image opencode:local` after a local build +- Serve: `opencode serve --docker --port 8080` +- Build: `bun run docker:build` (tags both `opencodeai/opencode:server` and `opencode:local`) + +Notes +- Backwards-compatible and opt-in. +- Only provider credentials are synced; no other host secrets are exposed. diff --git a/README.md b/README.md index f0a69c7cdd4..a923bad8f0a 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,18 @@ Or use the helper: ./script/docker-build [Dockerfile] [context] ``` +Auto-enable Docker mode via config: + +```jsonc +// ~/.config/opencode/config.json +{ + "server": { + "docker": true, + "image": "opencodeai/opencode:server" + } +} +``` + #### Development Notes **API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients. diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 98445844577..b094dafa82a 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -132,10 +132,17 @@ export const TuiCommand = cmd({ return "needs_provider" } const cfg = await Config.get() +<<<<<<< HEAD const useDocker = (args.docker ?? (cfg.server?.docker === true)) === true const server = await (async () => { if (!useDocker) { +======= + const docker = (args.docker ?? (cfg.server?.docker === true)) === true + + const server = await (async () => { + if (!docker) { +>>>>>>> 31983999 (feat(config): add server.docker (and image) to auto-use Docker server for TUI when enabled; update TUI to honor config) return Server.listen({ port: args.port, hostname: args.hostname }) } @@ -228,9 +235,15 @@ export const TuiCommand = cmd({ port, url, stop: async () => { +<<<<<<< HEAD const stop = Bun.spawn({ cmd: [dockerBin, "stop", id], stdout: "ignore", stderr: "inherit" }) await stop.exited }, +======= + const stop = Bun.spawn({ cmd: [dockerBin, "stop", id], stdout: "ignore", stderr: "inherit" }) + await stop.exited + }, +>>>>>>> 31983999 (feat(config): add server.docker (and image) to auto-use Docker server for TUI when enabled; update TUI to honor config) } })() @@ -254,7 +267,11 @@ export const TuiCommand = cmd({ Log.Default.info("tui", { cmd, }) +<<<<<<< HEAD if (useDocker) { +======= + if (docker) { +>>>>>>> 31983999 (feat(config): add server.docker (and image) to auto-use Docker server for TUI when enabled; update TUI to honor config) const auth = await Auth.all() await Promise.all( Object.entries(auth).map(([id, info]) => diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 135c0e80c38..8142209e036 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -485,6 +485,13 @@ export namespace Config { .optional(), }) .optional(), + server: z + .object({ + docker: z.boolean().optional().describe("Run the server in Docker by default for the TUI"), + image: z.string().optional().describe("Default Docker image to use for the server"), + }) + .optional() + .describe("Server runtime preferences"), }) .strict() .openapi({ From 1c081232dbe7a6a8ff4d2bd2cae57d9269acffa5 Mon Sep 17 00:00:00 2001 From: opencode-bot Date: Mon, 1 Sep 2025 17:16:11 -0400 Subject: [PATCH 5/7] Remove accidental file --- .opencode/docker-server-pr.md | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 .opencode/docker-server-pr.md diff --git a/.opencode/docker-server-pr.md b/.opencode/docker-server-pr.md deleted file mode 100644 index 211ffdc79db..00000000000 --- a/.opencode/docker-server-pr.md +++ /dev/null @@ -1,24 +0,0 @@ -Adds an optional Docker-backed server mode for the TUI and headless server to isolate the runtime environment without sacrificing TUI performance. - -Why -- Improve security/isolation by running the server in a container -- Avoid host tooling/version conflicts while keeping the TUI native on the host -- Keep this fully optional; default behavior is unchanged - -What -- TUI/Serve: `--docker` flag to start the server in Docker, mounting `$PWD` to `/workspace` and mapping a host port to container `8080`. -- Image: default to `opencodeai/opencode:server`; support `--docker-image`. -- Local builds: support `--dockerfile`, `--docker-context`, `--docker-build` for building a local image; added `script/docker-build` and `docker:build` script. -- Auth: sync only opencode-managed provider credentials to the server (`PUT /auth/:id`) and inject only provider-defined env vars (from models.dev) into the container (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`). No $HOME/XDG dirs are mounted. -- Dockerfile: based on `oven/bun`; installs minimal tools (`git`, `curl`, `unzip`, `tar`, `nodejs`, `npm`, `golang`) and runs `bun run /app/src/index.ts serve --hostname 0.0.0.0 --port 8080`. -- CI: GitHub Action to publish `opencodeai/opencode:server` on release (multi-arch). -- Docs: README snippet for Docker usage. - -Usage -- TUI: `opencode --docker` (uses Hub image) or `opencode --docker --docker-image opencode:local` after a local build -- Serve: `opencode serve --docker --port 8080` -- Build: `bun run docker:build` (tags both `opencodeai/opencode:server` and `opencode:local`) - -Notes -- Backwards-compatible and opt-in. -- Only provider credentials are synced; no other host secrets are exposed. From a8dd115354771b74a013eec0469914bbb85eb3d4 Mon Sep 17 00:00:00 2001 From: opencode-bot Date: Mon, 1 Sep 2025 17:34:54 -0400 Subject: [PATCH 6/7] merge: resolve conflicts in TUI (config-driven Docker mode + env/auth sync) --- packages/opencode/src/cli/cmd/tui.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index b094dafa82a..27a27762065 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -132,17 +132,10 @@ export const TuiCommand = cmd({ return "needs_provider" } const cfg = await Config.get() -<<<<<<< HEAD const useDocker = (args.docker ?? (cfg.server?.docker === true)) === true const server = await (async () => { if (!useDocker) { -======= - const docker = (args.docker ?? (cfg.server?.docker === true)) === true - - const server = await (async () => { - if (!docker) { ->>>>>>> 31983999 (feat(config): add server.docker (and image) to auto-use Docker server for TUI when enabled; update TUI to honor config) return Server.listen({ port: args.port, hostname: args.hostname }) } @@ -235,15 +228,9 @@ export const TuiCommand = cmd({ port, url, stop: async () => { -<<<<<<< HEAD - const stop = Bun.spawn({ cmd: [dockerBin, "stop", id], stdout: "ignore", stderr: "inherit" }) - await stop.exited - }, -======= const stop = Bun.spawn({ cmd: [dockerBin, "stop", id], stdout: "ignore", stderr: "inherit" }) await stop.exited - }, ->>>>>>> 31983999 (feat(config): add server.docker (and image) to auto-use Docker server for TUI when enabled; update TUI to honor config) + }, } })() @@ -267,11 +254,7 @@ export const TuiCommand = cmd({ Log.Default.info("tui", { cmd, }) -<<<<<<< HEAD if (useDocker) { -======= - if (docker) { ->>>>>>> 31983999 (feat(config): add server.docker (and image) to auto-use Docker server for TUI when enabled; update TUI to honor config) const auth = await Auth.all() await Promise.all( Object.entries(auth).map(([id, info]) => From ae03472047e9a3c6c31896889b231aa7d680a7bb Mon Sep 17 00:00:00 2001 From: opencode-bot Date: Mon, 1 Sep 2025 17:43:54 -0400 Subject: [PATCH 7/7] fix(docker): wait for server readiness; fallback to local if container not ready; prevent TUI crash/connection refused --- packages/opencode/src/cli/cmd/serve.ts | 11 ++++++++--- packages/opencode/src/cli/cmd/tui.ts | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index e7b5cc16835..13ea3d8052a 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -118,12 +118,17 @@ export const ServeCommand = cmd({ const id = await new Response(p.stdout).text().then((x) => x.trim()) if (code !== 0 || !id) return Server.listen({ port: args.port, hostname: args.hostname }) const url = new URL("http://" + host + ":" + String(port)) - const until = Date.now() + 20_000 + const until = Date.now() + 30_000 + let ready = false while (Date.now() < until) { const ok = await fetch(new URL("/doc", url)).then((r) => r.ok).catch(() => false) - if (ok) break - await Bun.sleep(200) + if (ok) { + ready = true + break + } + await Bun.sleep(250) } + if (!ready) return Server.listen({ port: args.port, hostname: args.hostname }) return { hostname: host, port, diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 27a27762065..4f6b01be6e7 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -216,11 +216,21 @@ export const TuiCommand = cmd({ } const url = new URL("http://" + host + ":" + String(port)) - const until = Date.now() + 20_000 + const until = Date.now() + 30_000 + let ready = false while (Date.now() < until) { const ok = await fetch(new URL("/doc", url)).then((r) => r.ok).catch(() => false) - if (ok) break - await Bun.sleep(200) + if (ok) { + ready = true + break + } + await Bun.sleep(250) + } + if (!ready) { + UI.error("docker server failed to become ready, starting locally") + const stop = Bun.spawn({ cmd: [dockerBin, "stop", id], stdout: "ignore", stderr: "inherit" }) + await stop.exited + return Server.listen({ port: args.port, hostname: args.hostname }) } return {