diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e2380c3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +**/node_modules +**/.next +.env* +**/.env* +**/*.log diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml new file mode 100644 index 0000000..ac249d3 --- /dev/null +++ b/.github/workflows/docker-preview.yml @@ -0,0 +1,137 @@ +name: Docker production preview +on: + pull_request: + paths: + - 'Dockerfile' + - '.dockerignore' + - 'docker-compose.yml' + - 'scripts/**' + - 'deploy/**' + - 'docker-compose.proxy.yml' + - 'docker-compose.cli.yml' + - '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 + 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 -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 + 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 + # 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 + 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 + 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 + 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" + test "$code" != 200 + - name: Test production browser navigation + run: | + set -eu + cd client-next + npm ci --ignore-scripts + 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 \ + '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 + 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 -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 + sleep 2 + 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' + 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 + echo 'OpenCode printed its configured password; do not use this deployment.' >&2 + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 357b895..0b095d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. +## [2.6.0] - 2026-11-12 + +### Added +- Draft production client build, loopback-bound Docker setup and deployment security guidance. +- 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. + +### 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 ### Fixed diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bf98468 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +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/ +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/ +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 732fad8..b358d6e 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 f57fd82..5eead87 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/package-lock.json b/client-next/package-lock.json index 4f7b1a0..459adeb 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 9c5426a..e69a262 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 0000000..ce30161 --- /dev/null +++ b/client-next/scripts/production-browser-smoke.mjs @@ -0,0 +1,26 @@ +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. +// 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 = []; + 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(); +} diff --git a/client-next/src/lib/api.ts b/client-next/src/lib/api.ts index 557a96a..b474d54 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 0000000..c4d8b2f --- /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/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..d8b91e7 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,13 @@ +https://{$STUDIO_SITE} { + basic_auth { + {env.STUDIO_USER} {env.STUDIO_PASSWORD_HASH} + } + reverse_proxy studio:3000 { + 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 0000000..9339330 --- /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 0000000..07322a5 --- /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 0000000..e4f60f7 --- /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 0000000..5eb9c84 --- /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/docker-compose.proxy.yml b/docker-compose.proxy.yml new file mode 100644 index 0000000..2ace410 --- /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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4b54d99 --- /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 0000000..1dafd20 --- /dev/null +++ b/docs/PRODUCTION-DOCKER.md @@ -0,0 +1,84 @@ +# 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. +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` +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 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. + +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) + +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. +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. diff --git a/package.json b/package.json index 04a49ee..f9465e3 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "opencode-studio-server", - "version": "2.5.1", + "version": "2.6.0", "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 0000000..c8a1bd2 --- /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 0000000..220258c --- /dev/null +++ b/scripts/start-production.cjs @@ -0,0 +1,50 @@ +// 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(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, ...(standalone ? { STUDIO_CONTAINER_LOCK: '1' } : {}) }, + }), + 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 c38b9ec..9fd618b 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...'); @@ -168,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; @@ -5745,7 +5750,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 67699c7..b793915 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 d5298a9..2fc0fb6 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/server/package-lock.json b/server/package-lock.json index a1202bb..e740071 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 d1bf3ef..31908bb 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": { diff --git a/start.bat b/start.bat index d69be87..8fb05c2 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 d3e4fdb..6f6ea81 --- a/start.sh +++ b/start.sh @@ -1,3 +1,3 @@ #!/bin/bash echo "Starting Opencode Studio..." -npm start +npm run dev