Skip to content

Commit 83798a8

Browse files
authored
Merge pull request #1 from dtump/feature/opencode-container
feat: replace Claude Code with OpenCode
2 parents 475b9ae + a68ba0c commit 83798a8

16 files changed

Lines changed: 603 additions & 517 deletions

.github/workflows/ci.yml

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -106,26 +106,26 @@ jobs:
106106
# image. Build is amd64-only (single-arch load is fine); arm64
107107
# coverage relies on the maintainers' local builds for now.
108108
load: true
109-
tags: claude-docker:ci
109+
tags: opencode:ci
110110
platforms: linux/amd64
111111
cache-from: type=gha
112112
cache-to: type=gha,mode=min
113113

114-
- name: Smoke test — claude --version matches Dockerfile pin
115-
# Catches the 2.1.x failure mode where the JS launcher is installed
116-
# but the native-binary postinstall isn't run, leaving `claude` as a
114+
- name: Smoke test — opencode --version matches Dockerfile pin
115+
# Catches the failure mode where the JS launcher is installed
116+
# but the native-binary postinstall isn't run, leaving `opencode` as a
117117
# stub that errors at exec time. Asserts the actual reported version
118-
# matches the CLAUDE_CODE_VERSION pin in pins/claude-code.env.
118+
# matches the OPENCODE_VERSION pin in pins/opencode.env.
119119
run: |
120120
set -euo pipefail
121-
EXPECTED=$(python3 update_pins.py --list-npm-tools | awk -F'\t' '$1=="claude-code"{print $5}')
122-
ACTUAL=$(docker run --rm claude-docker:ci claude --version)
121+
EXPECTED=$(python3 update_pins.py --list-npm-tools | awk -F'\t' '$1=="opencode"{print $5}')
122+
ACTUAL=$(docker run --rm opencode:ci opencode --version)
123123
echo "Expected: ${EXPECTED} Actual: ${ACTUAL}"
124-
[[ "$ACTUAL" == "${EXPECTED} (Claude Code)" ]]
124+
[[ "$ACTUAL" == "${EXPECTED}" ]]
125125
126126
- name: Cross-platform smoke (Linux)
127127
# Drives smoke.sh across a matrix of cells, all reusing the already-loaded
128-
# claude-docker:ci image from the build step above. Do NOT move this to a
128+
# opencode:ci image from the build step above. Do NOT move this to a
129129
# separate job — a separate job gets a blank local daemon and cannot reuse
130130
# the image loaded by the build step.
131131
#
@@ -141,12 +141,12 @@ jobs:
141141
# 9. runner-uid/ro/no-optins — read-only workspace (EROFS robustness)
142142
run: |
143143
set -euo pipefail
144-
export IMAGE=claude-docker:ci
144+
export IMAGE=opencode:ci
145145
RUNNER_UID=$(id -u)
146146
147147
run_cell() {
148148
echo "--- Cell: $* ---"
149-
IMAGE=claude-docker:ci bash smoke/smoke.sh "$@"
149+
IMAGE=opencode:ci bash smoke/smoke.sh "$@"
150150
}
151151
152152
# 1. Baseline: runner UID, cold volume, no opt-ins.

