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
119 changes: 117 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ jobs:
bash -n tests/test_entrypoint_auth_guard.sh
bash -n tests/test_entrypoint_instruction_flag.sh
bash -n tests/test_entrypoint_home_pin.sh
bash -n tests/test_startup_vacuum.sh
bash -n tests/test_docs_apache_endpoints.sh
bash -n tests/test_help.sh
bash -n tests/test_playwright_dockerfile_contract.sh
bash -n tests/test_version_command.sh
bash -n tests/test_version_guard.sh
bash -n tests/test_self_update.sh
bash -n tests/test_install_bootstrap.sh
bash -n tests/test_auth_required.sh
Expand Down Expand Up @@ -61,10 +63,12 @@ jobs:
shellcheck -x tests/test_entrypoint_auth_guard.sh
shellcheck -x tests/test_entrypoint_instruction_flag.sh
shellcheck -x tests/test_entrypoint_home_pin.sh
shellcheck -x tests/test_startup_vacuum.sh
shellcheck -x tests/test_docs_apache_endpoints.sh
shellcheck -x tests/test_help.sh
shellcheck -x tests/test_playwright_dockerfile_contract.sh
shellcheck -x tests/test_version_command.sh
shellcheck -x tests/test_version_guard.sh
shellcheck -x tests/test_self_update.sh
shellcheck -x tests/test_install_bootstrap.sh
shellcheck -x tests/test_auth_required.sh
Expand Down Expand Up @@ -110,7 +114,116 @@ jobs:
run: docker build -f .opencode_web_yolo.Dockerfile -t opencode_web_yolo:ci .
- name: Validate required binaries
run: |
docker run --rm --entrypoint sh opencode_web_yolo:ci -lc 'command -v gh && command -v git && command -v ssh && command -v tini'
docker run --rm --entrypoint sh opencode_web_yolo:ci -lc 'command -v gh && command -v git && command -v ssh && command -v sqlite3 && command -v timeout && command -v tini && opencode serve --help'
- name: Validate real startup VACUUM
run: |
set -euo pipefail
data_dir="$(mktemp -d)"
trap 'rm -rf "$data_dir"' EXIT
mkdir -p "${data_dir}/opencode"

docker run --rm \
--user "$(id -u):$(id -g)" \
-v "${data_dir}:/data" \
--entrypoint sqlite3 \
opencode_web_yolo:ci /data/opencode/opencode.db \
'CREATE TABLE retained (id INTEGER PRIMARY KEY, value TEXT);
CREATE TABLE bulky (payload BLOB);
INSERT INTO retained(value) VALUES (zeroblob(4));
WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM seq WHERE i < 2000)
INSERT INTO bulky(payload) SELECT zeroblob(4096) FROM seq;
DELETE FROM bulky;'

before_freelist="$(docker run --rm \
-v "${data_dir}:/data" \
--entrypoint sqlite3 \
opencode_web_yolo:ci /data/opencode/opencode.db 'PRAGMA freelist_count;')"
test "${before_freelist}" -gt 0

vacuum_output="$(docker run --rm \
-e LOCAL_UID="$(id -u)" \
-e LOCAL_GID="$(id -g)" \
-e LOCAL_USER=ci \
-e OPENCODE_SERVER_PASSWORD=ci-secret \
-e OPENCODE_WEB_RETENTION_DAYS=0 \
-e XDG_DATA_HOME=/data \
-v "${data_dir}:/data" \
--entrypoint /usr/local/bin/opencode_web_yolo_entrypoint.sh \
opencode_web_yolo:ci true 2>&1)"
case "${vacuum_output}" in
*"VACUUM: compacting OpenCode database at /data/opencode/opencode.db."*) ;;
*) printf '%s\n' "${vacuum_output}" >&2; exit 1 ;;
esac
case "${vacuum_output}" in
*WARNING*) printf '%s\n' "${vacuum_output}" >&2; exit 1 ;;
esac

