From 6a9f1fc2d4ce8ce28dd5cf9bc063d91db90a1c6d Mon Sep 17 00:00:00 2001 From: Microck Date: Thu, 12 Nov 2026 10:36:19 +0100 Subject: [PATCH 1/9] feat: draft production Docker setup --- .dockerignore | 5 ++++ .github/workflows/docker-preview.yml | 38 +++++++++++++++++++++++++ CHANGELOG.md | 8 ++++++ Dockerfile | 21 ++++++++++++++ README.md | 4 +++ client-next/next.config.ts | 5 ++++ client-next/src/lib/api.ts | 4 ++- client-next/src/proxy.ts | 21 ++++++++++++++ docker-compose.yml | 13 +++++++++ docs/PRODUCTION-DOCKER.md | 37 ++++++++++++++++++++++++ package.json | 5 ++-- scripts/build-production.cjs | 9 ++++++ scripts/start-production.cjs | 42 ++++++++++++++++++++++++++++ server/index.js | 3 +- server/lib/cors-policy.js | 5 ++++ server/lib/cors-policy.test.js | 18 ++++++++++++ start.bat | 2 +- start.sh | 2 +- 18 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-preview.yml create mode 100644 Dockerfile create mode 100644 client-next/src/proxy.ts create mode 100644 docker-compose.yml create mode 100644 docs/PRODUCTION-DOCKER.md create mode 100644 scripts/build-production.cjs create mode 100644 scripts/start-production.cjs mode change 100755 => 100644 start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ab61c7a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +**/node_modules +**/.next +.env* +**/*.log diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml new file mode 100644 index 00000000..1f8e5926 --- /dev/null +++ b/.github/workflows/docker-preview.yml @@ -0,0 +1,38 @@ +name: Docker production preview +on: + pull_request: + paths: + - 'Dockerfile' + - '.dockerignore' + - 'docker-compose.yml' + - 'scripts/**' + - 'server/**' + - 'client-next/**' + - '.github/workflows/docker-preview.yml' +permissions: + contents: read +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Build and smoke-test the image + run: | + set -eu + docker build -t studio-pr-test . + docker run -d --name studio-pr-test -p 127.0.0.1:3300:3000 \ + -e STUDIO_PUBLIC_ORIGIN=http://127.0.0.1:3300 studio-pr-test + trap 'docker logs studio-pr-test; docker rm -f studio-pr-test' EXIT + for n in $(seq 1 60); do + if curl -fsS http://127.0.0.1:3300/api/health >/dev/null; then break; fi + sleep 2 + done + curl -fsS http://127.0.0.1:3300/ >/dev/null + curl -fsS -H 'Origin: http://127.0.0.1:3300' http://127.0.0.1:3300/api/health >/dev/null + code=$(curl -sS -o /dev/null -w '%{http_code}' -H 'Origin: https://untrusted.example' http://127.0.0.1:3300/api/health) + test "$code" = 403 + code=$(curl -sS -o /dev/null -w '%{http_code}' -H 'Host: evil.example' http://127.0.0.1:3300/api/health) + test "$code" = 403 diff --git a/CHANGELOG.md b/CHANGELOG.md index 357b8954..539c912c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## Unreleased + +### Added +- Draft production client build, loopback-bound Docker setup and deployment security guidance. + +### Changed +- Separate `npm run dev` from the built production `npm start` workflow. + ## [2.5.1] - 2026-10-24 ### Fixed diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..1624bea9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY client-next/package*.json ./client-next/ +RUN cd client-next && npm ci --ignore-scripts +COPY client-next/ ./client-next/ +ENV STUDIO_PROXY_API=1 NEXT_PUBLIC_STUDIO_PROXY=1 NEXT_TELEMETRY_DISABLED=1 +RUN cd client-next && npm run build + +FROM node:22-bookworm-slim AS runtime +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOME=/home/node STUDIO_STANDALONE=1 +WORKDIR /app +COPY server/package*.json ./server/ +RUN cd server && npm ci --omit=dev --ignore-scripts && npm cache clean --force +COPY server/ ./server/ +COPY scripts/start-production.cjs ./scripts/start-production.cjs +COPY --from=build /app/client-next/.next/standalone/ ./client-next/.next/standalone/ +COPY --from=build /app/client-next/.next/static/ ./client-next/.next/standalone/.next/static/ +COPY --from=build /app/client-next/public/ ./client-next/.next/standalone/public/ +USER node +EXPOSE 3000 +CMD ["node", "scripts/start-production.cjs"] diff --git a/README.md b/README.md index 732fad87..b358d6e4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ --- +For the experimental production build and loopback-only Docker setup, see +[production deployment](docs/PRODUCTION-DOCKER.md). Remote use requires an +authenticated reverse proxy; the Studio backend has no remote authentication. + ### opencode v2 configuration support The compatibility work targets OpenCode 2.1.4 while retaining supported v1 config diff --git a/client-next/next.config.ts b/client-next/next.config.ts index f57fd826..5eead87e 100644 --- a/client-next/next.config.ts +++ b/client-next/next.config.ts @@ -26,6 +26,11 @@ const securityHeaders = [ const nextConfig: NextConfig = { output: "standalone", + async rewrites() { + return process.env.STUDIO_PROXY_API === "1" + ? [{ source: "/api/:path*", destination: "http://127.0.0.1:1920/api/:path*" }] + : []; + }, async headers() { return [ { diff --git a/client-next/src/lib/api.ts b/client-next/src/lib/api.ts index 557a96a0..b474d54f 100644 --- a/client-next/src/lib/api.ts +++ b/client-next/src/lib/api.ts @@ -117,7 +117,9 @@ async function discoverBackendPort(): Promise { } } -const envApiUrl = process.env.NEXT_PUBLIC_API_URL; +const envApiUrl = process.env.NEXT_PUBLIC_STUDIO_PROXY === '1' && typeof window !== 'undefined' + ? `${window.location.origin}/api` + : process.env.NEXT_PUBLIC_API_URL; const CLIENT_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '1.17.0'; diff --git a/client-next/src/proxy.ts b/client-next/src/proxy.ts new file mode 100644 index 00000000..c4d8b2f5 --- /dev/null +++ b/client-next/src/proxy.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// The Next rewrite otherwise replaces the external Host with 127.0.0.1, +// defeating the backend's DNS-rebinding protection. Check it before rewriting. +export function proxy(request: NextRequest) { + if (process.env.STUDIO_PROXY_API !== '1') return NextResponse.next(); + try { + const configured = new URL(process.env.STUDIO_PUBLIC_ORIGIN || ''); + const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(configured.hostname); + if (configured.origin !== process.env.STUDIO_PUBLIC_ORIGIN || + !(configured.protocol === 'https:' || (configured.protocol === 'http:' && isLoopback)) || + request.headers.get('host')?.toLowerCase() !== configured.host.toLowerCase()) { + return new NextResponse('Untrusted host', { status: 403 }); + } + } catch { + return new NextResponse('Invalid configured origin', { status: 503 }); + } + return NextResponse.next(); +} + +export const config = { matcher: '/api/:path*' }; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..4b54d99c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + studio: + build: . + ports: + - "127.0.0.1:${STUDIO_PORT:-3000}:3000" + environment: + STUDIO_PUBLIC_ORIGIN: ${STUDIO_PUBLIC_ORIGIN:-} + volumes: + - studio-home:/home/node + restart: unless-stopped + +volumes: + studio-home: diff --git a/docs/PRODUCTION-DOCKER.md b/docs/PRODUCTION-DOCKER.md new file mode 100644 index 00000000..e9a20ce7 --- /dev/null +++ b/docs/PRODUCTION-DOCKER.md @@ -0,0 +1,37 @@ +# Production preview (draft) + +This setup builds the Next.js client ahead of time and starts it with the Studio +backend. The backend remains on container loopback; only the client port is +published, and Docker Compose binds it to host loopback by default. + +```sh +docker compose build +docker compose up -d +``` + +Open `http://127.0.0.1:3000`. Set `STUDIO_PORT` to change the host port; when +doing so, also set `STUDIO_PUBLIC_ORIGIN` to the exact browser origin, such as +`http://127.0.0.1:3001`. Data is stored in the `studio-home` volume. Back up +that volume before replacing it; it can contain credentials and configurations. + +For a local production build without Docker, install dependencies as described +in the main README, then use `npm run build` and `npm start`. `npm run dev` +retains the prior development behavior. `PORT` sets the client port. + +## Reverse proxy security + +The backend manages sensitive local configuration and has **no built-in remote +authentication**. Do not expose the container port or backend directly to the +internet. To use a remote HTTPS hostname, keep the loopback binding, configure +an independently authenticated reverse proxy on the host, and set +`STUDIO_PUBLIC_ORIGIN` to its exact HTTPS origin (no trailing slash or path). +TLS alone is not authentication. The origin check is not an authentication +mechanism; the proxy must protect every route, including `/api/` and upgrade +requests, before allowing external traffic. Do not trust forwarded headers +from the internet without a correctly configured proxy boundary. + +This draft does not yet provide an authenticated proxy configuration, automated +remote CLI connectivity, or a verified Docker build. Studio's backend manages +configuration on its own filesystem; it is not demonstrated to be an OpenCode +execution server to which a separate local CLI can connect. Do not rely on this +draft for remote access until those pieces are designed and tested. diff --git a/package.json b/package.json index 04a49eed..277a0919 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "version": "2.5.1", "private": false, "scripts": { - "start": "concurrently \"npm run server\" \"npm run client\"", + "start": "node scripts/start-production.cjs", + "dev": "concurrently \"npm run server\" \"npm run client\"", "server": "cd server && npm start", "client": "cd client-next && npm run dev", - "build": "cd client-next && npm run build", + "build": "node scripts/build-production.cjs", "postinstall": "cd server && npm install && cd ../client-next && npm install", "release-server": "cd server && npm publish" }, diff --git a/scripts/build-production.cjs b/scripts/build-production.cjs new file mode 100644 index 00000000..c8a1bd2b --- /dev/null +++ b/scripts/build-production.cjs @@ -0,0 +1,9 @@ +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); +const executable = path.resolve(__dirname, '../client-next/node_modules/next/dist/bin/next'); +const result = spawnSync(process.execPath, [executable, 'build'], { + cwd: path.resolve(__dirname, '../client-next'), stdio: 'inherit', + env: { ...process.env, STUDIO_PROXY_API: '1', NEXT_PUBLIC_STUDIO_PROXY: '1' }, +}); +if (result.error) console.error(result.error); +process.exit(result.status ?? 1); diff --git a/scripts/start-production.cjs b/scripts/start-production.cjs new file mode 100644 index 00000000..f3cd942b --- /dev/null +++ b/scripts/start-production.cjs @@ -0,0 +1,42 @@ +// Supervise both services without a development server or shell job control. +const { spawn } = require('node:child_process'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const standalone = process.env.STUDIO_STANDALONE === '1'; +const publicOrigin = process.env.STUDIO_PUBLIC_ORIGIN || `http://127.0.0.1:${process.env.PORT || '3000'}`; +const children = [ + spawn(process.execPath, [path.join(root, 'server/index.js')], { + stdio: 'inherit', env: { ...process.env, STUDIO_SERVICE: '1', + STUDIO_PUBLIC_ORIGIN: publicOrigin }, + }), + spawn(process.execPath, standalone + ? [path.join(root, 'client-next/.next/standalone/server.js')] + : [path.join(root, 'client-next/node_modules/next/dist/bin/next'), 'start', '-H', '127.0.0.1'], { + cwd: path.join(root, 'client-next'), stdio: 'inherit', + env: { ...process.env, STUDIO_PROXY_API: '1', STUDIO_PUBLIC_ORIGIN: publicOrigin, + ...(standalone ? { HOSTNAME: '0.0.0.0' } : {}) }, + }), +]; +let stopping = false; +function stop(code) { + if (stopping) return; + stopping = true; + for (const child of children) if (!child.killed) child.kill('SIGTERM'); + const timer = setTimeout(() => process.exit(code), 5000); + timer.unref(); + Promise.all(children.map(child => new Promise(resolve => { + if (child.exitCode !== null || child.signalCode !== null) resolve(); + else child.once('exit', resolve); + }))).then(() => process.exit(code)); +} +for (const child of children) { + child.once('error', error => { console.error(error); stop(1); }); + child.once('exit', (code, signal) => { + if (!stopping) { + console.error(`Service exited unexpectedly (${signal || code})`); + stop(1); + } + }); +} +for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => stop(0)); diff --git a/server/index.js b/server/index.js index c38b9eca..f8efcfee 100644 --- a/server/index.js +++ b/server/index.js @@ -129,6 +129,7 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; let idleTimer = null; function resetIdleTimer() { + if (process.env.STUDIO_SERVICE === '1') return; if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(() => { console.log('Server idle for 30 minutes, shutting down...'); @@ -5745,7 +5746,7 @@ async function startServer() { ['google', 'anthropic', 'openai', 'xai', 'openrouter', 'together', 'mistral', 'deepseek', 'amazon-bedrock', 'azure', 'github-copilot'].forEach(p => importCurrentAuthToPool(p)); - const server = await listenOnAvailablePort(app, DEFAULT_PORT, 10); + const server = await listenOnAvailablePort(app, DEFAULT_PORT, process.env.STUDIO_SERVICE === '1' ? 1 : 10); const port = server.address().port; { console.log(`Server running at http://127.0.0.1:${port}`); diff --git a/server/lib/cors-policy.js b/server/lib/cors-policy.js index 67699c78..b793915d 100644 --- a/server/lib/cors-policy.js +++ b/server/lib/cors-policy.js @@ -12,6 +12,11 @@ function isAllowedOrigin(origin) { // An Origin is exactly a serialized origin, never credentials, paths or lists. if (url.origin !== origin || url.username || url.password) return false; if (ALLOWED_ORIGINS.includes(origin)) return true; + // An explicitly configured deployment origin is exact, not a wildcard. + // The deployment must still authenticate at the reverse proxy. + if (process.env.STUDIO_PUBLIC_ORIGIN && origin === process.env.STUDIO_PUBLIC_ORIGIN && + (url.protocol === 'https:' || (url.protocol === 'http:' && + ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) return true; if (url.protocol !== 'http:' || !/^108\d$/.test(url.port)) return false; if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]') return true; const octets = url.hostname.split('.'); diff --git a/server/lib/cors-policy.test.js b/server/lib/cors-policy.test.js index d5298a9f..2fc0fb65 100644 --- a/server/lib/cors-policy.test.js +++ b/server/lib/cors-policy.test.js @@ -36,6 +36,24 @@ describe('cors policy', () => { assert.equal(isAllowedOrigin('https://example.com'), false); }); + it('accepts only the exact configured secure origin or explicit loopback origin', () => { + const previous = process.env.STUDIO_PUBLIC_ORIGIN; + try { + process.env.STUDIO_PUBLIC_ORIGIN = 'https://studio.example.com'; + assert.equal(isAllowedOrigin('https://studio.example.com'), true); + assert.equal(isAllowedOrigin('https://studio.example.com.evil.test'), false); + assert.equal(isAllowedOrigin('https://studio.example.com/path'), false); + process.env.STUDIO_PUBLIC_ORIGIN = 'http://studio.example.com'; + assert.equal(isAllowedOrigin('http://studio.example.com'), false); + process.env.STUDIO_PUBLIC_ORIGIN = 'http://127.0.0.1:3000'; + assert.equal(isAllowedOrigin('http://127.0.0.1:3000'), true); + assert.equal(isAllowedOrigin('http://127.0.0.1:3001'), false); + } finally { + if (previous === undefined) delete process.env.STUDIO_PUBLIC_ORIGIN; + else process.env.STUDIO_PUBLIC_ORIGIN = previous; + } + }); + it('keeps cors origin checks wired through the shared origin policy', () => { corsOptions.origin('https://opencode-studio.micr.dev', (error, allowed) => { assert.equal(error, null); diff --git a/start.bat b/start.bat index d69be870..8fb05c28 100644 --- a/start.bat +++ b/start.bat @@ -1,3 +1,3 @@ @echo off echo Starting Opencode Studio... -npm start +npm run dev diff --git a/start.sh b/start.sh old mode 100755 new mode 100644 index d3e4fdb8..6f6ea81e --- a/start.sh +++ b/start.sh @@ -1,3 +1,3 @@ #!/bin/bash echo "Starting Opencode Studio..." -npm start +npm run dev From ad293b724cd07df134fa9eb282bdde5c5963f848 Mon Sep 17 00:00:00 2001 From: Microck Date: Thu, 12 Nov 2026 10:55:39 +0100 Subject: [PATCH 2/9] feat: add opt-in authenticated HTTPS gateway --- .github/workflows/docker-preview.yml | 24 ++++++++++++++ CHANGELOG.md | 1 + deploy/Caddyfile | 8 +++++ docker-compose.proxy.yml | 22 +++++++++++++ docs/PRODUCTION-DOCKER.md | 49 ++++++++++++++++++++-------- 5 files changed, 90 insertions(+), 14 deletions(-) create mode 100644 deploy/Caddyfile create mode 100644 docker-compose.proxy.yml diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 1f8e5926..eff37287 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -6,6 +6,8 @@ on: - '.dockerignore' - 'docker-compose.yml' - 'scripts/**' + - 'deploy/**' + - 'docker-compose.proxy.yml' - 'server/**' - 'client-next/**' - '.github/workflows/docker-preview.yml' @@ -36,3 +38,25 @@ jobs: test "$code" = 403 code=$(curl -sS -o /dev/null -w '%{http_code}' -H 'Host: evil.example' http://127.0.0.1:3300/api/health) test "$code" = 403 + - name: Test authenticated gateway + run: | + set -eu + docker network create studio-ci + docker run -d --name studio -p 127.0.0.1:3300:3000 --network studio-ci \ + -e STUDIO_PUBLIC_ORIGIN=https://localhost studio-pr-test + HASH=$(docker run --rm caddy:2 caddy hash-password --plaintext ci-test-password) + docker run -d --name studio-proxy --network studio-ci -p 127.0.0.1:3301:443 \ + -e STUDIO_SITE=localhost -e STUDIO_USER=ci -e STUDIO_PASSWORD_HASH="$HASH" \ + -v "$PWD/deploy/Caddyfile:/etc/caddy/Caddyfile:ro" caddy:2 + trap 'docker logs studio-proxy; docker logs studio; docker rm -f studio-proxy studio; docker network rm studio-ci' EXIT + for n in $(seq 1 60); do + code=$(curl -ks -o /dev/null -w '%{http_code}' -H 'Host: localhost' https://localhost:3301/ || true) + [ "$code" = 401 ] && break + sleep 2 + done + test "$code" = 401 + code=$(curl -ks -o /dev/null -w '%{http_code}' -u ci:wrong -H 'Host: localhost' https://localhost:3301/api/health) + test "$code" = 401 + curl -kfsS -u ci:ci-test-password -H 'Host: localhost' -H 'Origin: https://localhost' https://localhost:3301/api/health >/dev/null + code=$(curl -ks -o /dev/null -w '%{http_code}' -u ci:ci-test-password -H 'Host: other.example.test' https://localhost:3301/api/health) + test "$code" != 200 diff --git a/CHANGELOG.md b/CHANGELOG.md index 539c912c..eecbf55c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added - Draft production client build, loopback-bound Docker setup and deployment security guidance. +- Optional authenticated HTTPS Caddy gateway example (pending hosted verification). ### Changed - Separate `npm run dev` from the built production `npm start` workflow. diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 00000000..852c74b2 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,8 @@ +https://{$STUDIO_SITE} { + basic_auth { + {env.STUDIO_USER} {env.STUDIO_PASSWORD_HASH} + } + reverse_proxy studio:3000 { + header_up -Authorization + } +} diff --git a/docker-compose.proxy.yml b/docker-compose.proxy.yml new file mode 100644 index 00000000..2ace4100 --- /dev/null +++ b/docker-compose.proxy.yml @@ -0,0 +1,22 @@ +# Optional public HTTPS gateway. Never start without reviewing docs/PRODUCTION-DOCKER.md. +services: + caddy: + image: caddy:2 + ports: + - "80:80" + - "443:443" + environment: + STUDIO_SITE: ${STUDIO_SITE:?Set STUDIO_SITE to your public DNS hostname} + STUDIO_USER: ${STUDIO_USER:?Set STUDIO_USER} + STUDIO_PASSWORD_HASH: ${STUDIO_PASSWORD_HASH:?Set a Caddy password hash} + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + - studio + restart: unless-stopped + +volumes: + caddy-data: + caddy-config: diff --git a/docs/PRODUCTION-DOCKER.md b/docs/PRODUCTION-DOCKER.md index e9a20ce7..2176761f 100644 --- a/docs/PRODUCTION-DOCKER.md +++ b/docs/PRODUCTION-DOCKER.md @@ -21,17 +21,38 @@ retains the prior development behavior. `PORT` sets the client port. ## Reverse proxy security The backend manages sensitive local configuration and has **no built-in remote -authentication**. Do not expose the container port or backend directly to the -internet. To use a remote HTTPS hostname, keep the loopback binding, configure -an independently authenticated reverse proxy on the host, and set -`STUDIO_PUBLIC_ORIGIN` to its exact HTTPS origin (no trailing slash or path). -TLS alone is not authentication. The origin check is not an authentication -mechanism; the proxy must protect every route, including `/api/` and upgrade -requests, before allowing external traffic. Do not trust forwarded headers -from the internet without a correctly configured proxy boundary. - -This draft does not yet provide an authenticated proxy configuration, automated -remote CLI connectivity, or a verified Docker build. Studio's backend manages -configuration on its own filesystem; it is not demonstrated to be an OpenCode -execution server to which a separate local CLI can connect. Do not rely on this -draft for remote access until those pieces are designed and tested. +authentication**. Do not expose the Studio container or backend directly to the +internet. TLS alone and the Studio origin check are not authentication. + +An optional Caddy gateway example protects all routes with HTTP Basic auth and +uses HTTPS. It opens ports 80 and 443, so use it only on a host you intend to +expose, with DNS pointing to that host and a valid publicly trusted certificate. +Choose a strong unique password and generate its bcrypt hash interactively using +`docker run --rm -it caddy:2 caddy hash-password`. Do not put the plaintext +password in shell history or the repository. Place values in an access-restricted +`.env` file outside version control. Single-quote the hash in `.env` so Compose +does not interpolate its `$` characters. Container administrators can read the +hash from the environment. Example keys (replace placeholders, do not use the +example values): + +```dotenv +STUDIO_SITE=studio.example.com +STUDIO_PUBLIC_ORIGIN=https://studio.example.com +STUDIO_USER=replace-me +STUDIO_PASSWORD_HASH='replace-with-bcrypt-hash' +``` + +Then start `docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d --build`. +The hostname and exact HTTPS origin must match. The Studio port remains bound to +host loopback. Protect the host itself, limit access at the network layer, and +add monitoring/rate limiting appropriate to the deployment; Basic auth alone +does not provide rate limiting or MFA. An existing authenticated HTTPS reverse +proxy can be used instead, provided it protects every route including `/api/`, +preserves the configured Host, and does not trust arbitrary forwarded headers. + +The proxy example is pending CI verification and independent security review. +Studio's backend manages configuration on its own filesystem; it is not the +OpenCode execution server. The [OpenCode CLI documentation](https://opencode.ai/docs/cli/web) +describes a separate password-protected `opencode serve` and `opencode --server` +connection. This draft does not install, start, connect, or test that separate +service. Do not assume the CLI can connect to Studio's API or shares its data. From d01bd6a905234976b421ce2f10097d8871825a43 Mon Sep 17 00:00:00 2001 From: Microck Date: Thu, 12 Nov 2026 10:58:25 +0100 Subject: [PATCH 3/9] feat: prototype isolated OpenCode server and harden gateway --- .github/workflows/docker-preview.yml | 22 +++ CHANGELOG.md | 1 + deploy/Caddyfile | 5 + deploy/opencode-cli/Dockerfile | 9 ++ deploy/opencode-cli/package-lock.json | 205 ++++++++++++++++++++++++++ deploy/opencode-cli/package.json | 1 + docker-compose.cli.yml | 17 +++ docs/PRODUCTION-DOCKER.md | 28 +++- 8 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 deploy/opencode-cli/Dockerfile create mode 100644 deploy/opencode-cli/package-lock.json create mode 100644 deploy/opencode-cli/package.json create mode 100644 docker-compose.cli.yml diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index eff37287..d59b39a1 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -8,6 +8,7 @@ on: - 'scripts/**' - 'deploy/**' - 'docker-compose.proxy.yml' + - 'docker-compose.cli.yml' - 'server/**' - 'client-next/**' - '.github/workflows/docker-preview.yml' @@ -54,9 +55,30 @@ jobs: [ "$code" = 401 ] && break sleep 2 done + echo "Unauthenticated gateway response: $code" test "$code" = 401 code=$(curl -ks -o /dev/null -w '%{http_code}' -u ci:wrong -H 'Host: localhost' https://localhost:3301/api/health) + echo "Wrong-password response: $code" test "$code" = 401 curl -kfsS -u ci:ci-test-password -H 'Host: localhost' -H 'Origin: https://localhost' https://localhost:3301/api/health >/dev/null code=$(curl -ks -o /dev/null -w '%{http_code}' -u ci:ci-test-password -H 'Host: other.example.test' https://localhost:3301/api/health) + echo "Untrusted-host response: $code" test "$code" != 200 + - name: Test separate OpenCode server authentication + run: | + set -eu + docker build -f deploy/opencode-cli/Dockerfile -t studio-cli-test . + docker run -d --name studio-cli -p 127.0.0.1:3400:4096 \ + -e OPENCODE_PASSWORD=ci-only-password studio-cli-test + trap 'docker rm -f studio-cli' EXIT + for n in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3400/api/info || true) + [ "$code" = 401 ] && break + sleep 2 + done + test "$code" = 401 + curl -fsS -u opencode:ci-only-password http://127.0.0.1:3400/api/info >/dev/null + if docker logs studio-cli 2>&1 | grep -F 'ci-only-password'; then + echo 'OpenCode printed its configured password; do not use this deployment.' >&2 + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index eecbf55c..878bd24a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ### Added - Draft production client build, loopback-bound Docker setup and deployment security guidance. - Optional authenticated HTTPS Caddy gateway example (pending hosted verification). +- Optional separately authenticated OpenCode server and loopback/SSH connection example (pending hosted verification). ### Changed - Separate `npm run dev` from the built production `npm start` workflow. diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 852c74b2..d8b91e72 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -6,3 +6,8 @@ https://{$STUDIO_SITE} { header_up -Authorization } } + +# Caddy otherwise answers an unmatched Host with an empty 200. Fail closed. +:443 { + respond 421 +} diff --git a/deploy/opencode-cli/Dockerfile b/deploy/opencode-cli/Dockerfile new file mode 100644 index 00000000..93393304 --- /dev/null +++ b/deploy/opencode-cli/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-bookworm-slim +WORKDIR /opt/opencode +COPY deploy/opencode-cli/package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force && mkdir /workspace && chown node:node /workspace +ENV HOME=/home/node NODE_ENV=production +USER node +WORKDIR /workspace +EXPOSE 4096 +ENTRYPOINT ["/opt/opencode/node_modules/.bin/opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"] diff --git a/deploy/opencode-cli/package-lock.json b/deploy/opencode-cli/package-lock.json new file mode 100644 index 00000000..07322a59 --- /dev/null +++ b/deploy/opencode-cli/package-lock.json @@ -0,0 +1,205 @@ +{ + "name": "studio-opencode-runtime", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "studio-opencode-runtime", + "version": "1.0.0", + "dependencies": { + "@opencode/cli": "2.2.7" + } + }, + "node_modules/@opencode/cli": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli/-/cli-2.2.7.tgz", + "integrity": "sha512-qsSExDfpUCqhGXBqZ8xhjg2OG9DxnsnfWl1/7vGINRWw7sxZvK9cXeW6MI8j9e+GItaYtg94zH3Eo2ng86VLCg==", + "cpu": [ + "arm64", + "x64" + ], + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "bin": { + "opencode": "bin/opencode.exe", + "opencode2": "bin/opencode.exe" + }, + "optionalDependencies": { + "@opencode/cli-darwin-arm64": "2.2.7", + "@opencode/cli-darwin-x64": "2.2.7", + "@opencode/cli-darwin-x64-baseline": "2.2.7", + "@opencode/cli-linux-arm64": "2.2.7", + "@opencode/cli-linux-arm64-musl": "2.2.7", + "@opencode/cli-linux-x64": "2.2.7", + "@opencode/cli-linux-x64-baseline": "2.2.7", + "@opencode/cli-linux-x64-baseline-musl": "2.2.7", + "@opencode/cli-linux-x64-musl": "2.2.7", + "@opencode/cli-windows-arm64": "2.2.7", + "@opencode/cli-windows-x64": "2.2.7", + "@opencode/cli-windows-x64-baseline": "2.2.7" + } + }, + "node_modules/@opencode/cli-darwin-arm64": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-darwin-arm64/-/cli-darwin-arm64-2.2.7.tgz", + "integrity": "sha512-KH45DjiTcsHcnVLDrAEP3kdG57olO2BndtpYi+iGc7/cBrZfUs6YYrVpsZ40lr4izTupSIxwTbvSwKHutuq5uw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opencode/cli-darwin-x64": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-darwin-x64/-/cli-darwin-x64-2.2.7.tgz", + "integrity": "sha512-6+US0PhgH8BaCQzkUGey+tz1gtHlhGxnKJspnETlcWOuZWQmyaZaF3wFf3L8+9+ukQsXV191tMG+jDIZefPjrg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opencode/cli-darwin-x64-baseline": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-darwin-x64-baseline/-/cli-darwin-x64-baseline-2.2.7.tgz", + "integrity": "sha512-2fTG/E3oEy+RM9d148d3XQ6maMOD/hK5p1NXIQKIvXtMRWRMheZ0HSpkInrWFaw73qp6Be0w1X8/BfAz9F0rdQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opencode/cli-linux-arm64": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-linux-arm64/-/cli-linux-arm64-2.2.7.tgz", + "integrity": "sha512-AgxoB3IxewSilPpWjU6GrdyJ/dSBnjncpHoZRK7Kul96bgVSEUKuPwC8nUYjTTXgLHqM+tqImwMLOlwCulzSxw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode/cli-linux-arm64-musl": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.7.tgz", + "integrity": "sha512-bfkdZxcfuueJUy6+mduxoibp5In2stNIVJUDlyxEp6tk3N/6Pr9Lz6bjzeKCwUWsGxEvrF225jyLt4vwL+bYRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode/cli-linux-x64": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-2.2.7.tgz", + "integrity": "sha512-wk/DPHxr4maiW+FmQp8ztoXlo/vC5qsmEIHsLNaNLZL2iaIYfhyqFRe6CRsCVxy+ib0sfK07X2IepFw7ZSKqNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode/cli-linux-x64-baseline": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-linux-x64-baseline/-/cli-linux-x64-baseline-2.2.7.tgz", + "integrity": "sha512-1X6fFT4VWyzMxIGWK1Lg29NEr6XEOYYUXMXSPbfSmbEHdqTLDcO9DlTTaebq4uBbVw/tQpIGJw4sBBrKb9tKMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode/cli-linux-x64-baseline-musl": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-2.2.7.tgz", + "integrity": "sha512-8/UdXfTW/fLc+02R54/Qgg++cBWN+ZKes0ZNgvHAEybJqc6U0XOVca1DRBtcOjLs4HzX4WIIQdBw9aivM6FQmw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode/cli-linux-x64-musl": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.7.tgz", + "integrity": "sha512-LyE+YntGyYN87ZiILBGS3STQgzovYpOaGXi5N15IQzJymqIhVG70lJznp3tdByo4p+19irNgia94rQR8CjvsFA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opencode/cli-windows-arm64": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-windows-arm64/-/cli-windows-arm64-2.2.7.tgz", + "integrity": "sha512-pU2iskqUVeY7MzP5ztc7yXXwCb8UkVzid3WEFMsrXqym1NmM6EI8meu14FT+wbtXkLEr6gFP3st8HErWzoV1OA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode/cli-windows-x64": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-windows-x64/-/cli-windows-x64-2.2.7.tgz", + "integrity": "sha512-SnbgW2ofeGhFXMZCWZWnSbrpU4czgWr4itUFJfAsz/SonGEQXRxghnTwONk6KmZSkWD50B/X8fAGs2fgknptXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode/cli-windows-x64-baseline": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@opencode/cli-windows-x64-baseline/-/cli-windows-x64-baseline-2.2.7.tgz", + "integrity": "sha512-l4o0UQT2n8TaxV18Ir846JUW2r2XUN2z+auGUq2fQ1s08/+ycJv7qjqRE8u4nmjm/VC7zOjxhgMU2USg0404Ag==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + } + } +} diff --git a/deploy/opencode-cli/package.json b/deploy/opencode-cli/package.json new file mode 100644 index 00000000..e4f60f78 --- /dev/null +++ b/deploy/opencode-cli/package.json @@ -0,0 +1 @@ +{"name":"studio-opencode-runtime","private":true,"version":"1.0.0","dependencies":{"@opencode/cli":"2.2.7"}} diff --git a/docker-compose.cli.yml b/docker-compose.cli.yml new file mode 100644 index 00000000..5eb9c845 --- /dev/null +++ b/docker-compose.cli.yml @@ -0,0 +1,17 @@ +# Optional separate execution server. Exposes only host loopback; see the guide. +services: + opencode: + build: + context: . + dockerfile: deploy/opencode-cli/Dockerfile + environment: + OPENCODE_PASSWORD: ${OPENCODE_PASSWORD:?Set a strong unique OpenCode server password} + ports: + - "127.0.0.1:${OPENCODE_PORT:-4096}:4096" + volumes: + - studio-home:/home/node + - opencode-workspace:/workspace + restart: unless-stopped + +volumes: + opencode-workspace: diff --git a/docs/PRODUCTION-DOCKER.md b/docs/PRODUCTION-DOCKER.md index 2176761f..0c6ddaec 100644 --- a/docs/PRODUCTION-DOCKER.md +++ b/docs/PRODUCTION-DOCKER.md @@ -51,8 +51,26 @@ proxy can be used instead, provided it protects every route including `/api/`, preserves the configured Host, and does not trust arbitrary forwarded headers. The proxy example is pending CI verification and independent security review. -Studio's backend manages configuration on its own filesystem; it is not the -OpenCode execution server. The [OpenCode CLI documentation](https://opencode.ai/docs/cli/web) -describes a separate password-protected `opencode serve` and `opencode --server` -connection. This draft does not install, start, connect, or test that separate -service. Do not assume the CLI can connect to Studio's API or shares its data. + +## Separate OpenCode execution server (draft) + +Studio's API manages configuration; it is not the OpenCode execution server. +The optional `docker-compose.cli.yml` starts a separate, pinned OpenCode v2 +server, sharing Studio's home volume and a separate workspace volume. It binds +port 4096 to host loopback, not the public network. It requires a strong, +unique `OPENCODE_PASSWORD`; supply it privately, not in commands, logs or +version control. Environment variables can be inspected by container +administrators. The connected user can execute commands and modify files in the +server's workspace, so grant access only to trusted users and do not mount +Docker sockets or sensitive host directories. Configure model access and costs +separately; this setup does not run a model. + +After reviewing this draft and setting the password, start the optional service +with `docker compose -f docker-compose.yml -f docker-compose.cli.yml up -d --build`. +For remote access, use an authenticated SSH tunnel to the host's loopback port, +then connect with `opencode --server http://127.0.0.1:4096`; provide the same +password via `OPENCODE_PASSWORD` in the local CLI environment. See the +[official OpenCode server documentation](https://opencode.ai/docs/cli/web). +This draft does not expose the execution server through the Caddy gateway. +The container build, authentication and shared configuration behavior are +pending hosted verification; do not depend on this preview yet. From f8846ccb744f3de052c995c51b48df2326cdc01d Mon Sep 17 00:00:00 2001 From: Microck Date: Thu, 12 Nov 2026 11:09:32 +0100 Subject: [PATCH 4/9] test: exercise shared configuration and CLI connection --- .github/workflows/docker-preview.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index d59b39a1..ebbea59d 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -68,9 +68,14 @@ jobs: run: | set -eu docker build -f deploy/opencode-cli/Dockerfile -t studio-cli-test . + docker volume create studio-ci-home + docker run --rm -v studio-ci-home:/home/node --entrypoint sh studio-pr-test -c \ + 'mkdir -p /home/node/.config/opencode && printf "{\"username\":\"studio-ci-marker\"}" > /home/node/.config/opencode/opencode.json' + docker run -d --name studio-shared -p 127.0.0.1:3500:3000 \ + -e STUDIO_PUBLIC_ORIGIN=http://127.0.0.1:3500 -v studio-ci-home:/home/node studio-pr-test docker run -d --name studio-cli -p 127.0.0.1:3400:4096 \ - -e OPENCODE_PASSWORD=ci-only-password studio-cli-test - trap 'docker rm -f studio-cli' EXIT + -e OPENCODE_PASSWORD=ci-only-password -v studio-ci-home:/home/node studio-cli-test + trap 'docker rm -f studio-cli studio-shared; docker volume rm studio-ci-home' EXIT for n in $(seq 1 60); do code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3400/api/info || true) [ "$code" = 401 ] && break @@ -78,6 +83,9 @@ jobs: done test "$code" = 401 curl -fsS -u opencode:ci-only-password http://127.0.0.1:3400/api/info >/dev/null + curl -fsS -u opencode:ci-only-password http://127.0.0.1:3400/api/config | grep -F 'studio-ci-marker' + curl -fsS http://127.0.0.1:3500/api/config | grep -F 'studio-ci-marker' + docker exec studio-cli /opt/opencode/node_modules/.bin/opencode api --server http://127.0.0.1:4096 GET /api/info >/dev/null if docker logs studio-cli 2>&1 | grep -F 'ci-only-password'; then echo 'OpenCode printed its configured password; do not use this deployment.' >&2 exit 1 From 0baf1fec57619cf30a779f71f6e494562546d2eb Mon Sep 17 00:00:00 2001 From: Microck Date: Thu, 12 Nov 2026 11:17:29 +0100 Subject: [PATCH 5/9] docs: record Docker and CLI verification limits --- CHANGELOG.md | 3 +-- docs/PRODUCTION-DOCKER.md | 10 +++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 878bd24a..f2063e4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,7 @@ All notable changes to this project will be documented in this file. ### Added - Draft production client build, loopback-bound Docker setup and deployment security guidance. -- Optional authenticated HTTPS Caddy gateway example (pending hosted verification). -- Optional separately authenticated OpenCode server and loopback/SSH connection example (pending hosted verification). +- Optional authenticated HTTPS Caddy gateway and separate OpenCode server with loopback/SSH connection guidance. ### Changed - Separate `npm run dev` from the built production `npm start` workflow. diff --git a/docs/PRODUCTION-DOCKER.md b/docs/PRODUCTION-DOCKER.md index 0c6ddaec..4cbda208 100644 --- a/docs/PRODUCTION-DOCKER.md +++ b/docs/PRODUCTION-DOCKER.md @@ -50,7 +50,9 @@ does not provide rate limiting or MFA. An existing authenticated HTTPS reverse proxy can be used instead, provided it protects every route including `/api/`, preserves the configured Host, and does not trust arbitrary forwarded headers. -The proxy example is pending CI verification and independent security review. +CI has exercised gateway authentication and untrusted-host rejection using a +local test certificate. An independent security review is still required before +using this draft remotely; public certificate issuance was not tested. ## Separate OpenCode execution server (draft) @@ -72,5 +74,7 @@ then connect with `opencode --server http://127.0.0.1:4096`; provide the same password via `OPENCODE_PASSWORD` in the local CLI environment. See the [official OpenCode server documentation](https://opencode.ai/docs/cli/web). This draft does not expose the execution server through the Caddy gateway. -The container build, authentication and shared configuration behavior are -pending hosted verification; do not depend on this preview yet. +CI has built the container, checked API authentication, connected the OpenCode +CLI to the server, and observed a synthetic shared configuration from both +services. It has not exercised an actual SSH tunnel, real credentials, model +calls or a public deployment. Do not depend on this preview yet. From 25ea7850c39cb7d3a737d1db941b39d601209371 Mon Sep 17 00:00:00 2001 From: Microck Date: Thu, 12 Nov 2026 11:29:47 +0100 Subject: [PATCH 6/9] fix: isolate Docker secrets and recover shared locks --- .dockerignore | 1 + .github/workflows/docker-preview.yml | 30 ++++++++++++++++++++++++++-- CHANGELOG.md | 3 +++ Dockerfile | 2 ++ docs/PRODUCTION-DOCKER.md | 4 ++++ scripts/start-production.cjs | 12 +++++++++-- server/index.js | 6 +++++- 7 files changed, 53 insertions(+), 5 deletions(-) diff --git a/.dockerignore b/.dockerignore index ab61c7a3..e2380c30 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,4 +2,5 @@ **/node_modules **/.next .env* +**/.env* **/*.log diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index ebbea59d..d141b67c 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -25,10 +25,15 @@ jobs: - name: Build and smoke-test the image run: | set -eu + printf '%s\n' 'synthetic-only' > .env + printf '%s\n' 'synthetic-only' > client-next/.env + printf '%s\n' 'synthetic-only' > client-next/.env.production docker build -t studio-pr-test . + rm .env client-next/.env client-next/.env.production + docker volume create studio-restart-home docker run -d --name studio-pr-test -p 127.0.0.1:3300:3000 \ - -e STUDIO_PUBLIC_ORIGIN=http://127.0.0.1:3300 studio-pr-test - trap 'docker logs studio-pr-test; docker rm -f studio-pr-test' EXIT + -e STUDIO_PUBLIC_ORIGIN=http://127.0.0.1:3300 -v studio-restart-home:/home/node studio-pr-test + trap 'docker logs studio-pr-test; docker rm -f studio-pr-test studio-conflict 2>/dev/null || true; docker volume rm studio-restart-home' EXIT for n in $(seq 1 60); do if curl -fsS http://127.0.0.1:3300/api/health >/dev/null; then break; fi sleep 2 @@ -39,6 +44,19 @@ jobs: test "$code" = 403 code=$(curl -sS -o /dev/null -w '%{http_code}' -H 'Host: evil.example' http://127.0.0.1:3300/api/health) test "$code" = 403 + # A second container sharing the same data must not acquire the lock. + docker run -d --name studio-conflict -v studio-restart-home:/home/node studio-pr-test + test "$(docker wait studio-conflict)" != 0 + docker rm studio-conflict + curl -fsS http://127.0.0.1:3300/api/health >/dev/null + # An unclean container stop must release the kernel lock and recover. + docker kill --signal KILL studio-pr-test + docker start studio-pr-test + for n in $(seq 1 60); do + if curl -fsS http://127.0.0.1:3300/api/health >/dev/null; then break; fi + sleep 2 + done + curl -fsS http://127.0.0.1:3300/api/health >/dev/null - name: Test authenticated gateway run: | set -eu @@ -60,6 +78,10 @@ jobs: code=$(curl -ks -o /dev/null -w '%{http_code}' -u ci:wrong -H 'Host: localhost' https://localhost:3301/api/health) echo "Wrong-password response: $code" test "$code" = 401 + for n in $(seq 1 60); do + if curl -kfsS -u ci:ci-test-password -H 'Host: localhost' -H 'Origin: https://localhost' https://localhost:3301/api/health >/dev/null; then break; fi + sleep 2 + done curl -kfsS -u ci:ci-test-password -H 'Host: localhost' -H 'Origin: https://localhost' https://localhost:3301/api/health >/dev/null code=$(curl -ks -o /dev/null -w '%{http_code}' -u ci:ci-test-password -H 'Host: other.example.test' https://localhost:3301/api/health) echo "Untrusted-host response: $code" @@ -84,6 +106,10 @@ jobs: test "$code" = 401 curl -fsS -u opencode:ci-only-password http://127.0.0.1:3400/api/info >/dev/null curl -fsS -u opencode:ci-only-password http://127.0.0.1:3400/api/config | grep -F 'studio-ci-marker' + for n in $(seq 1 60); do + if curl -fsS http://127.0.0.1:3500/api/health >/dev/null; then break; fi + sleep 2 + done curl -fsS http://127.0.0.1:3500/api/config | grep -F 'studio-ci-marker' docker exec studio-cli /opt/opencode/node_modules/.bin/opencode api --server http://127.0.0.1:4096 GET /api/info >/dev/null if docker logs studio-cli 2>&1 | grep -F 'ci-only-password'; then diff --git a/CHANGELOG.md b/CHANGELOG.md index f2063e4b..0f4adf0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ All notable changes to this project will be documented in this file. ### Changed - Separate `npm run dev` from the built production `npm start` workflow. +### Fixed +- Exclude nested environment files from Docker contexts and guard shared container data with a crash-released lock. + ## [2.5.1] - 2026-10-24 ### Fixed diff --git a/Dockerfile b/Dockerfile index 1624bea9..bf984680 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,12 +3,14 @@ WORKDIR /app COPY client-next/package*.json ./client-next/ RUN cd client-next && npm ci --ignore-scripts COPY client-next/ ./client-next/ +RUN test ! -e client-next/.env && test ! -e client-next/.env.production ENV STUDIO_PROXY_API=1 NEXT_PUBLIC_STUDIO_PROXY=1 NEXT_TELEMETRY_DISABLED=1 RUN cd client-next && npm run build FROM node:22-bookworm-slim AS runtime ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOME=/home/node STUDIO_STANDALONE=1 WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends util-linux && rm -rf /var/lib/apt/lists/* COPY server/package*.json ./server/ RUN cd server && npm ci --omit=dev --ignore-scripts && npm cache clean --force COPY server/ ./server/ diff --git a/docs/PRODUCTION-DOCKER.md b/docs/PRODUCTION-DOCKER.md index 4cbda208..1dafd204 100644 --- a/docs/PRODUCTION-DOCKER.md +++ b/docs/PRODUCTION-DOCKER.md @@ -13,6 +13,10 @@ Open `http://127.0.0.1:3000`. Set `STUDIO_PORT` to change the host port; when doing so, also set `STUDIO_PUBLIC_ORIGIN` to the exact browser origin, such as `http://127.0.0.1:3001`. Data is stored in the `studio-home` volume. Back up that volume before replacing it; it can contain credentials and configurations. +The container supervisor uses a kernel advisory lock in that volume to prevent +two Studio containers from writing the same home concurrently, and releases it +on a crash. Use a local Docker volume with working `flock` semantics; distributed +or network-backed volume locking has not been verified. For a local production build without Docker, install dependencies as described in the main README, then use `npm run build` and `npm start`. `npm run dev` diff --git a/scripts/start-production.cjs b/scripts/start-production.cjs index f3cd942b..220258c9 100644 --- a/scripts/start-production.cjs +++ b/scripts/start-production.cjs @@ -1,14 +1,22 @@ // Supervise both services without a development server or shell job control. const { spawn } = require('node:child_process'); const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); const root = path.resolve(__dirname, '..'); const standalone = process.env.STUDIO_STANDALONE === '1'; const publicOrigin = process.env.STUDIO_PUBLIC_ORIGIN || `http://127.0.0.1:${process.env.PORT || '3000'}`; +const lockDir = path.join(process.env.HOME || '', '.config', 'opencode-studio'); +if (standalone) fs.mkdirSync(lockDir, { recursive: true }); const children = [ - spawn(process.execPath, [path.join(root, 'server/index.js')], { + spawn(standalone ? 'flock' : process.execPath, standalone + ? ['--nonblock', '--no-fork', path.join(lockDir, 'container.lock'), '/bin/sh', '-c', + 'rm -f -- "$1"; exec "$2" "$3"', '--', path.join(os.tmpdir(), 'opencode-studio-server.lock.json'), + process.execPath, path.join(root, 'server/index.js')] + : [path.join(root, 'server/index.js')], { stdio: 'inherit', env: { ...process.env, STUDIO_SERVICE: '1', - STUDIO_PUBLIC_ORIGIN: publicOrigin }, + STUDIO_PUBLIC_ORIGIN: publicOrigin, ...(standalone ? { STUDIO_CONTAINER_LOCK: '1' } : {}) }, }), spawn(process.execPath, standalone ? [path.join(root, 'client-next/.next/standalone/server.js')] diff --git a/server/index.js b/server/index.js index f8efcfee..9fd618b9 100644 --- a/server/index.js +++ b/server/index.js @@ -169,7 +169,11 @@ const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'st const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json'); const ANTIGRAVITY_ACCOUNTS_PATH = path.join(HOME_DIR, '.config', 'opencode', 'antigravity-accounts.json'); const LOG_DIR = path.join(HOME_DIR, '.local', 'share', 'opencode', 'log'); -const SERVER_LOCK_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'server.lock.json'); +// Container supervisor holds a kernel advisory lock on the shared volume. Its +// PID file must be ephemeral because PID namespaces are reused on restart. +const SERVER_LOCK_PATH = process.env.STUDIO_CONTAINER_LOCK === '1' + ? path.join(os.tmpdir(), 'opencode-studio-server.lock.json') + : path.join(HOME_DIR, '.config', 'opencode-studio', 'server.lock.json'); let lockFileDescriptor = null; From f9d7ad78c3c3be6bb2e4fb6fc5fdace71d102170 Mon Sep 17 00:00:00 2001 From: Microck Date: Thu, 12 Nov 2026 11:42:15 +0100 Subject: [PATCH 7/9] test: exercise production interface in browser --- .github/workflows/docker-preview.yml | 19 ++++++++++++ CHANGELOG.md | 1 + client-next/package-lock.json | 30 +++++++++++++++++++ client-next/package.json | 1 + .../scripts/production-browser-smoke.mjs | 24 +++++++++++++++ 5 files changed, 75 insertions(+) create mode 100644 client-next/scripts/production-browser-smoke.mjs diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index d141b67c..8489c61a 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -86,6 +86,25 @@ jobs: code=$(curl -ks -o /dev/null -w '%{http_code}' -u ci:ci-test-password -H 'Host: other.example.test' https://localhost:3301/api/health) echo "Untrusted-host response: $code" test "$code" != 200 + - name: Test production browser navigation + run: | + set -eu + cd client-next + npm ci --ignore-scripts + npx --no-install playwright install chromium + cd .. + docker volume create studio-browser-home + docker run --rm -v studio-browser-home:/home/node --entrypoint sh studio-pr-test -c \ + 'mkdir -p /home/node/.config/opencode && printf "{\"username\":\"browser-fixture\"}" > /home/node/.config/opencode/opencode.json' + docker run -d --name studio-browser -p 127.0.0.1:3300:3000 \ + -e STUDIO_PUBLIC_ORIGIN=http://127.0.0.1:3300 -v studio-browser-home:/home/node studio-pr-test + trap 'docker logs studio-browser; docker rm -f studio-browser; docker volume rm studio-browser-home' EXIT + for n in $(seq 1 60); do + if curl -fsS http://127.0.0.1:3300/api/health >/dev/null; then break; fi + sleep 2 + done + cd client-next + node scripts/production-browser-smoke.mjs - name: Test separate OpenCode server authentication run: | set -eu diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4adf0d..30947761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. ### Fixed - Exclude nested environment files from Docker contexts and guard shared container data with a crash-released lock. +- Add a production browser smoke test against synthetic local configuration. ## [2.5.1] - 2026-10-24 diff --git a/client-next/package-lock.json b/client-next/package-lock.json index 4f7b1a0d..459adeb3 100644 --- a/client-next/package-lock.json +++ b/client-next/package-lock.json @@ -50,6 +50,7 @@ "eslint": "^9", "eslint-config-next": "16.1.1", "jsdom": "^30.1.3", + "playwright": "1.65.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5", @@ -8622,6 +8623,35 @@ "integrity": "sha512-KCPRDCPq4ZzY2NQfVqQQo7lWWQxxoRudocRLMQVunfYBQmKye8enTY6tP92rdZKcUAhQTqdgr7m9NXT/ZYHtgg==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/po-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.2.0.tgz", diff --git a/client-next/package.json b/client-next/package.json index 9c5426ad..e69a262f 100644 --- a/client-next/package.json +++ b/client-next/package.json @@ -53,6 +53,7 @@ "eslint": "^9", "eslint-config-next": "16.1.1", "jsdom": "^30.1.3", + "playwright": "1.65.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5", diff --git a/client-next/scripts/production-browser-smoke.mjs b/client-next/scripts/production-browser-smoke.mjs new file mode 100644 index 00000000..d9d4d08b --- /dev/null +++ b/client-next/scripts/production-browser-smoke.mjs @@ -0,0 +1,24 @@ +import { chromium } from 'playwright'; +import assert from 'node:assert/strict'; + +// The container is populated with a synthetic config by CI; no user data or +// model calls are involved. Keep Chromium's sandbox enabled. +const browser = await chromium.launch({ headless: true, chromiumSandbox: true }); +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + const configResponse = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/config' && response.status() === 200, + ); + await page.goto('http://127.0.0.1:3300/', { waitUntil: 'domcontentloaded' }); + await configResponse; + await page.locator('#main-content').waitFor({ state: 'visible', timeout: 30000 }); + await page.locator('a[href="/skills"]').click(); + await page.waitForURL('**/skills'); + await page.locator('#main-content').waitFor({ state: 'visible' }); + assert.deepEqual(errors, [], 'Unexpected browser page errors'); + console.log('Production browser loaded config and navigated to Skills.'); +} finally { + await browser.close(); +} From f04d5547568e2cdd235538d24b0b7995c705dfad Mon Sep 17 00:00:00 2001 From: Microck Date: Thu, 12 Nov 2026 11:48:54 +0100 Subject: [PATCH 8/9] test: use runner Chrome with sandbox enabled --- .github/workflows/docker-preview.yml | 2 +- client-next/scripts/production-browser-smoke.mjs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 8489c61a..ac249d32 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -91,7 +91,7 @@ jobs: set -eu cd client-next npm ci --ignore-scripts - npx --no-install playwright install chromium + google-chrome --version cd .. docker volume create studio-browser-home docker run --rm -v studio-browser-home:/home/node --entrypoint sh studio-pr-test -c \ diff --git a/client-next/scripts/production-browser-smoke.mjs b/client-next/scripts/production-browser-smoke.mjs index d9d4d08b..ce301610 100644 --- a/client-next/scripts/production-browser-smoke.mjs +++ b/client-next/scripts/production-browser-smoke.mjs @@ -3,7 +3,9 @@ import assert from 'node:assert/strict'; // The container is populated with a synthetic config by CI; no user data or // model calls are involved. Keep Chromium's sandbox enabled. -const browser = await chromium.launch({ headless: true, chromiumSandbox: true }); +// Use the runner's installed Chrome, whose packaged sandbox is supported by +// Ubuntu's AppArmor profile. Do not fall back to disabling the sandbox. +const browser = await chromium.launch({ channel: 'chrome', headless: true, chromiumSandbox: true }); try { const page = await browser.newPage(); const errors = []; From e8a6d8da55cd28682d2dc65c15c115efe17e2e4b Mon Sep 17 00:00:00 2001 From: Microck Date: Thu, 12 Nov 2026 11:54:01 +0100 Subject: [PATCH 9/9] chore: prepare 2.6.0 release --- CHANGELOG.md | 2 +- package.json | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30947761..0b095d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## Unreleased +## [2.6.0] - 2026-11-12 ### Added - Draft production client build, loopback-bound Docker setup and deployment security guidance. diff --git a/package.json b/package.json index 277a0919..f9465e36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-studio-server", - "version": "2.5.1", + "version": "2.6.0", "private": false, "scripts": { "start": "node scripts/start-production.cjs", diff --git a/server/package-lock.json b/server/package-lock.json index a1202bb7..e7400718 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-studio-server", - "version": "2.5.1", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-studio-server", - "version": "2.5.1", + "version": "2.6.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/server/package.json b/server/package.json index d1bf3efa..31908bb3 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "opencode-studio-server", - "version": "2.5.1", + "version": "2.6.0", "description": "Backend server for OpenCode Studio - manages opencode configurations", "main": "index.js", "bin": {