AGENTS.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# OpenCode Agents Configuration
2+
3+
This file documents the OpenCode configuration for the opencode-docker project.
4+
5+
## Project Overview
6+
7+
`opencode-docker` is a hardened Docker wrapper for OpenCode that provides:
8+
- Isolated workspace access via bind mounts
9+
- Credential opt-in mechanisms for AWS, GitHub, GitLab, and Terraform Cloud
10+
- GitHub auth proxy sidecar for secure token handling
11+
- Private package registry support
12+
- File ownership preservation (host UID/GID mapping)
13+
- Security hardening via capability dropping and no-new-privileges
14+
15+
## Agent Configuration
16+
17+
### Primary Agent
18+
The main OpenCode agent runs inside the container with access to mounted workspaces and opted-in credentials.
19+
20+
### Config Directory
21+
- **Host:** `~/.config/opencode/`
22+
- **Container:** `/root/.config/opencode/`
23+
24+
Mounted items from host config:
25+
- `agents/` - Custom agent definitions
26+
- `skills/` - Custom skills
27+
- `commands/` - Slash commands
28+
- `AGENTS.md` - Global preferences
29+
- `opencode.docker.json` - Container-specific OpenCode config (copied to `opencode.json`)
30+
31+
### Settings
32+
Container-specific settings are managed via `opencode.docker.json`, which is copied to `opencode.json` in the container at startup. This allows in-session config changes to persist for the container's lifetime while being re-seeded from the host on each start.
33+
34+
See `examples/opencode.docker.json` for a starting configuration.
35+
36+
## Security Model
37+
38+
### Protected
39+
- Host filesystem outside passed workspaces
40+
- Host `~/.aws/credentials` (long-lived keys)
41+
- Host credential directories (read-only when opted in)
42+
43+
### Exposed (per session, when opted in)
44+
- Workspace directories (read-write unless `--ro`)
45+
- Short-lived AWS SSO bearer tokens
46+
- GitHub tokens (via proxy sidecar or direct forwarding)
47+
- GitLab tokens
48+
- Terraform Cloud tokens
49+
- Private registry configuration files
50+
51+
### Persistent State
52+
Named volumes carry state across runs (unless `--ephemeral`):
53+
- `opencode-root` - `/root` (OAuth tokens, shell history)
54+
- `opencode-home` - `/root/.config/opencode` (conversation history, settings)
55+
56+
## Usage Patterns
57+
58+
### Multi-workspace
59+
```bash
60+
opencode-docker ~/repo-a ~/repo-b
61+
```
62+
63+
### Credential Opt-in
64+
```bash
65+
opencode-docker --aws --gh ~/repo
66+
```
67+
68+
### Read-only Review
69+
```bash
70+
opencode-docker --ephemeral --ro ~/untrusted-repo
71+
```
72+
73+
### Split-pane Teams
74+
```bash
75+
opencode-docker --iterm ~/repo # iTerm2 native panes
76+
opencode-docker --tmux ~/repo # Plain tmux splits
77+
```
78+
79+
## Customization
80+
81+
Extend the base image with project-specific tooling:
82+
```dockerfile
83+
FROM opencode:local
84+
RUN apt-get install -y my-tool
85+
```
86+
87+
Then use with:
88+
```bash
89+
OPENCODE_DOCKER_IMAGE=my-opencode:local opencode-docker ~/repo
90+
```

CONTRIBUTING.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Contributing
22

3-
Contributions to `claude-docker` are welcome from anyone.
3+
Contributions to `opencode-docker` are welcome from anyone.
44

55
## Workflow
66

@@ -29,8 +29,8 @@ hadolint --config .hadolint.yaml Dockerfile
2929
python3 -m unittest discover -s tests -p 'test_*.py' -v
3030

