forked from anomalyco/opencode
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathDockerfile
More file actions
410 lines (388 loc) · 25.4 KB
/
Copy pathDockerfile
File metadata and controls
410 lines (388 loc) · 25.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# syntax=docker/dockerfile:1.7
#
# Multi-stage build optimised for fast iteration during development.
#
# Layer-cache strategy:
# 1. apt deps + plugin pre-install → stable, change rarely → top of file
# 2. manifests-only COPY → bun install → cached unless package.json/lockfile changes
# 3. full source COPY → bun run build → only this stage re-runs on source edits
#
# Typical iteration after a source edit: only stage 3 rebuilds.
# Cold build time: ~the same. Warm rebuild after a source-only edit: ~minutes saved.
# ─────────────────────────────────────────────────────────────────────────────
# Stage 1 — extract only the files `bun install` needs so the install layer is
# cached independently of source-code edits.
#
# We COPY the whole context (invalidates on any change) then strip everything
# except:
# - package.json files (every workspace + root)
# - bun.lock / bun.lockb
# - patches/* — referenced by `patchedDependencies` in root package.json;
# bun install fails immediately if any patch file is missing
# - .npmrc / .bunfig.toml — registry/auth config, if present
#
# The OUTPUT of this stage is content-addressed: if none of those files change,
# downstream COPY --from=manifests is a cache hit and `bun install` is skipped.
# ─────────────────────────────────────────────────────────────────────────────
FROM busybox AS manifests
WORKDIR /m
COPY . .
RUN find . -type f \
! -name 'package.json' \
! -name 'bun.lock' \
! -name 'bun.lockb' \
! -name '.npmrc' \
! -name 'bunfig.toml' \
! -path './patches/*' \
-delete && \
find . -type d -empty -delete
# ─────────────────────────────────────────────────────────────────────────────
# Stage 2 — system deps + opencode plugin pre-install + workspace deps install.
# Anything in this stage is reused as long as manifests don't change.
# ─────────────────────────────────────────────────────────────────────────────
FROM oven/bun:1.3 AS deps
WORKDIR /app
# System packages — git for the workspace repo cloning at runtime; build tools
# in case native modules need to compile (tree-sitter / pty fall back to wasm
# when --ignore-scripts is used, but g++/python3/make are still cheap insurance);
# nodejs/npm are required by @npmcli/arborist (opencode's plugin loader);
# openssh-client lets git fork ssh for npm packages that declare git+ssh deps
# (pnpm-lock.yaml occasionally records ssh URLs from upstream package.json
# specs). Without ssh in PATH, git fails with "cannot run ssh: No such file
# or directory" — observed in the wild during unleashlive/frontend preview
# install. We don't actually USE ssh auth (no key shipped); the next layer
# rewrites every git ssh URL to authenticated HTTPS via a system gitconfig.
RUN apt-get update && apt-get install -y --no-install-recommends \
git ca-certificates python3 python3-pip make g++ nodejs npm openssh-client && \
rm -rf /var/lib/apt/lists/*
# Install Headroom (token-compression MCP server, chopratejas/headroom).
# Available to the iframe LLM as an MCP server registered in opencode.json;
# usage prompt baked into AGENTS.md so Claude reaches for it on big tool
# outputs. --break-system-packages is required on modern Debian (PEP 668);
# the container is single-purpose, so the "externally-managed-environment"
# safeguard doesn't apply. --no-cache-dir keeps the image smaller; the
# [mcp] extra pulls only the MCP-server entry point (smaller than [all]).
RUN pip3 install --break-system-packages --no-cache-dir 'headroom-ai[mcp]' && \
headroom --version | head -1
# Install Playwright MCP (microsoft/playwright-mcp) + a version-matched headless
# Chromium. Registered as an OFF-by-default MCP server in opencode.json (see
# below); a Driver toggles it on per session from the iframe's MCP panel when a
# session needs browser automation. Baking the browser at build time
# (PLAYWRIGHT_BROWSERS_PATH) means the first connect launches instantly with no
# runtime download — and no network dependency at all on a cold task.
#
# Version pinning matters: @playwright/mcp bundles an EXACT playwright build, and
# playwright matches browsers by a revision number baked into the folder name
# (chromium-NNNN). So we install Chromium via the SAME playwright that ships
# inside the pinned @playwright/mcp (its nested node_modules .bin), never a
# floating `npx playwright@latest`, which could fetch a mismatched revision the
# MCP can't find. Bin name is `playwright-mcp` (npm `bin` field); it lands on
# the global npm bin dir (on PATH for uid 10001, same as the pip-installed
# `headroom`). PLAYWRIGHT_BROWSERS_PATH is set once here in the deps stage and
# inherits into the final image, so it applies to both this build-time install
# and the runtime spawn. --with-deps pulls the Chromium apt libs (root only;
# this stage is root).
ENV PLAYWRIGHT_BROWSERS_PATH=/home/opencode/.cache/playwright
RUN set -eux; \
npm install --global @playwright/mcp@0.0.76; \
PW="$(npm root -g)/@playwright/mcp/node_modules/.bin/playwright"; \
if [ ! -x "$PW" ]; then PW="$(npm root -g)/.bin/playwright"; fi; \
if [ ! -x "$PW" ]; then PW="playwright"; fi; \
"$PW" install --with-deps chromium; \
command -v playwright-mcp; \
playwright-mcp --version; \
ls -d "$PLAYWRIGHT_BROWSERS_PATH"/chromium-*
# Rewrite ssh-form GitHub URLs to HTTPS at the system level. Every flavour
# pnpm / npm / yarn could produce gets normalised to `https://github.com/`:
#
# git@github.com:owner/repo → https://github.com/owner/repo
# ssh://git@github.com/owner/repo → https://github.com/owner/repo
# git+ssh://git@github.com/... → https://github.com/...
# https://git@github.com:owner/repo → https://github.com/owner/repo
# git+https://git@github.com:owner/repo → https://github.com/owner/repo
#
# The trailing two forms look malformed (`https://` followed by an SSH-style
# colon path) but they're what pnpm's lockfile actually writes for git deps
# in some configurations. Observed in unleashlive/frontend's pnpm-lock.yaml:
# version: git+https://git@github.com:unleashlive/ua-gltf-viewer.git#…
#
# Public repos clone unauthenticated; for private repos (e.g. unleashlive
# internal forks declared as deps in the frontend's package.json) the
# per-launch GIT_ASKPASS handler below supplies the Driver's OAuth token at
# git's credential prompt. Writing to /etc/gitconfig (--system) means the
# rule applies to every UID inside the container — preview launches run as
# uid 10001, not root, so --global wouldn't reach the right HOME.
#
# CRITICAL: we use `--add` for EACH rule because url.<base>.insteadOf is a
# multi-valued key. Plain `git config key value` (without --add) REPLACES;
# a chain of plain calls leaves only the last entry in /etc/gitconfig. Bug
# observed in the wild on 2026-05-28: only `git+ssh://git@github.com/` was
# active until the Dockerfile got this fix, so plain `git@github.com:` URLs
# (the form pnpm-lock.yaml resolution.repo uses) weren't being rewritten and
# pnpm install died with "Please make sure you have the correct access
# rights and the repository exists."
RUN git config --system --add url."https://github.com/".insteadOf "git@github.com:" && \
git config --system --add url."https://github.com/".insteadOf "ssh://git@github.com/" && \
git config --system --add url."https://github.com/".insteadOf "git+ssh://git@github.com/" && \
git config --system --add url."https://github.com/".insteadOf "https://git@github.com:" && \
git config --system --add url."https://github.com/".insteadOf "git+https://git@github.com:"
# Credential helper for authenticated HTTPS git fetches. When git needs
# creds (e.g. cloning a private dep over the URL the rewrite above produced),
# it execs $GIT_ASKPASS twice — once for "Username", once for "Password".
# Our helper answers `x-access-token` / `$GITHUB_TOKEN`, which is GitHub's
# canonical OAuth-app HTTP basic-auth form.
#
# Token-flow lifecycle:
# - Token NEVER lands on disk (only in env at install time)
# - Token NEVER lands in pnpm-lock.yaml (lockfile sees `https://github.com/`
# from the rewrite above — the askpass form keeps the URL clean)
# - The preview-launcher injects GITHUB_TOKEN per-spawn only. Outside the
# install process, GITHUB_TOKEN is unset.
#
# Bun's shell-builtin /usr/local/bin needs to be writable by uid 10001 (or
# the file world-readable) — we chmod a+rx + write as root, world-readable.
RUN printf '#!/bin/sh\ncase "$1" in\n Username*) echo x-access-token ;;\n Password*) echo "$GITHUB_TOKEN" ;;\nesac\n' \
> /usr/local/bin/git-askpass-token && \
chmod a+rx /usr/local/bin/git-askpass-token
ENV GIT_ASKPASS=/usr/local/bin/git-askpass-token
# pnpm@10 — used by the frontend live-preview launcher to install + run dev
# servers inside a collab workspace (see packages/opencode/src/collab/preview-launcher.ts).
# Pre-installed here so the first "Launch" click doesn't pay the ~10s
# `npx pnpm@10` cold-start every time. Bound to a specific major to match
# unleashlive/frontend's lockfile.
RUN npm install --global pnpm@10 2>&1 | tail -3 && pnpm --version
# Pre-install opencode-claude-auth into opencode's npm package cache.
# Lives at /root/.cache/opencode/packages/<sanitized-pkg>/node_modules/<name>.
# At runtime, @opencode-ai/core/npm.ts checks `existsSafe(...)` and short-circuits,
# avoiding an ~18 s arborist.reify() that would otherwise block the event loop
# the first time a collab session is created.
#
# Cache mount on /root/.npm keeps the npm download cache between builds so this
# step is ~instant on subsequent builds (uses cached tarballs).
RUN --mount=type=cache,target=/root/.npm \
PLUGIN_CACHE=/root/.cache/opencode/packages/opencode-claude-auth@latest && \
mkdir -p "$PLUGIN_CACHE" && \
printf '{"name":"opencode-plugin-cache","version":"1.0.0","private":true,"dependencies":{"opencode-claude-auth":"latest"}}\n' \
> "$PLUGIN_CACHE/package.json" && \
npm install --prefix "$PLUGIN_CACHE" --ignore-scripts --no-audit --no-fund 2>&1 | tail -3 && \
echo "opencode-claude-auth pre-install complete" || \
echo "WARNING: opencode-claude-auth pre-install failed; will install lazily at runtime"
# Pre-create directories that opencode and the collab workspace need at runtime.
# Paths live under /home/opencode (ADR-0003) — the opencode user owns them and
# they're created here so the final-stage chown is one shallow walk.
RUN mkdir -p /var/opencode/workspaces \
/home/opencode/.local/share/opencode \
/home/opencode/.config/opencode \
/home/opencode/.config/opencode/agent \
/home/opencode/.cache/opencode/packages \
/home/opencode/.cache/headroom \
/home/opencode/.cache/playwright \
/home/opencode/.cache/playwright-output \
/home/opencode/.claude && \
# Bake a container-wide opencode config:
# - `plugin`: pre-installed opencode-claude-auth (cached above at /root)
# - `disabled_providers`: amazon-bedrock is disabled for this fork.
# ap-southeast-2 (utils deployment) only offers LEGACY Claude 3 / 3.5
# Sonnet v2 as ON_DEMAND models; the modern Claude 4.x family is
# INFERENCE_PROFILE-only via `apac.*` cross-region profiles, and
# opencode's bedrock region-prefix logic
# (packages/opencode/src/provider/provider.ts:1747-1759) only handles
# `us.*` / `eu.*` prefixes — `ap-*` falls back to whatever the sort
# puts first, which lands on `us.anthropic.claude-sonnet-4-6`. That
# ID does not exist in ap-southeast-2 → Bedrock returns 400 "The
# provided model identifier is invalid" on every request.
# Disabling the provider removes the variant from the dropdown so
# users can only pick Anthropic-native models (auth'd via the
# opencode-claude-auth plugin).
# Top-level opencode config — plugin + disabled providers + the Headroom
# MCP server registration. Schema in
# packages/opencode/src/config/mcp.ts; `type: local` means opencode
# spawns the command itself and speaks MCP over stdio. The stdio server
# is `headroom mcp serve` — `mcp` alone is the subcommand GROUP (install /
# status / uninstall / serve); spawning it with no action just prints help
# and exits, so the MCP handshake never completes and opencode flags the
# server with a red "failed" dot. Timeout is generous (30 s) because
# Headroom's first run lazy-loads its compressor models; subsequent calls
# are sub-second.
# The `playwright` entry is the browser-automation MCP (see the install
# block above). It is OFF by default (`enabled:false`) so no Chromium spins
# up unless a Driver toggles it on for a session — keeps idle memory off the
# 16 GB task that also runs the frontend preview build. `--headless
# --no-sandbox` is mandatory for Chromium as the non-root opencode user
# without SYS_ADMIN; `--isolated` gives each connect a throwaway profile (no
# cross-session state, no disk growth). 60 s timeout because the first
# connect launches the browser.
printf '{"plugin":["opencode-claude-auth@latest"],"disabled_providers":["amazon-bedrock"],"mcp":{"headroom":{"type":"local","command":["headroom","mcp","serve"],"enabled":true,"timeout":30000},"playwright":{"type":"local","command":["playwright-mcp","--headless","--no-sandbox","--isolated","--browser","chromium","--output-dir","/home/opencode/.cache/playwright-output"],"environment":{"PLAYWRIGHT_BROWSERS_PATH":"/home/opencode/.cache/playwright"},"enabled":false,"timeout":60000}}}\n' \
> /home/opencode/.config/opencode/opencode.json && \
# Global AGENTS.md — opencode auto-loads $XDG_CONFIG_HOME/opencode/AGENTS.md
# (= /home/opencode/.config/opencode/AGENTS.md here) into the system
# prompt of EVERY session, regardless of project cwd
# (packages/opencode/src/session/instruction.ts:64). Use it to introduce
# Headroom MCP so the LLM knows when to compress. We keep the nudge
# short and behaviour-focused — operator-facing context belongs in per-
# repo AGENTS.md instead.
printf '%s\n' \
'# Collab session context' \
'' \
'## Headroom MCP (token compression)' \
'' \
'Three MCP tools are available in this session for compressing large content before it consumes the context budget:' \
'' \
'- `headroom_compress(content)` — returns an `<hr:...>` token and caches the original locally. Pass the token onward instead of the full content; downstream tools can still call `headroom_retrieve` on it when they need the bytes.' \
'- `headroom_retrieve(token)` — fetches the original content for a previously-compressed token.' \
'- `headroom_stats()` — current cache size + cumulative token-savings summary.' \
'' \
'### When to reach for it' \
'' \
'Use `headroom_compress` whenever a tool result is likely above ~2,000 tokens — long log dumps, large file reads, RAG chunks, sprawling CloudWatch tails. Originals stay reversible (no information loss); typical compression is 60–95%. Skipping it on big outputs leaves obvious tokens on the table.' \
'' \
'Skip compression on short results (< ~1,000 tokens) — the overhead outweighs the savings.' \
'' \
'## Playwright MCP (browser automation)' \
'' \
'A headless-Chromium browser-automation server (`playwright`) is registered but **OFF by default**. If a task needs to load a web page, click, fill a form, or capture a screenshot, ask a Driver to enable `playwright` in the session MCP panel first — until it shows a green dot the browser tools are unavailable.' \
'' \
'Once enabled, drive the browser with `browser_navigate`, `browser_click`, `browser_type`, `browser_snapshot` (accessibility tree — prefer it over screenshots for reading page state) and `browser_take_screenshot`. Output lands in /home/opencode/.cache/playwright-output. Reach for it to verify the live frontend preview, inspect a page the user references, or reproduce a UI bug — not for routine file edits.' \
> /home/opencode/.config/opencode/AGENTS.md && \
# Bake the "fast" agent into the container so it shows up in the iframe's
# per-session agent picker. Haiku-powered, minimal ceremony. Users in
# /collab/new (or inside an existing session) can pick "fast" from the
# native agent dropdown — selection is per-session, not global. No file
# mounts or workspace plumbing needed; the agent dir is canonical opencode
# config-discovery (see opencode-ai/opencode AGENTS.md spec).
printf '%s\n' \
'---' \
'description: Fast coding agent — Haiku-powered, minimal ceremony, collab default.' \
'model: anthropic/claude-haiku-4-5' \
'---' \
'You are a focused coding agent optimized for speed.' \
'' \
'## Operating principle' \
'' \
'Read what you need, edit minimally, ship it. Skip elaborate planning for small changes.' \
'' \
'## Rules' \
'' \
'- **Match existing code style and whitespace exactly.** Look at the file you'"'"'re editing before writing anything new.' \
'- **Make the smallest edit possible** to accomplish the task. Don'"'"'t refactor adjacent code unless asked.' \
'- **Don'"'"'t add comments** unless the user asks for them, OR the code is genuinely non-obvious and the comment is short.' \
'- **Don'"'"'t run tests** for trivial changes (one-line edits, typos, comment changes, documentation updates).' \
'- **Don'"'"'t write a plan** for changes under ~5 files. Just do them.' \
'- For changes >5 files, write a 3-bullet plan first, then execute.' \
'- **If blocked, ask ONE specific question** rather than guessing or thrashing.' \
'' \
'## Tool usage' \
'' \
'- `read` before `edit`. Always know what'"'"'s in the file before changing it.' \
'- Use `glob` / `grep` to find references before refactoring.' \
'- Use the shell only when a built-in tool can'"'"'t do the job.' \
'- Don'"'"'t fetch documentation unless you'"'"'re using an API you'"'"'ve never seen before. Existing imports + types are usually enough.' \
'' \
'## Output' \
'' \
'- Keep prose short. Bullets > paragraphs. Code > prose.' \
'- After making changes, give a one-line summary of what changed and where.' \
'- If something might break, name it explicitly.' \
'' \
'You optimize for low time-to-working-code. Quality matters but not at the cost of three rounds of "let me check first."' \
> /home/opencode/.config/opencode/agent/fast.md && \
# Carry the pre-installed plugin tree across from /root.
cp -r /root/.cache/opencode/packages/. /home/opencode/.cache/opencode/packages/ 2>/dev/null || true
# ─────────────────────────────────────────────────────────────────────────────
# Pre-warm the pnpm content-addressed store with unleashlive/frontend +
# unleashlive/api dependencies (speed win V1).
#
# The frontend's pnpm tree is ~3,500 packages; a COLD `pnpm install` inside a
# fresh collab workspace takes ~10 min (network + download). pnpm's store is
# content-addressed and shared across installs, so if we populate it at BUILD
# time, every runtime session install becomes a near-offline store-link op
# ("Done in ~30 s") instead of a 10-min download.
#
# `pnpm fetch` is the right tool: it reads ONLY the lockfile (ignores
# package.json), downloads every locked dependency into the store, and does
# NOT link a node_modules tree — exactly a store warm. We run it with
# HOME=/home/opencode so pnpm's default store path resolves to
# /home/opencode/.local/share/pnpm/store — the SAME path the runtime preview
# install (uid 10001, HOME=/home/opencode) reads by default. The final-stage
# `chown -R 10001:10001 /home/opencode` hands the warmed store to the runtime
# user.
#
# Auth: cloning these PRIVATE repos at build time needs a cross-repo read
# token, provided as a BuildKit secret (id=github_token). The token is
# mounted at /run/secrets/github_token for this RUN only — it never lands in
# a layer or the image. When the secret is absent (local builds, or before
# the deploy workflow is wired to pass it), the step logs and SKIPS cleanly —
# the image still builds; runtime installs just pay the cold ~10 min until a
# warmed image ships. See DEPLOYMENT.md for wiring the secret in CI.
#
# Staleness is fine: a lockfile that drifts after build means pnpm fetches
# only the DELTA at runtime (new/changed packages); the unchanged thousands
# are store hits. Even a week-stale warm removes the bulk of the cold cost.
RUN --mount=type=secret,id=github_token,required=false \
TOKEN_FILE=/run/secrets/github_token; \
if [ ! -s "$TOKEN_FILE" ]; then \
echo "[warm-store] no github_token secret — SKIPPING pnpm store pre-warm (runtime installs will be cold)"; \
else \
TOKEN="$(cat "$TOKEN_FILE")"; \
for repo in unleashlive/frontend unleashlive/api; do \
name="$(basename "$repo")"; \
echo "[warm-store] cloning $repo (shallow) for lockfile…"; \
if git clone --depth 1 "https://x-access-token:${TOKEN}@github.com/${repo}.git" "/tmp/warm-$name" >/tmp/clone.log 2>&1; then \
if [ -f "/tmp/warm-$name/pnpm-lock.yaml" ]; then \
echo "[warm-store] pnpm fetch for $name…"; \
if ( cd "/tmp/warm-$name" && HOME=/home/opencode pnpm fetch >/tmp/fetch.log 2>&1 ); then \
tail -3 /tmp/fetch.log; \
else \
echo "[warm-store] WARNING pnpm fetch failed for $name (continuing):"; tail -5 /tmp/fetch.log || true; \
fi; \
else \
echo "[warm-store] $name has no pnpm-lock.yaml — skipping"; \
fi; \
rm -rf "/tmp/warm-$name"; \
else \
echo "[warm-store] WARNING clone failed for $repo (continuing):"; tail -3 /tmp/clone.log || true; \
fi; \
done; \
rm -f /tmp/clone.log /tmp/fetch.log; \
echo "[warm-store] pnpm store pre-warm complete"; \
fi
# Bring in ONLY manifests, then install workspace deps.
# Cache mount on /root/.bun/install/cache keeps the bun package store between builds.
COPY --from=manifests /m/ ./
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --no-optional --ignore-scripts
# ─────────────────────────────────────────────────────────────────────────────
# Stage 3 — build the SolidJS web app. Only this stage re-runs on source edits.
# Inherits node_modules + all caches from the `deps` stage.
# ─────────────────────────────────────────────────────────────────────────────
FROM deps AS build
WORKDIR /app
# Copy the full source. This invalidates on every source change — that's fine,
# because the expensive `bun install` above is already done.
COPY . .
# Build the web app (Vite + SolidJS). Cache mount keeps Vite's dep optimizer
# warm between builds — ~10–20 s saved on subsequent builds.
RUN --mount=type=cache,target=/app/packages/app/node_modules/.vite \
bun run --cwd packages/app build
# Container entrypoint — writes ~/.claude/.credentials.json from
# $CLAUDE_CREDENTIALS_JSON when present, then execs the server. See
# scripts/entrypoint.sh for the rationale.
COPY scripts/entrypoint.sh /usr/local/bin/opencode-entrypoint
RUN chmod +x /usr/local/bin/opencode-entrypoint
# ─────────────────────────────────────────────────────────────────────────────
# Non-root user (ADR-0003).
#
# Until this stage everything ran as root for build speed. Now we create the
# `opencode` user (uid 10001) and hand the runtime tree over to it. The
# container's working set after this point — /app, /home/opencode, and the
# data mount at /var/opencode — is owned by uid 10001. Drops the
# blast-radius of any future RCE / PTY abuse from "read every secret" to
# "stuff the unprivileged user can see".
# ─────────────────────────────────────────────────────────────────────────────
RUN useradd --uid 10001 --create-home --shell /bin/bash --home-dir /home/opencode opencode 2>/dev/null || true && \
chown -R 10001:10001 /app /home/opencode /var/opencode /usr/local/bin/opencode-entrypoint
ENV NODE_ENV=production
ENV HOME=/home/opencode
EXPOSE 4096
USER opencode
ENTRYPOINT ["/usr/local/bin/opencode-entrypoint"]