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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
!Dockerfile
!entrypoint.sh
!healthcheck.sh
!openchamber-healthcheck.sh
!AGENTS.md
!skills/
!opencode.jsonc
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ jobs:
run: |
sh -n entrypoint.sh
sh -n healthcheck.sh
sh -n openchamber-healthcheck.sh
bash -n scripts/smoke-test.sh
shellcheck entrypoint.sh healthcheck.sh scripts/smoke-test.sh
shellcheck entrypoint.sh healthcheck.sh openchamber-healthcheck.sh scripts/smoke-test.sh
- name: Set up pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- name: Set up Node.js
Expand All @@ -32,4 +33,5 @@ jobs:
- name: Validate Compose
env:
OPENCODE_PASSWORD: validation-only-not-a-deployment-password
OPENCHAMBER_UI_PASSWORD: validation-only-not-a-deployment-password
run: docker compose config --quiet
12 changes: 7 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd

ARG OPENCODE_VERSION=2.0.27
ARG OPENCHAMBER_VERSION=2.2.0
ARG TYPESCRIPT_VERSION=7.0.2
ARG PNPM_VERSION=12.11.2
ARG TARGETARCH
Expand All @@ -38,9 +39,10 @@ RUN case "$TARGETARCH" in amd64) arch=x64 ;; arm64) arch=arm64 ;; *) exit 1 ;; e
"https://registry.npmjs.org/@pnpm/exe.linux-${arch}/-/exe.linux-${arch}-${PNPM_VERSION}.tgz" \
| tar -xz --strip-components=1 -C /opt/pnpm-cli \
&& ln -s /opt/pnpm-cli/pnpm /usr/local/bin/pnpm \
&& XDG_CONFIG_HOME=/opt/pnpm-config pnpm --allow-build=@opencode/cli add --global \
"@opencode/cli@${OPENCODE_VERSION}" "typescript@${TYPESCRIPT_VERSION}" \
&& opencode --version
&& XDG_CONFIG_HOME=/opt/pnpm-config pnpm --allow-build=@opencode/cli --allow-build=node-pty add --global \
"@opencode/cli@${OPENCODE_VERSION}" "@openchamber/web@${OPENCHAMBER_VERSION}" "typescript@${TYPESCRIPT_VERSION}" \
&& opencode --version \
&& openchamber --version

FROM tools AS config
WORKDIR /build
Expand All @@ -66,14 +68,14 @@ ENV HOME=/data \
COPY --from=config /build/runtime.json /etc/opencode/opencode.json
COPY AGENTS.md /etc/opencode/AGENTS.md
COPY skills /etc/opencode/skills
COPY --chmod=755 entrypoint.sh healthcheck.sh /usr/local/bin/
COPY --chmod=755 entrypoint.sh healthcheck.sh openchamber-healthcheck.sh /usr/local/bin/
RUN mkdir -p /data/workspace /data/.config/opencode "$PNPM_HOME" \
&& usermod --home /data node \
&& chown -R node:node /data

USER node
WORKDIR /data/workspace
EXPOSE 4096
EXPOSE 4096 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
CMD ["/usr/local/bin/healthcheck.sh"]
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OpenCode Server