retained_rows="$(docker run --rm \
-v "${data_dir}:/data" \
--entrypoint sqlite3 \
opencode_web_yolo:ci /data/opencode/opencode.db 'SELECT count(*) FROM retained;')"
after_freelist="$(docker run --rm \
-v "${data_dir}:/data" \
--entrypoint sqlite3 \
opencode_web_yolo:ci /data/opencode/opencode.db 'PRAGMA freelist_count;')"
test "${retained_rows}" = 1
test "${after_freelist}" = 0
- name: Smoke test authenticated opencode serve
run: |
set -euo pipefail
smoke_dir="$(mktemp -d)"
container_name=opencode_web_yolo_ci_smoke
host_port=4097
cleanup() {
local status=$?
trap - EXIT
if [ "${status}" -ne 0 ]; then
docker logs "${container_name}" || true
fi
docker rm -f "${container_name}" >/dev/null 2>&1 || true
rm -rf "${smoke_dir}"
exit "${status}"
}
trap cleanup EXIT
mkdir -p "${smoke_dir}/config" "${smoke_dir}/data"

docker run -d \
--name "${container_name}" \
--restart unless-stopped \
-p "127.0.0.1:${host_port}:${host_port}" \
-e LOCAL_UID="$(id -u)" \
-e LOCAL_GID="$(id -g)" \
-e LOCAL_USER=ci \
-e OPENCODE_SERVER_PASSWORD=ci-secret \
-e OPENCODE_SERVER_USERNAME=opencode \
-e OPENCODE_WEB_PORT="${host_port}" \
-e HOME=/home/opencode \
-e XDG_CONFIG_HOME=/home/opencode/.config \
-e XDG_DATA_HOME=/home/opencode/.local/share \
-e XDG_STATE_HOME=/home/opencode/.local/share/opencode/state \
-v "${smoke_dir}/config:/home/opencode/.config/opencode" \
-v "${smoke_dir}/data:/home/opencode/.local/share/opencode" \
opencode_web_yolo:ci \
opencode serve --hostname 0.0.0.0 --port "${host_port}" >/dev/null