3131
# Build the image and run a smoke cell against it
32-
docker build -t claude-code:local .
33-
IMAGE=claude-code:local bash smoke/smoke.sh --uid="$(id -u)" --optins=aws,glab,tfe
32+
docker build -t opencode:local .
33+
IMAGE=opencode:local bash smoke/smoke.sh --uid="$(id -u)" --optins=aws,glab,tfe
3434
```
3535

3636
See [`README.md`](README.md) for the full architecture, threat model, and the

Dockerfile

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ RUN echo 'APT::Sandbox::User "root";' > /etc/apt/apt.conf.d/10no-sandbox \
4141

4242
# Free UID/GID 1000. Ubuntu's base image ships a default `ubuntu` user at
4343
# 1000:1000 — i.e. exactly the typical host UID. The entrypoint creates a
44-
# fresh `claude` user mapped to HOST_UID; without this step its `useradd`
45-
# is skipped on collision and `runuser -u claude` then fails. Reusing the
44+
# fresh `opencode` user mapped to HOST_UID; without this step its `useradd`
45+
# is skipped on collision and `runuser -u opencode` then fails. Reusing the
4646
# baked-in `ubuntu` account would also silently inherit its supplementary
4747
# groups (sudo, adm, plugdev, …). Guarded so a future base image without
4848
# the default user doesn't break the build.
4949
RUN if getent passwd ubuntu >/dev/null; then userdel -r ubuntu; fi \
50-
&& if getent group ubuntu >/dev/null; then groupdel ubuntu; fi
50+
&& if getent group ubuntu >/dev/null; then groupdel ubuntu; fi
5151

5252
# NodeSource ships Node 24 LTS pinned to upstream releases — Ubuntu's archive
5353
# `nodejs` tracks an older minor and isn't LTS-pinned. `nodistro` is
@@ -141,35 +141,37 @@ RUN . /tmp/uv.env; set -e; ARCH=$(uname -m); \
141141

142142
# npm-backed CLIs — pinned versions. Trust = npm's signed dist.integrity;
143143
# run `npm audit signatures <pkg>@<ver>` when bumping.
144-
# --ignore-scripts blocks lifecycle hooks for every package + transitive dep
145-
# (hard security boundary, kept on). claude-code 2.1.x ships its real binary
146-
# in a per-arch optional-dep package; the launcher's postinstall (install.cjs)
147-
# copies it over bin/claude.exe. Without it `claude` is a stub that errors at
148-
# exec. We invoke that one script ourselves — platform-detect + file copy,
149-
# no network/exec, audit-verified for 2.1.131; re-read on each bump.
144+
# opencode-ai ships its real binary in a per-arch optional-dep package; the
145+
# launcher's postinstall copies it over. Without it `opencode` is a stub that
146+
# errors at exec. We install with --ignore-scripts (blocks lifecycle hooks for
147+
# every package + transitive dep, hard security boundary) then explicitly invoke
148+
# the postinstall for opencode-ai only. It selects and copies the installed
149+
# platform binary, verifies it with `--version`, and only falls back to a
150+
# separate `npm install --ignore-scripts` when npm omitted the optional binary.
151+
# Re-review that script whenever the OpenCode pin changes.
150152
# `npm root -g` over a hardcoded path so we don't break on a different prefix.
151153
# npm tools carry version-only pins (no sha256): npm install verifies the
152154
# registry-advertised dist.integrity (registry-integrity, not provenance; CI
153155
# runs `npm audit signatures`). All three share this layer, so they share a COPY.
154-
COPY pins/claude-code.env pins/openspec.env pins/pnpm.env /tmp/
155-
RUN . /tmp/claude-code.env && . /tmp/openspec.env && . /tmp/pnpm.env \
156-
&& npm install -g --ignore-scripts \
157-
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
158-
"@fission-ai/openspec@${OPENSPEC_VERSION}" \
159-
"pnpm@${PNPM_VERSION}" \
160-
&& node "$(npm root -g)/@anthropic-ai/claude-code/install.cjs" \
161-
&& rm /tmp/claude-code.env /tmp/openspec.env /tmp/pnpm.env
156+
COPY pins/opencode.env pins/openspec.env pins/pnpm.env /tmp/
157+
RUN . /tmp/opencode.env && . /tmp/openspec.env && . /tmp/pnpm.env \
158+
&& npm install -g --ignore-scripts \
159+
"opencode-ai@${OPENCODE_VERSION}" \
160+
"@fission-ai/openspec@${OPENSPEC_VERSION}" \
161+
"pnpm@${PNPM_VERSION}" \
162+
&& node "$(npm root -g)/opencode-ai/postinstall.mjs" \
163+
&& rm /tmp/opencode.env /tmp/openspec.env /tmp/pnpm.env
162164

163165
# tfenv — pure-bash terraform version manager. Arch-independent (just
164166
# bash scripts), so a single sha256 covers amd64 and arm64. We deliberately
165167
# ship NO terraform binary; the project's `.terraform-version` (or an
166168
# interactive `tfenv install <v>`) fetches the right version from
167169
# releases.hashicorp.com at runtime, in the same runtime-fetch class as
168170
# `pnpm dlx`/`uvx`. Installed under /opt (not /root) so image-level
169-
# version bumps aren't shadowed by the claude-code-root named volume.
171+
# version bumps aren't shadowed by the opencode-root named volume.
170172
# Placed after the heavier npm install so a tfenv version bump doesn't
171173
# invalidate that layer's cache (tfenv pins move far less often than the
172-
# claude-code/openspec/pnpm pins above).
174+
# opencode-ai/openspec/pnpm pins above).
173175
COPY pins/tfenv.env /tmp/tfenv.env
174176
RUN . /tmp/tfenv.env \
175177
&& curl -fsSL "$TFENV_URL" -o /tmp/tfenv.tar.gz \
@@ -180,11 +182,11 @@ RUN . /tmp/tfenv.env \
180182
&& ln -s /opt/tfenv/bin/terraform /usr/local/bin/terraform \
181183
&& rm /tmp/tfenv.tar.gz /tmp/tfenv.env
182184

183-
# Plain `tmux` mode swallows Shift+Enter so Claude's prompt sees only Enter,
185+
# Plain `tmux` mode swallows Shift+Enter so OpenCode's prompt sees only Enter,
184186
# forcing users to type `\` for a literal newline. `always` is required
185-
# (not `on`) because Claude does not send the kitty activation request that
186-
# `on` waits for — see claude-code#26629. /etc/tmux.conf, not
187-
# /root/.tmux.conf, because /root is masked by the claude-code-root named
187+
# (not `on`) because OpenCode does not send the kitty activation request that
188+
# `on` waits for. /etc/tmux.conf, not
189+
# /root/.tmux.conf, because /root is masked by the opencode-root named
188190
# volume at runtime. Harmless under tmux -CC: iTerm2 control mode bypasses
189191
# tmux's input layer. Placed after npm install so edits don't invalidate
190192
# the heavy AWS CLI / uv / glab / npm download layers above.
@@ -193,17 +195,17 @@ set -s extended-keys always
193195
set -as terminal-features "*:extkeys"
194196
EOF
195197

196-
# DISABLE_AUTOUPDATER=1 keeps the pinned CLAUDE_CODE_VERSION authoritative —
197-
# without it, claude auto-replaces itself at runtime, defeating the
198+
# OPENCODE_DISABLE_AUTOUPDATE=1 keeps the pinned OPENCODE_VERSION authoritative —
199+
# without it, opencode auto-replaces itself at runtime, defeating the
198200
# --ignore-scripts supply-chain pinning above. Bump the image to upgrade.
199-
ENV CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 \
200-
DISABLE_AUTOUPDATER=1 \
201+
ENV OPENCODE_EXPERIMENTAL_AGENT_TEAMS=1 \
202+
OPENCODE_DISABLE_AUTOUPDATE=1 \
201203
IS_SANDBOX=1 \
202204
LANG=C.UTF-8 \
203205
LC_ALL=C.UTF-8
204206

205207
# Container starts as root so the entrypoint can chown /root to the host
206-
# UID, then drops privileges via runuser. Steady-state, claude runs as the
208+
# UID, then drops privileges via runuser. Steady-state, opencode runs as the
207209
# host user with no effective / permitted / ambient capabilities — the
208210
# kernel clears those on the UID→non-zero transition; the bounding set
209211
# retains the setup caps but is inert under `no-new-privileges`. Do not
@@ -216,4 +218,4 @@ WORKDIR /workspaces
216218
COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh
217219

218220
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
219-
CMD ["claude"]
221+
CMD ["opencode"]

0 commit comments

Comments
 (0)