Self-hosted [OpenCode V2](https://opencode.ai/v2/docs/) web UI and API. The image `ghcr.io/icco/opencode-server:main` supports amd64 and arm64.
Self-hosted [OpenCode V2](https://opencode.ai/v2/docs/) behind the [OpenChamber](https://docs.openchamber.dev/) web UI. The image `ghcr.io/icco/opencode-server:main` supports amd64 and arm64.

## Run

Expand All @@ -9,18 +9,19 @@ From this checkout, with Docker Compose and OpenSSL installed:
```sh
mkdir -p "$HOME/.ssh"
export OPENCODE_PASSWORD="$(openssl rand -hex 32)"
export OPENCHAMBER_UI_PASSWORD="$(openssl rand -hex 32)"
docker compose up -d
```

Open <http://localhost:4096> and sign in as `opencode` with that password. Keep the password in your deployment's secret store and export it for later Compose commands; startup requires at least 32 characters.
Open <http://localhost:3000> and sign in with `OPENCHAMBER_UI_PASSWORD`. Keep both passwords in your deployment's secret store and export them for later Compose commands; startup requires at least 32 characters for each. OpenCode has no host port; only OpenChamber reaches it, over the Compose network.

The `/data` volume preserves repositories, sessions, credentials, and caches. Keep repositories in `/data/workspace` and back up the volume. Bind mounts must be writable by UID/GID 1000. Compose also mounts the host's `~/.ssh` read-only; keys must be readable by UID 1000, and `known_hosts` must be maintained on the host.

To update the image or apply changed environment variables:

```sh
docker compose pull
docker compose up -d --force-recreate opencode
docker compose up -d --force-recreate
```

## Connect accounts
Expand Down Expand Up @@ -70,7 +71,18 @@ To replace the image defaults, install dependencies, edit `opencode.jsonc`, run

## Hosting

Compose binds only to `127.0.0.1:4096`. Put an HTTPS reverse proxy in front for remote access. A containerized proxy can share a network and use `opencode:4096` instead of the host port mapping.
Compose binds OpenChamber only to `127.0.0.1:3000`. Put an HTTPS reverse proxy in front for remote access, following [OpenChamber's reverse proxy guide](https://docs.openchamber.dev/reverse-proxy/). With Caddy:

```caddyfile
code.example.com {
respond /health* 404
reverse_proxy 127.0.0.1:3000 {
flush_interval -1
}
}
```

`/health` is unauthenticated and reports diagnostics, so keep it off the internet. OpenChamber runs in [enterprise mode](https://docs.openchamber.dev/security/#enterprise-mode), which disables the relay and untrusted extensions. Login is rate limited; add a passkey under Settings after first sign-in.

The container runs as UID 1000 with dropped capabilities and resource limits. Login grants code execution and access to stored credentials; this is a trusted-user service, not a multi-tenant sandbox.

Expand Down
53 changes: 50 additions & 3 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ services:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 512
pids_limit: 2048
mem_limit: 4g
cpus: 2
environment:
Expand All @@ -17,11 +17,58 @@ services:
LUNCHMONEY_API_TOKEN: ${LUNCHMONEY_API_TOKEN:-}
GRAFANA_URL: ${GRAFANA_URL:-}
GRAFANA_SERVICE_ACCOUNT_TOKEN: ${GRAFANA_SERVICE_ACCOUNT_TOKEN:-}
KARAKEEP_API_ADDR: ${KARAKEEP_API_ADDR:-https://hoard.natwelch.com}
KARAKEEP_API_ADDR: ${KARAKEEP_API_ADDR:-}
KARAKEEP_API_KEY: ${KARAKEEP_API_KEY:-}
OPENCODE_PASSWORD: ${OPENCODE_PASSWORD:?Set OPENCODE_PASSWORD}
volumes:
- data:/data
- type: bind
source: ${HOME}/.ssh
target: /data/.ssh
read_only: true
bind:
create_host_path: false
openchamber:
image: ghcr.io/icco/opencode-server:main
restart: unless-stopped
stop_grace_period: 30s
command:
- openchamber
- serve
- --foreground
- --host
- 0.0.0.0
- --port
- "3000"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 2048
mem_limit: 4g
cpus: 2
environment:
GH_TOKEN: ${GH_TOKEN:-}
OPENCODE_HOST: http://opencode:4096
OPENCODE_SKIP_START: "true"
OPENCODE_PASSWORD: ${OPENCODE_PASSWORD:?Set OPENCODE_PASSWORD}
OPENCODE_SERVER_PASSWORD: ${OPENCODE_PASSWORD:?Set OPENCODE_PASSWORD}
OPENCHAMBER_UI_PASSWORD: ${OPENCHAMBER_UI_PASSWORD:?Set OPENCHAMBER_UI_PASSWORD}
OPENCHAMBER_ENTERPRISE_MODE: "1"
OPENCHAMBER_ALLOW_NETWORK_ACCESS: "1"
depends_on:
opencode:
condition: service_healthy
healthcheck:
test:
- CMD
- /usr/local/bin/openchamber-healthcheck.sh
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
ports:
- 127.0.0.1:4096:4096
- 127.0.0.1:3000:3000
volumes:
- data:/data
- type: bind
Expand Down
7 changes: 7 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ if [ "${#OPENCODE_PASSWORD}" -lt 32 ]; then
exit 1
fi

# OpenChamber trims the password before use, so measure what it will store.
ui_password=$(printf "%s" "${OPENCHAMBER_UI_PASSWORD:-}" | sed -e "s/^[[:space:]]*//" -e "s/[[:space:]]*\$//")
if [ "${1:-}" = openchamber ] && [ "${#ui_password}" -lt 32 ]; then
echo "OPENCHAMBER_UI_PASSWORD must contain at least 32 characters; generate it with openssl rand -hex 32" >&2
exit 1
fi

mkdir -p "$XDG_CONFIG_HOME/opencode" "$XDG_CONFIG_HOME/gh" "$XDG_DATA_HOME/opencode" \
"$XDG_STATE_HOME/opencode" "$XDG_CACHE_HOME/opencode" "$HOME/workspace/.worktrees"

Expand Down
6 changes: 6 additions & 0 deletions openchamber-healthcheck.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/bin/sh
set -eu

# Healthy only when OpenChamber reaches OpenCode with its password.
curl --fail --silent --show-error --max-time 4 http://127.0.0.1:3000/health |
jq -e '.isOpenCodeReady == true and .openCodeSecureConnection == true' >/dev/null
47 changes: 46 additions & 1 deletion scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ test "$status" = 1

docker run -d --name "$name" \
--cap-drop ALL --security-opt no-new-privileges:true \
--pids-limit 512 --memory 4g --cpus 2 \
--pids-limit 2048 --memory 4g --cpus 2 \
-p 127.0.0.1::4096 \
-e "OPENCODE_PASSWORD=$password" \
-e LUNCHMONEY_API_TOKEN=catalog-test-only \
Expand Down Expand Up @@ -96,3 +96,48 @@ docker exec "$name" sh -ec '
/usr/local/bin/entrypoint.sh true
cmp /etc/opencode/AGENTS.md "$XDG_CONFIG_HOME/opencode/AGENTS.md"
'

# OpenChamber must refuse to start without a strong UI password.
for ui in "" short " a "; do
status=0
timeout 15 docker run --rm -e "OPENCODE_PASSWORD=$password" -e "OPENCHAMBER_UI_PASSWORD=$ui" \
"$image" openchamber serve --foreground || status=$?
test "$status" = 1
done

network="opencode-test-net-$$"
chamber="openchamber-test-$$"
ui_password=$(openssl rand -hex 24)
docker network create "$network"
docker network connect --alias opencode "$network" "$name"
docker run -d --name "$chamber" --network "$network" \
--cap-drop ALL --security-opt no-new-privileges:true \
-p 127.0.0.1::3000 \
-e OPENCODE_HOST=http://opencode:4096 \
-e OPENCODE_SKIP_START=true \
-e "OPENCODE_PASSWORD=$password" \
-e "OPENCODE_SERVER_PASSWORD=$password" \
-e "OPENCHAMBER_UI_PASSWORD=$ui_password" \
-e OPENCHAMBER_ENTERPRISE_MODE=1 \
-e OPENCHAMBER_ALLOW_NETWORK_ACCESS=1 \
"$image" openchamber serve --foreground --host 0.0.0.0 --port 3000
trap 'docker logs "$chamber" || true; docker rm -f "$chamber" || true; cleanup; docker network rm "$network" || true' EXIT
chamber_url="http://127.0.0.1:$(docker port "$chamber" 3000/tcp | cut -d: -f2)"

for attempt in $(seq 1 40); do
if docker exec "$chamber" /usr/local/bin/openchamber-healthcheck.sh; then
break
fi
printf 'Waiting for OpenChamber (%s/40)\n' "$attempt"
sleep 3
done
docker exec "$chamber" /usr/local/bin/openchamber-healthcheck.sh

test "$(curl -s -o /dev/null -w '%{http_code}' "$chamber_url/api/session")" = 401
test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' \
-d '{"password":"wrong-password"}' "$chamber_url/auth/session")" = 401
jar=$(mktemp)
curl --fail -s -c "$jar" -H 'Content-Type: application/json' \
-d "{\"password\":\"$ui_password\"}" "$chamber_url/auth/session" >/dev/null
curl --fail -s -b "$jar" "$chamber_url/api/session" | jq -e 'type == "array" or type == "object"' >/dev/null
rm -f "$jar"
Loading