healthy=0
unauthenticated_status=''
health_body=''
for attempt in $(seq 1 60); do
if unauthenticated_status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 2 "http://127.0.0.1:${host_port}/global/health" 2>/dev/null)" \
&& [ "${unauthenticated_status}" = 401 ] \
&& health_body="$(curl -fsS --max-time 2 -u 'opencode:ci-secret' "http://127.0.0.1:${host_port}/global/health" 2>/dev/null)" \
&& node -e 'const value = JSON.parse(process.argv[1]); process.exit(value && value.healthy === true ? 0 : 1)' "${health_body}"; then
healthy=1
break
fi
if [ "$(docker inspect --format '{{.State.Running}}' "${container_name}" 2>/dev/null || true)" != true ]; then
break
fi
sleep 1
done
test "${unauthenticated_status}" = 401
test "${healthy}" = 1
- name: Build Playwright-enabled runtime image
run: |
docker build \
Expand Down Expand Up @@ -138,6 +251,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
fetch-depth: 0
- name: Validate version format and drift guard
env:
VERSION_GUARD_BASE_REF: ${{ github.event.pull_request.base.sha }}
run: bash tests/version_guard.sh
1 change: 1 addition & 0 deletions .opencode_web_yolo.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ RUN apt-get update \
gosu \
openssh-client \
passwd \
sqlite3 \
sudo \
tini \
&& rm -rf /var/lib/apt/lists/*
Expand Down
4 changes: 2 additions & 2 deletions .opencode_web_yolo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1182,7 +1182,7 @@ main() {
host_agents_log="Host instruction file mount disabled by --no-host-agents."
fi

app_cmd=(opencode web --hostname "${OPENCODE_WEB_HOSTNAME}" --port "${OPENCODE_WEB_PORT}")
app_cmd=(opencode serve --hostname "${OPENCODE_WEB_HOSTNAME}" --port "${OPENCODE_WEB_PORT}")
app_cmd+=("${passthrough[@]}")

ensure_image
Expand Down Expand Up @@ -1215,7 +1215,7 @@ main() {
printf '%s\n' "runtime_env_xdg_config_home=${runtime_xdg_config}"
printf '%s\n' "runtime_env_xdg_data_home=${runtime_xdg_data}"
printf '%s\n' "runtime_env_xdg_state_home=${runtime_xdg_state}"
printf '%s\n' "command=opencode web --hostname ${OPENCODE_WEB_HOSTNAME} --port ${OPENCODE_WEB_PORT}"
printf '%s\n' "command=opencode serve --hostname ${OPENCODE_WEB_HOSTNAME} --port ${OPENCODE_WEB_PORT}"
printf '%s\n' "env.OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME}"
printf '%s\n' "host_agents_source=${host_agents_source}"
printf '%s\n' "host_agents_path=${host_agents_path}"
Expand Down
23 changes: 23 additions & 0 deletions .opencode_web_yolo_entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-${OPENCODE_WEB_YOLO_HOME}/.config}"
XDG_DATA_HOME="${XDG_DATA_HOME:-${OPENCODE_WEB_YOLO_HOME}/.local/share}"
XDG_STATE_HOME="${XDG_STATE_HOME:-${XDG_DATA_HOME}/opencode/state}"
OPENCODE_WEB_RETENTION_DAYS="${OPENCODE_WEB_RETENTION_DAYS-0}"
STARTUP_VACUUM_BUSY_TIMEOUT_MS=5000
STARTUP_VACUUM_TERM_TIMEOUT_SECONDS=300
STARTUP_VACUUM_KILL_AFTER_SECONDS=5

case "$OPENCODE_WEB_RETENTION_DAYS" in
''|*[!0-9]*)
Expand Down Expand Up @@ -80,6 +83,26 @@ export XDG_CONFIG_HOME="${XDG_CONFIG_HOME}"
export XDG_DATA_HOME="${XDG_DATA_HOME}"
export XDG_STATE_HOME="${XDG_STATE_HOME}"

opencode_database="${XDG_DATA_HOME}/opencode/opencode.db"
if [ -f "${opencode_database}" ]; then
printf '%s\n' "[opencode_web_yolo] VACUUM: compacting OpenCode database at ${opencode_database}."
if gosu "${runtime_user}" timeout \
--kill-after="${STARTUP_VACUUM_KILL_AFTER_SECONDS}" \
"${STARTUP_VACUUM_TERM_TIMEOUT_SECONDS}" \
sqlite3 \
-cmd ".timeout ${STARTUP_VACUUM_BUSY_TIMEOUT_MS}" \
"${opencode_database}" 'VACUUM;'; then
:
else
vacuum_status=$?
if [ "$vacuum_status" -eq 124 ] || [ "$vacuum_status" -eq 137 ]; then
printf '%s\n' "[opencode_web_yolo] WARNING: startup VACUUM timed out after the ${STARTUP_VACUUM_TERM_TIMEOUT_SECONDS}-second TERM deadline (KILL escalation after ${STARTUP_VACUUM_KILL_AFTER_SECONDS} additional seconds); continuing startup." >&2
else
printf '%s\n' "[opencode_web_yolo] WARNING: startup VACUUM failed for ${opencode_database}; continuing startup." >&2
fi
fi
fi

if [ "$OPENCODE_WEB_RETENTION_DAYS" = "0" ]; then
exec env HOME="${HOME}" XDG_CONFIG_HOME="${XDG_CONFIG_HOME}" XDG_DATA_HOME="${XDG_DATA_HOME}" XDG_STATE_HOME="${XDG_STATE_HOME}" gosu "${runtime_user}" "$@"
fi
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to this project are documented here.

## [0.3.0] - 2026-09-13

- Switched runtime launches from `opencode web` to `opencode serve`.
- Added startup SQLite VACUUM maintenance for existing OpenCode databases, with mapped-user execution, a 5000 ms lock wait, TERM after 300 seconds, KILL escalation after 5 more seconds, and warning-only failure handling.
- Added `sqlite3` to the runtime image.

## [0.2.2] - 2026-09-02

- Fixed self-update and streamed installation to use a validated GitHub branch archive snapshot, including repair of incomplete installs at an equal version. Promotion is retry-safe with `VERSION` last, and malformed or incomplete releases fail closed before Docker build. Historical 0.1.10 installs on stock macOS/BSD may need the latest `install.sh` because their old updater depends on GNU `sort -V`.
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ opencode_web_yolo
Defaults:
- Port: `4096`
- Bind/publish: `127.0.0.1:4096:4096`
- OpenCode web host inside container: `0.0.0.0`
- OpenCode serve host inside container: `0.0.0.0`
- OpenCode package install target: `latest` at build time
- Playwright build: disabled by default; opt in with `OPENCODE_WEB_BUILD_PLAYWRIGHT=1` in the persistent config or `--playwright` for one run
- Container name: `opencode_web_yolo`
Expand Down Expand Up @@ -107,8 +107,8 @@ Operator-facing settings:
| --- | --- | --- |
| `OPENCODE_SERVER_PASSWORD` | none, required | Required non-empty password for OpenCode Web. Startup fails if it is missing or empty. |
| `OPENCODE_SERVER_USERNAME` | `opencode` | Login username paired with `OPENCODE_SERVER_PASSWORD`. |
| `OPENCODE_WEB_PORT` | `4096` | Host/container port used for `opencode web` and the local Docker publish mapping. |
| `OPENCODE_WEB_HOSTNAME` | `0.0.0.0` | Hostname passed to `opencode web` inside the container. |
| `OPENCODE_WEB_PORT` | `4096` | Host/container port used for `opencode serve` and the local Docker publish mapping. |
| `OPENCODE_WEB_HOSTNAME` | `0.0.0.0` | Hostname passed to `opencode serve` inside the container. |
| `OPENCODE_WEB_CONTAINER_NAME` | `opencode_web_yolo` | Docker container name used for launch, replacement, and diagnostics. |
| `OPENCODE_WEB_RESTART_POLICY` | `unless-stopped` | Docker restart policy applied to the container. |
| `OPENCODE_WEB_RUN_DETACHED` | `1` | Launch mode default. Use `1` for background mode or `0` for attached runs unless overridden by flags. |
Expand Down Expand Up @@ -152,6 +152,18 @@ Truthy toggle values such as `true`, `yes`, and `on` are accepted and normalized
Provider auth/session state (for example OpenAI and GitHub Copilot links) persists across restarts from the OpenCode data path.
The wrapper also pins runtime env (`HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`) to `/home/opencode` paths so app writes always land on mounted host directories.

On every container startup, after the mapped-user ownership and XDG setup, the entrypoint checks
`$XDG_DATA_HOME/opencode/opencode.db`. If that database exists, it runs `VACUUM;` with `sqlite3` as
the mapped runtime user, waiting up to 5000 ms for a lock. GNU `timeout` sends TERM after 300
seconds and sends KILL 5 seconds later if VACUUM is still running. A missing database is skipped
without creating one. Vacuum can add startup latency and temporarily require additional disk space
while SQLite rewrites the database. If it cannot vacuum because of a lock, permissions, corruption,
disk space, timeout, or another error, startup prints a warning and continues.

Startup VACUUM is separate from weekly retention. The retention worker remains an authenticated
OpenCode API worker: it does not use raw SQL or manually modify SQLite WAL, SHM, or journal
sidecars.

## Weekly session retention

Enable cleanup in the generated config, or override it for one invocation:
Expand Down Expand Up @@ -218,7 +230,7 @@ Run in background (with automatic startup on reboot):

```bash
export OPENCODE_SERVER_PASSWORD='change-me-now'
mkdir -p "$HOME/.config/opencode" "$HOME/.local/share/opencode" && (docker rm -f opencode_web_yolo >/dev/null 2>&1 || true) && docker run -d --name opencode_web_yolo --restart unless-stopped -p 127.0.0.1:4096:4096 -e LOCAL_UID="$(id -u)" -e LOCAL_GID="$(id -g)" -e LOCAL_USER="$(id -un)" -e OPENCODE_SERVER_PASSWORD -e HOME=/home/opencode -e XDG_CONFIG_HOME=/home/opencode/.config -e XDG_DATA_HOME=/home/opencode/.local/share -e XDG_STATE_HOME=/home/opencode/.local/share/opencode/state -v "$PWD:/workspace" -v "$HOME/.config/opencode:/home/opencode/.config/opencode" -v "$HOME/.local/share/opencode:/home/opencode/.local/share/opencode" opencode_web_yolo:latest opencode web --hostname 0.0.0.0 --port 4096
mkdir -p "$HOME/.config/opencode" "$HOME/.local/share/opencode" && (docker rm -f opencode_web_yolo >/dev/null 2>&1 || true) && docker run -d --name opencode_web_yolo --restart unless-stopped -p 127.0.0.1:4096:4096 -e LOCAL_UID="$(id -u)" -e LOCAL_GID="$(id -g)" -e LOCAL_USER="$(id -un)" -e OPENCODE_SERVER_PASSWORD -e HOME=/home/opencode -e XDG_CONFIG_HOME=/home/opencode/.config -e XDG_DATA_HOME=/home/opencode/.local/share -e XDG_STATE_HOME=/home/opencode/.local/share/opencode/state -v "$PWD:/workspace" -v "$HOME/.config/opencode:/home/opencode/.config/opencode" -v "$HOME/.local/share/opencode:/home/opencode/.local/share/opencode" opencode_web_yolo:latest opencode serve --hostname 0.0.0.0 --port 4096
```

Force-refresh image to the resolved latest OpenCode and Playwright versions:
Expand Down
12 changes: 8 additions & 4 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

- Host command: `opencode_web_yolo`
- Wrapper builds/validates runtime image and runs:
- `opencode web --hostname 0.0.0.0 --port ${OPENCODE_WEB_PORT}`
- `opencode serve --hostname 0.0.0.0 --port ${OPENCODE_WEB_PORT}`
- Docker publish contract:
- `-p 127.0.0.1:${OPENCODE_WEB_PORT}:${OPENCODE_WEB_PORT}`
- Container lifecycle defaults:
Expand Down Expand Up @@ -76,6 +76,8 @@ Docker image includes:
- `gh`
- `git`
- `openssh-client`
- `sqlite3`
- Debian coreutils `timeout`
- runtime helpers (`gosu`, `sudo`, `passwd`, `ca-certificates`)
- PID 1 init/subreaper (`tini`)
- OpenCode CLI (`opencode-ai` npm package by default)
Expand Down Expand Up @@ -103,6 +105,7 @@ Entrypoint behavior:
- avoids recursive ownership operations across read-only mount boundaries.
- installs passwordless sudo policy for mapped user.
- executes command via `gosu`.
- after ownership and HOME/XDG setup, checks `${XDG_DATA_HOME}/opencode/opencode.db`; when present, runs `VACUUM;` through `sqlite3` via `gosu` as the mapped user with a 5000 ms busy timeout. GNU `timeout` sends TERM after 300 seconds and KILL 5 seconds later if the command remains alive. Missing databases are skipped without creation. Vacuum failures or timeout expiry warn to stderr and do not block either direct or retention-supervised OpenCode launch. This startup maintenance is separate from retention, whose worker never uses raw SQL or mutates SQLite WAL/SHM files.
- when retention is enabled, starts OpenCode, waits for authenticated `/global/health`, and supervises a mapped-user scheduler; TERM/INT are forwarded and the app exit status is returned.
- Docker starts `tini -s -g` so orphaned descendants are reaped and TERM/INT are forwarded to the child process group. The supervisor preserves the received signal when forwarding it to the app.
- does not inject unsupported OpenCode CLI flags for instruction loading.
Expand Down Expand Up @@ -175,14 +178,14 @@ Controls:
- Update `VERSION` and `CHANGELOG.md` together.
- Run `bash tests/run.sh`.
- Run `bash -n` and `shellcheck` for touched shell scripts.
- Build the runtime image and verify required binaries (`gh`, `git`, `ssh`).
- Build the runtime image and verify required binaries (`gh`, `git`, `ssh`, `sqlite3`) plus `opencode serve --help`.
- Verify README/TECHNICAL accuracy for any behavior changes.

## Test and CI Strategy

Tests and CI assert:
- `bash -n` and `shellcheck` on touched shell scripts.
- dry-run output contract (local-only port mapping, opencode web command, env values, config/data mounts, lifecycle flags, detach/pull defaults).
- dry-run output contract (local-only port mapping, `opencode serve` command, env values, config/data mounts, lifecycle flags, detach/pull defaults).
- launch behavior replaces same-name containers by stopping running instances, then removing the old container before re-run.
- password gate behavior when `OPENCODE_SERVER_PASSWORD` is missing.
- `-gh` validation/mount behavior and `--mount-ssh` explicit warning/mount behavior.
Expand All @@ -191,5 +194,6 @@ Tests and CI assert:
- health output includes browser-vs-server persistence scope visibility.
- retention configuration, marker path, API schedule, and dry-run state.
- retention API compatibility, active-session skipping, serial deletion, marker retry semantics, and supervisor signal/exit behavior.
- Docker image build and runtime binary presence (`gh`, `git`, `ssh`).
- Docker image build and runtime binary presence (`gh`, `git`, `ssh`, `sqlite3`), including a successful `opencode serve --help` check.
- startup VACUUM behavior for existing and missing databases, custom XDG data paths, mapped-user invocation, SQLite timeout plus TERM/KILL escalation, warning-only failures, and continued application execution.
- `VERSION` semver format and runtime-file/version drift guard.
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.2
0.3.0
Loading
Loading