Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git
**/node_modules
**/.next
.env*
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
**/.env*
**/*.log
137 changes: 137 additions & 0 deletions .github/workflows/docker-preview.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions client-next/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
{
Expand Down
30 changes: 30 additions & 0 deletions client-next/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions client-next/scripts/production-browser-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
4 changes: 3 additions & 1 deletion client-next/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ async function discoverBackendPort(): Promise<string> {
}
}

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';

Expand Down
21 changes: 21 additions & 0 deletions client-next/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -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*' };
13 changes: 13 additions & 0 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions deploy/opencode-cli/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading